diff --git a/cypress/component/CreateModal.cy.tsx b/cypress/component/CreateModal.cy.tsx index 5d6e2f5a..0273e198 100644 --- a/cypress/component/CreateModal.cy.tsx +++ b/cypress/component/CreateModal.cy.tsx @@ -3,6 +3,7 @@ import { CreateModal } from '../../src/Components/CreateModal/CreateModal'; import Portal from '@redhat-cloud-services/frontend-components-notifications/Portal'; import { useAtomValue } from 'jotai'; import { notificationsAtom, useRemoveNotification } from '../../src/state/notificationsAtom'; +import { backendFlagAtom, store } from '../../src/state/store'; const NotificationPortal = () => { const notifications = useAtomValue(notificationsAtom); @@ -23,6 +24,10 @@ const mockDashboardResponse = { }; describe('CreateModal', () => { + beforeEach(() => { + store.set(backendFlagAtom, true); + }); + it('renders modal with title when isOpen=true', () => { cy.mount(); cy.contains('Create new blank dashboard').should('be.visible'); diff --git a/cypress/component/DuplicateModal.cy.tsx b/cypress/component/DuplicateModal.cy.tsx index bd38d0d3..478b9696 100644 --- a/cypress/component/DuplicateModal.cy.tsx +++ b/cypress/component/DuplicateModal.cy.tsx @@ -5,6 +5,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { notificationsAtom, useRemoveNotification } from '../../src/state/notificationsAtom'; import { dashboardsAtom } from '../../src/state/dashboardsAtom'; import { DashboardTemplate } from '../../src/api/dashboard-templates'; +import { backendFlagAtom, store } from '../../src/state/store'; const NotificationPortal = () => { const notifications = useAtomValue(notificationsAtom); @@ -58,6 +59,9 @@ const mockCopyResponse = { }; describe('DuplicateModal', () => { + beforeEach(() => { + store.set(backendFlagAtom, true); + }); it('renders modal with title when isOpen=true', () => { cy.mount(); diff --git a/src/App.tsx b/src/App.tsx index 628c7b63..2f8e9610 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import React from 'react'; import DefaultRoute from './Routes/Default/Default'; const App = () => { - return ; + return ; }; export default App; diff --git a/src/Components/CreateModal/CreateModal.tsx b/src/Components/CreateModal/CreateModal.tsx index 867f84ff..fec9180d 100644 --- a/src/Components/CreateModal/CreateModal.tsx +++ b/src/Components/CreateModal/CreateModal.tsx @@ -8,10 +8,12 @@ interface CreateModalProps { isOpen: boolean; onClose: () => void; onSuccess?: () => void; + layoutType?: string; } -export const CreateModal: React.FunctionComponent = ({ isOpen, onClose, onSuccess }) => { - const { name, setName, setAsHomepage, setSetAsHomepage, isLoading, error, isFormValid, createDashboard, reset } = useCreateBlankDashboard(); +export const CreateModal: React.FunctionComponent = ({ isOpen, onClose, onSuccess, layoutType }) => { + const { name, setName, setAsHomepage, setSetAsHomepage, isLoading, error, isFormValid, createDashboard, reset } = + useCreateBlankDashboard(layoutType); const addNotification = useAddNotification(); const handleNameChange = (_event: React.FormEvent, value: string) => { diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx index e22c72cc..dc604227 100644 --- a/src/Components/Header/Header.tsx +++ b/src/Components/Header/Header.tsx @@ -27,7 +27,7 @@ import { CodeIcon, CopyIcon, EditAltIcon, EllipsisVIcon, PlusCircleIcon, PlusIco import { useAtom, useSetAtom } from 'jotai'; import { drawerExpandedAtom } from '../../state/drawerExpandedAtom'; import { templateIdAtom } from '../../state/templateAtom'; -import { resetDashboardTemplate } from '../../api/dashboard-templates'; +import { useApi } from '../../hooks/useApi'; import useCurrentUser from '../../hooks/useCurrentUser'; import { WarningModal } from '@patternfly/react-component-groups'; import { Link } from 'react-router-dom'; @@ -37,7 +37,7 @@ import { CreateModal } from '../CreateModal/CreateModal'; import { ImportModal } from '../DashboardHub/ImportModal/ImportModal'; import { DuplicateModal } from '../DuplicateModal/DuplicateModal'; -export const KebabDropdown = () => { +export const KebabDropdown = ({ layoutType }: { layoutType?: string }) => { const { dashboards } = useGetDashboards(); const [isOpen, setIsOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -198,7 +198,7 @@ export const KebabDropdown = () => { toggleRef={toggleRef} popperProps={{ position: 'end' }} /> - setIsCreateModalOpen(false)} /> + setIsCreateModalOpen(false)} layoutType={layoutType} /> setIsImportModalOpen(false)} /> setIsDuplicateModalOpen(false)} /> @@ -209,6 +209,7 @@ const Controls = () => { const [isOpen, setIsOpen] = useState(false); const toggleOpen = useSetAtom(drawerExpandedAtom); const [templateId, setTemplateId] = useAtom(templateIdAtom); + const api = useApi(); return ( <> @@ -223,7 +224,7 @@ const Controls = () => { onConfirm={() => { setIsOpen(false); if (templateId > 0) { - resetDashboardTemplate(templateId).then(() => { + api.resetDashboardTemplate(templateId).then(() => { setTemplateId(NaN); }); } @@ -260,7 +261,7 @@ const Controls = () => { ); }; -const Header = () => { +const Header = ({ layoutType }: { layoutType?: string }) => { const { currentUser } = useCurrentUser(); const userName = currentUser?.first_name && currentUser?.last_name ? ` ${currentUser.first_name} ${currentUser.last_name}` : currentUser?.username; const isDashboardHub = useFlag('platform.widget-layout.dashboard-dropdown'); @@ -281,7 +282,7 @@ const Header = () => { {isDashboardHub && ( - + )} diff --git a/src/Modules/DashboardHub.tsx b/src/Modules/DashboardHub.tsx index b0c6add8..5f35f5e9 100644 --- a/src/Modules/DashboardHub.tsx +++ b/src/Modules/DashboardHub.tsx @@ -1,18 +1,26 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import Header from '../Components/DashboardHub/Header/Header'; import DashboardTable from '../Components/DashboardHub/DashboardTable/DashboardTable'; import { PageSection } from '@patternfly/react-core'; import useGetDashboards from '../hooks/useGetDashboards'; import Portal from '@redhat-cloud-services/frontend-components-notifications/Portal'; -import { useAtomValue } from 'jotai'; +import { Provider, useAtomValue, useSetAtom } from 'jotai'; +import { useFlag } from '@unleash/proxy-client-react'; import { notificationsAtom, useRemoveNotification } from '../state/notificationsAtom'; import { Route, Routes } from 'react-router-dom'; import GenericDashboardPage from './GenericDashboardPage'; +import { backendFlagAtom, store } from '../state/store'; -const DashboardHub = () => { +const DashboardHubInner = () => { const notifications = useAtomValue(notificationsAtom); const removeNotification = useRemoveNotification(); const { dashboards, fetchDashboards } = useGetDashboards(); + const setBackendFlag = useSetAtom(backendFlagAtom); + const isNewBackend = useFlag('platform.widget-layout.new-backend'); + + useEffect(() => { + setBackendFlag(isNewBackend); + }, [isNewBackend]); return ( @@ -34,4 +42,10 @@ const DashboardHub = () => { ); }; +const DashboardHub = () => ( + + + +); + export default DashboardHub; diff --git a/src/Routes/Default/Default.tsx b/src/Routes/Default/Default.tsx index aa9cfb2f..f306d548 100644 --- a/src/Routes/Default/Default.tsx +++ b/src/Routes/Default/Default.tsx @@ -1,7 +1,9 @@ import { PageSection } from '@patternfly/react-core'; import AddWidgetDrawer from '../../Components/WidgetDrawer/WidgetDrawer'; import GridLayout from '../../Components/DnDLayout/GridLayout'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { Provider, useAtomValue, useSetAtom } from 'jotai'; +import { useFlag } from '@unleash/proxy-client-react'; +import { backendFlagAtom, store } from '../../state/store'; import { lockedLayoutAtom } from '../../state/lockedLayoutAtom'; import { notificationsAtom, useRemoveNotification } from '../../state/notificationsAtom'; import Header from '../../Components/Header/Header'; @@ -13,13 +15,19 @@ import Portal from '@redhat-cloud-services/frontend-components-notifications/Por import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; import { resolvedWidgetMappingAtom } from '../../state/widgetMappingAtom'; -const DefaultRoute = (props: { layoutType?: LayoutTypes }) => { +const DefaultRouteInner = (props: { layoutType: LayoutTypes }) => { const isLayoutLocked = useAtomValue(lockedLayoutAtom); const notifications = useAtomValue(notificationsAtom); const removeNotification = useRemoveNotification(); const { template, saveTemplate, isLoaded, layoutRef } = useDashboardConfig(props.layoutType); const resolveWidgetMapping = useSetAtom(resolvedWidgetMappingAtom); const { visibilityFunctions } = useChrome(); + const setBackendFlag = useSetAtom(backendFlagAtom); + const isNewBackend = useFlag('platform.widget-layout.new-backend'); + + useEffect(() => { + setBackendFlag(isNewBackend); + }, [isNewBackend]); useEffect(() => { if (visibilityFunctions) { @@ -30,7 +38,7 @@ const DefaultRoute = (props: { layoutType?: LayoutTypes }) => { return (
-
+
@@ -40,4 +48,10 @@ const DefaultRoute = (props: { layoutType?: LayoutTypes }) => { ); }; +const DefaultRoute = (props: { layoutType: LayoutTypes }) => ( + + + +); + export default DefaultRoute; diff --git a/src/api/dashboard-templates-new.ts b/src/api/dashboard-templates-new.ts new file mode 100644 index 00000000..e3c47719 --- /dev/null +++ b/src/api/dashboard-templates-new.ts @@ -0,0 +1,367 @@ +import { LayoutItem } from 'react-grid-layout'; +import { ScalprumComponentProps } from '@scalprum/react-core'; +import { dropping_elem_id } from '../consts'; +import { VisibilityFunctions } from '@redhat-cloud-services/types'; + +const API_BASE = '/api/widget-layout/v1'; + +const getRequestHeaders = () => ({ + Accept: 'application/json', + 'Content-Type': 'application/json', +}); + +export const widgetIdSeparator = '#'; + +export type LayoutTypes = string; + +export type Variants = 'sm' | 'md' | 'lg' | 'xl'; + +export type LayoutWithTitle = LayoutItem & { title: string }; + +export type TemplateConfig = { + [k in Variants]: LayoutWithTitle[]; +}; + +export type PartialTemplateConfig = Partial; + +export type ExtendedLayoutItem = LayoutWithTitle & { + widgetType: string; + config?: WidgetConfiguration; + locked?: boolean; +}; + +export type ExtendedTemplateConfig = { + [k in Variants]: ExtendedLayoutItem[]; +}; + +export type PartialExtendedTemplateConfig = Partial; + +export type BaseTemplate = { + name: string; + displayName: string; + templateConfig: TemplateConfig; +}; + +export type DashboardTemplate = { + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userId: string; + default: boolean; + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; + dashboardName: string; +}; + +export type ExportDashboardTemplate = { + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; +}; + +export class DashboardTemplatesError extends Error { + constructor(message: string, readonly status: number, readonly response: Response) { + super(message); + + Object.defineProperty(this, 'name', { + value: new.target.name, + configurable: true, + }); + + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = new Error(message).stack; + } + } +} + +export type WidgetDefaults = { + w: number; + h: number; + maxH: number; + minH: number; + minW?: number; +}; + +export type WidgetHeaderLink = { + title?: string; + href?: string; +}; + +type VisibilityFunctionKeys = keyof VisibilityFunctions; + +export type WidgetPermission = { + method: VisibilityFunctionKeys; + args?: Parameters; +}; + +export type WidgetConfiguration = { + icon?: string; + headerLink?: WidgetHeaderLink; + title?: string; + permissions?: WidgetPermission[]; +}; + +export type WidgetMapping = { + [key: string]: Pick & { + defaults: WidgetDefaults; + config?: WidgetConfiguration; + }; +}; + +const handleErrors = (resp: Response) => { + if (!resp.ok) { + throw new DashboardTemplatesError('widget-layout API fetch error', resp.status, resp); + } +}; + +export const getWidgetIdentifier = (widgetType: string, uniqueId: string = crypto.randomUUID()) => { + return `${widgetType}${widgetIdSeparator}${uniqueId}`; +}; + +// GET /api/widget-layout/v1/{id} +export const getDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +// GET /api/widget-layout/v1/base-templates +// GET /api/widget-layout/v1/base-templates/{name} +export async function getBaseDashboardTemplate(): Promise; +export async function getBaseDashboardTemplate(type: LayoutTypes): Promise; +export async function getBaseDashboardTemplate(type?: LayoutTypes): Promise { + if (type) { + const resp = await fetch(`${API_BASE}/base-templates/${type}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + return resp.json(); + } + const resp = await fetch(`${API_BASE}/base-templates`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// GET /api/widget-layout/v1/?dashboardType={type} +export async function getDashboardTemplates(): Promise; +export async function getDashboardTemplates(type: LayoutTypes): Promise; +export async function getDashboardTemplates(type?: LayoutTypes): Promise { + const resp = await fetch(`${API_BASE}/${type ? `?dashboardType=${type}` : ''}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// GET /api/widget-layout/v1/ +export async function getUsersDashboards(): Promise { + const resp = await fetch(`${API_BASE}/`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// GET /api/widget-layout/v1/widget-mapping +export async function getWidgetMapping(): Promise { + const resp = await fetch(`${API_BASE}/widget-mapping`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// POST /api/widget-layout/v1/{id}/reset +export const resetDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}/reset`, { + method: 'POST', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +// PATCH /api/widget-layout/v1/{id} +export const patchDashboardTemplate = async ( + templateId: DashboardTemplate['id'], + data: { templateConfig: PartialTemplateConfig } +): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +// PATCH /api/widget-layout/v1/{id} +export const patchDashboardTemplateHub = async ( + templateId: DashboardTemplate['id'], + data: { templateConfig: PartialTemplateConfig } +): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +// DELETE /api/widget-layout/v1/{id} +export const deleteDashboardTemplate = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}`, { + method: 'DELETE', + headers: getRequestHeaders(), + }); + handleErrors(resp); + return resp.status === 204; +}; + +// DELETE /api/widget-layout/v1/{id} +export const deleteDashboardTemplateFromHub = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}`, { + method: 'DELETE', + headers: getRequestHeaders(), + }); + handleErrors(resp); + return resp.status === 204; +}; + +// PATCH /api/widget-layout/v1/{id}/rename +export const renameDashboardTemplate = async (templateId: DashboardTemplate['id'], data: { dashboardName: string }): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}/rename`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +// POST /api/widget-layout/v1/{id}/copy +export const copyDashboardTemplate = async (templateId: DashboardTemplate['id'], data: { dashboardName: string }): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}/copy`, { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const getDefaultTemplate = (templates: DashboardTemplate[]): DashboardTemplate | undefined => { + return templates.find((itm) => itm.default === true); +}; + +// POST /api/widget-layout/v1/{id}/default +export const setDefaultTemplate = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}/default`, { + method: 'POST', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +// // GET /api/widget-layout/v1/base-templates/{name}/fork +// export const forkBaseTemplate = async (baseTemplateName: string): Promise => { +// const resp = await fetch(`${API_BASE}/base-templates/${baseTemplateName}/fork`, { +// method: 'GET', +// headers: getRequestHeaders(), +// }); +// handleErrors(resp); +// const json = await resp.json(); +// return json; +// }; + +// POST /api/widget-layout/v1/import +export const importDashboardTemplate = async (data: { + dashboardName: string; + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; +}): Promise => { + const resp = await fetch(`${API_BASE}/import`, { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +// GET /api/widget-layout/v1/{id}/export +export const exportDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`${API_BASE}/${templateId}/export`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const mapWidgetDefaults = (id: string): [string, string] => { + const [widgetType, i] = id.split(widgetIdSeparator); + return [widgetType, i]; +}; + +export const mapTemplateConfigToExtendedTemplateConfig = (templateConfig: TemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(templateConfig) as Variants[]).forEach((key) => { + result[key] = templateConfig[key].map( + (layoutWithTitle: LayoutWithTitle): ExtendedLayoutItem => ({ + ...layoutWithTitle, + widgetType: mapWidgetDefaults(layoutWithTitle.i)[0], + }) + ); + }); + return result; +}; + +export const extendLayout = (extendedTemplateConfig: ExtendedTemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(extendedTemplateConfig) as Variants[]).forEach((key) => { + result[key] = extendedTemplateConfig[key] + .filter(({ i }) => i !== dropping_elem_id) + .map((item) => ({ + ...item, + widgetType: mapWidgetDefaults(item.i)[0], + })); + }); + return result; +}; diff --git a/src/api/dashboard-templates-old.ts b/src/api/dashboard-templates-old.ts new file mode 100644 index 00000000..06fe50e1 --- /dev/null +++ b/src/api/dashboard-templates-old.ts @@ -0,0 +1,341 @@ +import { LayoutItem } from 'react-grid-layout'; +import { ScalprumComponentProps } from '@scalprum/react-core'; +import { dropping_elem_id } from '../consts'; +import { VisibilityFunctions } from '@redhat-cloud-services/types'; + +const getRequestHeaders = () => ({ + Accept: 'application/json', + 'Content-Type': 'application/json', +}); + +export const widgetIdSeparator = '#'; + +export type LayoutTypes = string; + +export type Variants = 'sm' | 'md' | 'lg' | 'xl'; + +export type LayoutWithTitle = LayoutItem & { title: string }; + +export type TemplateConfig = { + [k in Variants]: LayoutWithTitle[]; +}; + +export type PartialTemplateConfig = Partial; + +// extended type the UI tracks but not the backend +export type ExtendedLayoutItem = LayoutWithTitle & { + widgetType: string; + config?: WidgetConfiguration; + locked?: boolean; +}; + +// extended type the UI tracks but not the backend +export type ExtendedTemplateConfig = { + [k in Variants]: ExtendedLayoutItem[]; +}; + +// extended type the UI tracks but not the backend +export type PartialExtendedTemplateConfig = Partial; + +export type BaseTemplate = { + name: string; + displayName: string; + templateConfig: TemplateConfig; +}; + +export type DashboardTemplate = { + id: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + userIdentityID: number; + default: boolean; + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; + dashboardName: string; +}; + +export type ExportDashboardTemplate = { + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; +}; + +// TODO use dynamic-plugin-sdk CustomError as base class instead +export class DashboardTemplatesError extends Error { + constructor(message: string, readonly status: number, readonly response: Response) { + super(message); + + Object.defineProperty(this, 'name', { + value: new.target.name, + configurable: true, + }); + + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, this.constructor); + } else { + this.stack = new Error(message).stack; + } + } +} + +export type WidgetDefaults = { + w: number; + h: number; + maxH: number; + minH: number; + minW?: number; +}; + +export type WidgetHeaderLink = { + title?: string; + href?: string; +}; + +type VisibilityFunctionKeys = keyof VisibilityFunctions; + +export type WidgetPermission = { + method: VisibilityFunctionKeys; + args?: Parameters; +}; + +export type WidgetConfiguration = { + icon?: string; + headerLink?: WidgetHeaderLink; + title?: string; + permissions?: WidgetPermission[]; +}; + +export type WidgetMapping = { + [key: string]: Pick & { + defaults: WidgetDefaults; + config?: WidgetConfiguration; + }; +}; + +const handleErrors = (resp: Response) => { + if (!resp.ok) { + throw new DashboardTemplatesError('chrome-service dashboard-templates API fetch error', resp.status, resp); + } +}; + +export const getWidgetIdentifier = (widgetType: string, uniqueId: string = crypto.randomUUID()) => { + return `${widgetType}${widgetIdSeparator}${uniqueId}`; +}; + +export const getDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export async function getBaseDashboardTemplate(): Promise; +export async function getBaseDashboardTemplate(type: LayoutTypes): Promise; +export async function getBaseDashboardTemplate(type?: LayoutTypes): Promise { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/base-template${type ? `?dashboard=${type}` : ''}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// Returns multiple templates for a user (user can have multiple template copies) - we will render the one marked default: true by default +export async function getDashboardTemplates(): Promise; +export async function getDashboardTemplates(type: LayoutTypes): Promise; +export async function getDashboardTemplates(type?: LayoutTypes): Promise { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates${type ? `?dashboard=${type}` : ''}`, { + // const resp = await fetch(`/api/widget-layout/v1${type ? `?dashboardType=${type}` : ''}`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +// Returns user's dashboards +export async function getUsersDashboards(): Promise { + const resp = await fetch(`/api/widget-layout/v1/`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +export async function getWidgetMapping(): Promise { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/widget-mapping`, { + // const resp = await fetch(`/api/widget-layout/v1/widget-mapping`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +} + +export const resetDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}/reset`, { + // const resp = await fetch(`/api/widget-layout/v1/${templateId}/reset`, { + method: 'POST', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +export const patchDashboardTemplate = async ( + templateId: DashboardTemplate['id'], + data: { templateConfig: PartialTemplateConfig } +): Promise => { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}`, { + // const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +export const patchDashboardTemplateHub = async ( + templateId: DashboardTemplate['id'], + data: { templateConfig: PartialTemplateConfig } +): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json.data; +}; + +export const deleteDashboardTemplate = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}`, { + method: 'DELETE', + headers: getRequestHeaders(), + }); + handleErrors(resp); + return resp.status === 204; +}; + +export const deleteDashboardTemplateFromHub = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { + method: 'DELETE', + headers: getRequestHeaders(), + }); + handleErrors(resp); + return resp.status === 204; +}; + +export const importDashboardTemplate = async (data: { + dashboardName: string; + templateBase: { + name: string; + displayName: string; + }; + templateConfig: TemplateConfig; +}): Promise => { + const resp = await fetch(`/api/widget-layout/v1/import`, { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const exportDashboardTemplate = async (templateId: number): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}/export`, { + method: 'GET', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const copyDashboardTemplate = async (templateId: DashboardTemplate['id'], data: { dashboardName: string }): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}/copy`, { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(data), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const renameDashboardTemplate = async (templateId: DashboardTemplate['id'], dashboardName: string): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}/rename`, { + method: 'PATCH', + headers: getRequestHeaders(), + body: JSON.stringify({ dashboardName }), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const getDefaultTemplate = (templates: DashboardTemplate[]): DashboardTemplate | undefined => { + return templates.find((itm) => itm.default === true); +}; + +export const setDefaultTemplate = async (templateId: DashboardTemplate['id']): Promise => { + const resp = await fetch(`/api/widget-layout/v1/${templateId}/default`, { + method: 'POST', + headers: getRequestHeaders(), + }); + handleErrors(resp); + const json = await resp.json(); + return json; +}; + +export const mapWidgetDefaults = (id: string): [string, string] => { + const [widgetType, i] = id.split(widgetIdSeparator); + return [widgetType, i]; +}; + +// Returns template enhanced with widgetTypes +export const mapTemplateConfigToExtendedTemplateConfig = (templateConfig: TemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(templateConfig) as Variants[]).forEach((key) => { + result[key] = templateConfig[key].map( + (layoutWithTitle: LayoutWithTitle): ExtendedLayoutItem => ({ + ...layoutWithTitle, + widgetType: mapWidgetDefaults(layoutWithTitle.i)[0], + }) + ); + }); + return result; +}; + +export const extendLayout = (extendedTemplateConfig: ExtendedTemplateConfig): ExtendedTemplateConfig => { + const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; + (Object.keys(extendedTemplateConfig) as Variants[]).forEach((key) => { + result[key] = extendedTemplateConfig[key] + .filter(({ i }) => i !== dropping_elem_id) + .map((item) => ({ + ...item, + widgetType: mapWidgetDefaults(item.i)[0], + })); + }); + return result; +}; diff --git a/src/api/dashboard-templates.ts b/src/api/dashboard-templates.ts index f2d66351..0b8860c8 100644 --- a/src/api/dashboard-templates.ts +++ b/src/api/dashboard-templates.ts @@ -1,326 +1,28 @@ -import { LayoutItem } from 'react-grid-layout'; -import { ScalprumComponentProps } from '@scalprum/react-core'; -import { dropping_elem_id } from '../consts'; -import { VisibilityFunctions } from '@redhat-cloud-services/types'; - -const getRequestHeaders = () => ({ - Accept: 'application/json', - 'Content-Type': 'application/json', -}); - -export const widgetIdSeparator = '#'; - -export type LayoutTypes = 'landingPage' | 'landing-landingPage'; - -export type Variants = 'sm' | 'md' | 'lg' | 'xl'; - -export type LayoutWithTitle = LayoutItem & { title: string }; - -export type TemplateConfig = { - [k in Variants]: LayoutWithTitle[]; -}; - -export type PartialTemplateConfig = Partial; - -// extended type the UI tracks but not the backend -export type ExtendedLayoutItem = LayoutWithTitle & { - widgetType: string; - config?: WidgetConfiguration; - locked?: boolean; -}; - -// extended type the UI tracks but not the backend -export type ExtendedTemplateConfig = { - [k in Variants]: ExtendedLayoutItem[]; -}; - -// extended type the UI tracks but not the backend -export type PartialExtendedTemplateConfig = Partial; - -export type BaseTemplate = { - name: string; - displayName: string; - templateConfig: TemplateConfig; -}; - -export type DashboardTemplate = { - id: number; - createdAt: string; - updatedAt: string; - deletedAt: string | null; - userIdentityID: number; - default: boolean; - templateBase: { - name: string; - displayName: string; - }; - templateConfig: TemplateConfig; - dashboardName: string; -}; - -export type ExportDashboardTemplate = { - templateBase: { - name: string; - displayName: string; - }; - templateConfig: TemplateConfig; -}; - -// TODO use dynamic-plugin-sdk CustomError as base class instead -export class DashboardTemplatesError extends Error { - constructor(message: string, readonly status: number, readonly response: Response) { - super(message); - - Object.defineProperty(this, 'name', { - value: new.target.name, - configurable: true, - }); - - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, this.constructor); - } else { - this.stack = new Error(message).stack; - } - } -} - -export type WidgetDefaults = { - w: number; - h: number; - maxH: number; - minH: number; - minW?: number; -}; - -export type WidgetHeaderLink = { - title?: string; - href?: string; -}; - -type VisibilityFunctionKeys = keyof VisibilityFunctions; - -export type WidgetPermission = { - method: VisibilityFunctionKeys; - args?: Parameters; -}; - -export type WidgetConfiguration = { - icon?: string; - headerLink?: WidgetHeaderLink; - title?: string; - permissions?: WidgetPermission[]; -}; - -export type WidgetMapping = { - [key: string]: Pick & { - defaults: WidgetDefaults; - config?: WidgetConfiguration; - }; -}; - -const handleErrors = (resp: Response) => { - if (!resp.ok) { - throw new DashboardTemplatesError('chrome-service dashboard-templates API fetch error', resp.status, resp); - } -}; - -export const getWidgetIdentifier = (widgetType: string, uniqueId: string = crypto.randomUUID()) => { - return `${widgetType}${widgetIdSeparator}${uniqueId}`; -}; - -export const getDashboardTemplate = async (templateId: number): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json; -}; - -export async function getBaseDashboardTemplate(): Promise; -export async function getBaseDashboardTemplate(type: LayoutTypes): Promise; -export async function getBaseDashboardTemplate(type?: LayoutTypes): Promise { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/base-template${type ? `?dashboard=${type}` : ''}`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -} - -// Returns multiple templates for a user (user can have multiple template copies) - we will render the one marked default: true by default -export async function getDashboardTemplates(): Promise; -export async function getDashboardTemplates(type: LayoutTypes): Promise; -export async function getDashboardTemplates(type?: LayoutTypes): Promise { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates${type ? `?dashboard=${type}` : ''}`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -} - -// Returns user's dashboards -export async function getUsersDashboards(): Promise { - const resp = await fetch(`/api/widget-layout/v1/`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -} - -export async function getWidgetMapping(): Promise { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/widget-mapping`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -} - -export const resetDashboardTemplate = async (templateId: number): Promise => { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}/reset`, { - method: 'POST', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -}; - -export const patchDashboardTemplate = async ( - templateId: DashboardTemplate['id'], - data: { templateConfig: PartialTemplateConfig } -): Promise => { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}`, { - method: 'PATCH', - headers: getRequestHeaders(), - body: JSON.stringify(data), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -}; - -export const patchDashboardTemplateHub = async ( - templateId: DashboardTemplate['id'], - data: { templateConfig: PartialTemplateConfig } -): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { - method: 'PATCH', - headers: getRequestHeaders(), - body: JSON.stringify(data), - }); - handleErrors(resp); - const json = await resp.json(); - return json.data; -}; - -export const deleteDashboardTemplate = async (templateId: DashboardTemplate['id']): Promise => { - const resp = await fetch(`/api/chrome-service/v1/dashboard-templates/${templateId}`, { - method: 'DELETE', - headers: getRequestHeaders(), - }); - handleErrors(resp); - return resp.status === 204; -}; - -export const deleteDashboardTemplateFromHub = async (templateId: DashboardTemplate['id']): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}`, { - method: 'DELETE', - headers: getRequestHeaders(), - }); - handleErrors(resp); - return resp.status === 204; -}; - -export const importDashboardTemplate = async (data: { - dashboardName: string; - templateBase: { - name: string; - displayName: string; - }; - templateConfig: TemplateConfig; -}): Promise => { - const resp = await fetch(`/api/widget-layout/v1/import`, { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify(data), - }); - handleErrors(resp); - const json = await resp.json(); - return json; -}; - -export const exportDashboardTemplate = async (templateId: number): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}/export`, { - method: 'GET', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json; -}; - -export const copyDashboardTemplate = async (templateId: DashboardTemplate['id'], data: { dashboardName: string }): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}/copy`, { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify(data), - }); - handleErrors(resp); - const json = await resp.json(); - return json; -}; - -export const getDefaultTemplate = (templates: DashboardTemplate[]): DashboardTemplate | undefined => { - return templates.find((itm) => itm.default === true); -}; - -export const setDefaultTemplate = async (templateId: DashboardTemplate['id']): Promise => { - const resp = await fetch(`/api/widget-layout/v1/${templateId}/default`, { - method: 'POST', - headers: getRequestHeaders(), - }); - handleErrors(resp); - const json = await resp.json(); - return json; -}; - -export const mapWidgetDefaults = (id: string): [string, string] => { - const [widgetType, i] = id.split(widgetIdSeparator); - return [widgetType, i]; -}; - -// Returns template enhanced with widgetTypes -export const mapTemplateConfigToExtendedTemplateConfig = (templateConfig: TemplateConfig): ExtendedTemplateConfig => { - const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; - (Object.keys(templateConfig) as Variants[]).forEach((key) => { - result[key] = templateConfig[key].map( - (layoutWithTitle: LayoutWithTitle): ExtendedLayoutItem => ({ - ...layoutWithTitle, - widgetType: mapWidgetDefaults(layoutWithTitle.i)[0], - }) - ); - }); - return result; -}; - -export const extendLayout = (extendedTemplateConfig: ExtendedTemplateConfig): ExtendedTemplateConfig => { - const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] }; - (Object.keys(extendedTemplateConfig) as Variants[]).forEach((key) => { - result[key] = extendedTemplateConfig[key] - .filter(({ i }) => i !== dropping_elem_id) - .map((item) => ({ - ...item, - widgetType: mapWidgetDefaults(item.i)[0], - })); - }); - return result; -}; +export type { + LayoutTypes, + Variants, + LayoutWithTitle, + TemplateConfig, + PartialTemplateConfig, + ExtendedLayoutItem, + ExtendedTemplateConfig, + PartialExtendedTemplateConfig, + BaseTemplate, + DashboardTemplate, + ExportDashboardTemplate, + WidgetDefaults, + WidgetHeaderLink, + WidgetPermission, + WidgetConfiguration, + WidgetMapping, +} from './dashboard-templates-new'; + +export { + widgetIdSeparator, + DashboardTemplatesError, + getWidgetIdentifier, + getDefaultTemplate, + mapWidgetDefaults, + mapTemplateConfigToExtendedTemplateConfig, + extendLayout, +} from './dashboard-templates-new'; diff --git a/src/hooks/tests/useCreateBlankDashboard.test.ts b/src/hooks/tests/useCreateBlankDashboard.test.ts index 3f1a2989..b21a41ae 100644 --- a/src/hooks/tests/useCreateBlankDashboard.test.ts +++ b/src/hooks/tests/useCreateBlankDashboard.test.ts @@ -1,9 +1,11 @@ import { act, renderHook } from '@testing-library/react'; import { useCreateBlankDashboard } from '../useCreateBlankDashboard'; -import { DashboardTemplatesError, importDashboardTemplate, setDefaultTemplate } from '../../api/dashboard-templates'; +import { DashboardTemplatesError } from '../../api/dashboard-templates'; +import { importDashboardTemplate, setDefaultTemplate } from '../../api/dashboard-templates-new'; +import { backendFlagAtom, store } from '../../state/store'; -jest.mock('../../api/dashboard-templates', () => ({ - ...jest.requireActual('../../api/dashboard-templates'), +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), importDashboardTemplate: jest.fn(), setDefaultTemplate: jest.fn(), getUsersDashboards: jest.fn().mockResolvedValue([]), @@ -17,7 +19,7 @@ const mockDashboardTemplate = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', deletedAt: null, - userIdentityID: 1, + userId: '1', default: false, templateBase: { name: 'custom', displayName: 'Custom' }, templateConfig: { sm: [], md: [], lg: [], xl: [] }, @@ -27,6 +29,7 @@ const mockDashboardTemplate = { describe('useCreateBlankDashboard', () => { beforeEach(() => { jest.clearAllMocks(); + store.set(backendFlagAtom, true); }); it('should return initial state', () => { @@ -120,7 +123,7 @@ describe('useCreateBlankDashboard', () => { expect(mockedImportDashboardTemplate).toHaveBeenCalledWith({ dashboardName: 'My Dashboard', - templateBase: { name: 'custom', displayName: 'Custom' }, + templateBase: { name: 'landing-landingPage', displayName: 'Landing Landing Page' }, templateConfig: { sm: [], md: [], lg: [], xl: [] }, }); expect(createResult).toEqual(mockDashboardTemplate); diff --git a/src/hooks/tests/useDashboardConfig.test.ts b/src/hooks/tests/useDashboardConfig.test.ts index f6ddbb64..4aeb41e1 100644 --- a/src/hooks/tests/useDashboardConfig.test.ts +++ b/src/hooks/tests/useDashboardConfig.test.ts @@ -2,14 +2,13 @@ import { act, renderHook } from '@testing-library/react'; import { createElement } from 'react'; import { Provider, createStore } from 'jotai'; import useDashboardConfig from '../useDashboardConfig'; +import { DashboardTemplate, ExtendedTemplateConfig } from '../../api/dashboard-templates'; import { - DashboardTemplate, - ExtendedTemplateConfig, getDashboardTemplates, getDefaultTemplate, mapTemplateConfigToExtendedTemplateConfig, patchDashboardTemplate, -} from '../../api/dashboard-templates'; +} from '../../api/dashboard-templates-new'; import useCurrentUser from '../useCurrentUser'; import { useAddNotification } from '../../state/notificationsAtom'; import { templateIdAtom } from '../../state/templateAtom'; @@ -20,7 +19,12 @@ jest.mock('awesome-debounce-promise', () => ({ default: (fn: (...args: any[]) => any) => fn, })); -jest.mock('../../api/dashboard-templates', () => ({ +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => true, +})); + +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), getDashboardTemplates: jest.fn(), getDefaultTemplate: jest.fn(), mapTemplateConfigToExtendedTemplateConfig: jest.fn(), @@ -50,7 +54,7 @@ const createMockDashboardTemplate = (overrides: Partial = {}) createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', deletedAt: null, - userIdentityID: 1, + userId: '1', default: true, templateBase: { name: 'test', displayName: 'Test' }, templateConfig: { sm: [], md: [], lg: [], xl: [] }, @@ -86,7 +90,7 @@ describe('useDashboardConfig', () => { it('should return initial state with isLoaded false and empty template', () => { const { wrapper } = createWrapper(); - const { result } = renderHook(() => useDashboardConfig(), { wrapper }); + const { result } = renderHook(() => useDashboardConfig('landingPage'), { wrapper }); expect(result.current.isLoaded).toBe(false); expect(result.current.template).toEqual(emptyTemplate); @@ -99,7 +103,7 @@ describe('useDashboardConfig', () => { mockedUseCurrentUser.mockReturnValue({ currentUser: undefined, isLoaded: false }); const { wrapper } = createWrapper(); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -118,7 +122,7 @@ describe('useDashboardConfig', () => { const { wrapper, store } = createWrapper(); store.set(templateIdAtom, 5); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -163,7 +167,7 @@ describe('useDashboardConfig', () => { mockedGetDashboardTemplates.mockRejectedValue(new Error('Network error')); const { wrapper } = createWrapper(); - const { result } = renderHook(() => useDashboardConfig(), { wrapper }); + const { result } = renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -188,7 +192,7 @@ describe('useDashboardConfig', () => { mockedGetDefaultTemplate.mockReturnValue(undefined as unknown as ReturnType); const { wrapper } = createWrapper(); - const { result } = renderHook(() => useDashboardConfig(), { wrapper }); + const { result } = renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -221,7 +225,7 @@ describe('useDashboardConfig', () => { Object.defineProperty(document.body, 'clientWidth', { value: 1250, configurable: true }); const { wrapper } = createWrapper(); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -237,7 +241,7 @@ describe('useDashboardConfig', () => { Object.defineProperty(document.body, 'clientWidth', { value: 1100, configurable: true }); const { wrapper } = createWrapper(); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -251,7 +255,7 @@ describe('useDashboardConfig', () => { Object.defineProperty(document.body, 'clientWidth', { value: 800, configurable: true }); const { wrapper } = createWrapper(); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -265,7 +269,7 @@ describe('useDashboardConfig', () => { Object.defineProperty(document.body, 'clientWidth', { value: 400, configurable: true }); const { wrapper } = createWrapper(); - renderHook(() => useDashboardConfig(), { wrapper }); + renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush */ @@ -288,7 +292,7 @@ describe('useDashboardConfig', () => { mockedPatchDashboardTemplate.mockResolvedValue(mockDefault); const { wrapper } = createWrapper(); - const hookResult = renderHook(() => useDashboardConfig(), { wrapper }); + const hookResult = renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { /* flush initial load */ @@ -300,7 +304,7 @@ describe('useDashboardConfig', () => { it('should skip patch when templateId < 0', async () => { mockedUseCurrentUser.mockReturnValue({ currentUser: undefined, isLoaded: false }); const { wrapper } = createWrapper(); - const { result } = renderHook(() => useDashboardConfig(), { wrapper }); + const { result } = renderHook(() => useDashboardConfig('landingPage'), { wrapper }); await act(async () => { await result.current.saveTemplate(emptyTemplate); diff --git a/src/hooks/tests/useDashboardTemplate.test.ts b/src/hooks/tests/useDashboardTemplate.test.ts index fe9df17e..a5a047d8 100644 --- a/src/hooks/tests/useDashboardTemplate.test.ts +++ b/src/hooks/tests/useDashboardTemplate.test.ts @@ -1,14 +1,12 @@ import { act, renderHook } from '@testing-library/react'; import useDashboardTemplate from '../useDashboardTemplate'; +import { DashboardTemplate, ExtendedTemplateConfig, WidgetMapping } from '../../api/dashboard-templates'; import { - DashboardTemplate, - ExtendedTemplateConfig, - WidgetMapping, getDashboardTemplate, getWidgetMapping, mapTemplateConfigToExtendedTemplateConfig, patchDashboardTemplateHub, -} from '../../api/dashboard-templates'; +} from '../../api/dashboard-templates-new'; jest.mock('awesome-debounce-promise', () => ({ __esModule: true, @@ -16,12 +14,16 @@ jest.mock('awesome-debounce-promise', () => ({ default: (fn: (...args: any[]) => any) => fn, })); -jest.mock('../../api/dashboard-templates', () => ({ +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => true, +})); + +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), getDashboardTemplate: jest.fn(), getWidgetMapping: jest.fn(), mapTemplateConfigToExtendedTemplateConfig: jest.fn(), patchDashboardTemplateHub: jest.fn(), - widgetIdSeparator: '#', })); const mockedGetDashboardTemplate = getDashboardTemplate as jest.MockedFunction; @@ -36,7 +38,7 @@ const createMockDashboardTemplate = (overrides: Partial = {}) createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', deletedAt: null, - userIdentityID: 1, + userId: '1', default: true, templateBase: { name: 'test', displayName: 'Test' }, templateConfig: { sm: [], md: [], lg: [], xl: [] }, diff --git a/src/hooks/tests/useDuplicateDashboard.test.ts b/src/hooks/tests/useDuplicateDashboard.test.ts index 33b6cdcf..87a801ba 100644 --- a/src/hooks/tests/useDuplicateDashboard.test.ts +++ b/src/hooks/tests/useDuplicateDashboard.test.ts @@ -1,9 +1,11 @@ import { act, renderHook } from '@testing-library/react'; import { useDuplicateDashboard } from '../useDuplicateDashboard'; -import { DashboardTemplate, DashboardTemplatesError, copyDashboardTemplate, setDefaultTemplate } from '../../api/dashboard-templates'; +import { DashboardTemplate, DashboardTemplatesError } from '../../api/dashboard-templates'; +import { copyDashboardTemplate, setDefaultTemplate } from '../../api/dashboard-templates-new'; +import { backendFlagAtom, store } from '../../state/store'; -jest.mock('../../api/dashboard-templates', () => ({ - ...jest.requireActual('../../api/dashboard-templates'), +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), copyDashboardTemplate: jest.fn(), setDefaultTemplate: jest.fn(), getUsersDashboards: jest.fn().mockResolvedValue([]), @@ -17,7 +19,7 @@ const mockDashboard: DashboardTemplate = { createdAt: '2024-01-01', updatedAt: '2024-01-01', deletedAt: null, - userIdentityID: 1, + userId: '1', default: false, templateBase: { name: 'test', displayName: 'Test' }, templateConfig: { sm: [], md: [], lg: [], xl: [] }, @@ -27,6 +29,7 @@ const mockDashboard: DashboardTemplate = { describe('useDuplicateDashboard', () => { beforeEach(() => { jest.clearAllMocks(); + store.set(backendFlagAtom, true); }); it('should return initial state with isLoading false, error null, and isFormValid false', () => { diff --git a/src/hooks/tests/useExportDashboard.test.ts b/src/hooks/tests/useExportDashboard.test.ts index fa5c59cf..4353e349 100644 --- a/src/hooks/tests/useExportDashboard.test.ts +++ b/src/hooks/tests/useExportDashboard.test.ts @@ -1,8 +1,14 @@ import { act, renderHook } from '@testing-library/react'; import { useExportDashboard } from '../useExportDashboard'; -import { ExportDashboardTemplate, exportDashboardTemplate } from '../../api/dashboard-templates'; +import { ExportDashboardTemplate } from '../../api/dashboard-templates'; +import { exportDashboardTemplate } from '../../api/dashboard-templates-new'; -jest.mock('../../api/dashboard-templates', () => ({ +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => true, +})); + +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), exportDashboardTemplate: jest.fn(), })); diff --git a/src/hooks/tests/useGetDashboards.test.ts b/src/hooks/tests/useGetDashboards.test.ts index d983e06f..05d6b73f 100644 --- a/src/hooks/tests/useGetDashboards.test.ts +++ b/src/hooks/tests/useGetDashboards.test.ts @@ -1,8 +1,14 @@ import { act, renderHook } from '@testing-library/react'; import useGetDashboards from '../useGetDashboards'; -import { DashboardTemplate, getUsersDashboards } from '../../api/dashboard-templates'; +import { DashboardTemplate } from '../../api/dashboard-templates'; +import { getUsersDashboards } from '../../api/dashboard-templates-new'; -jest.mock('../../api/dashboard-templates', () => ({ +jest.mock('@unleash/proxy-client-react', () => ({ + useFlag: () => true, +})); + +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), getUsersDashboards: jest.fn(), })); @@ -34,7 +40,7 @@ const mockDashboards: DashboardTemplate[] = [ createdAt: '2024-01-01', updatedAt: '2024-01-01', deletedAt: null, - userIdentityID: 1, + userId: '1', default: true, templateBase: { name: 'dashboard-1', @@ -46,7 +52,7 @@ const mockDashboards: DashboardTemplate[] = [ createdAt: '2024-01-02', updatedAt: '2024-01-02', deletedAt: null, - userIdentityID: 1, + userId: '1', default: false, templateBase: { name: 'dashboard-2', diff --git a/src/hooks/tests/useImportDashboard.test.ts b/src/hooks/tests/useImportDashboard.test.ts index b280f3be..ce4c2228 100644 --- a/src/hooks/tests/useImportDashboard.test.ts +++ b/src/hooks/tests/useImportDashboard.test.ts @@ -1,8 +1,11 @@ import { act, renderHook } from '@testing-library/react'; import { useImportDashboard } from '../useImportDashboard'; -import { DashboardTemplate, importDashboardTemplate } from '../../api/dashboard-templates'; +import { DashboardTemplate } from '../../api/dashboard-templates'; +import { importDashboardTemplate } from '../../api/dashboard-templates-new'; +import { backendFlagAtom, store } from '../../state/store'; -jest.mock('../../api/dashboard-templates', () => ({ +jest.mock('../../api/dashboard-templates-new', () => ({ + ...jest.requireActual('../../api/dashboard-templates-new'), importDashboardTemplate: jest.fn(), getUsersDashboards: jest.fn().mockResolvedValue([]), })); @@ -14,7 +17,7 @@ const mockDashboardTemplate: DashboardTemplate = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', deletedAt: null, - userIdentityID: 42, + userId: '42', default: false, templateBase: { name: 'test-base', displayName: 'Test Base' }, templateConfig: {} as DashboardTemplate['templateConfig'], @@ -24,6 +27,7 @@ const mockDashboardTemplate: DashboardTemplate = { describe('useImportDashboard', () => { beforeEach(() => { jest.clearAllMocks(); + store.set(backendFlagAtom, true); }); it('should return correct initial state', () => { diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts new file mode 100644 index 00000000..b92f0c06 --- /dev/null +++ b/src/hooks/useApi.ts @@ -0,0 +1,19 @@ +import { useFlag } from '@unleash/proxy-client-react'; +import { useMemo } from 'react'; +import * as oldApi from '../api/dashboard-templates-old'; +import * as newApi from '../api/dashboard-templates-new'; + +const mapLayoutType = (type?: string) => (type === 'landing-landingPage' ? 'landingPage' : type); + +export const useApi = () => { + const isNewBackend = useFlag('platform.widget-layout.new-backend'); + return useMemo(() => { + if (!isNewBackend) + return { + ...oldApi, + getBaseDashboardTemplate: (type?: any) => oldApi.getBaseDashboardTemplate(mapLayoutType(type) as any), + getDashboardTemplates: (type?: any) => oldApi.getDashboardTemplates(mapLayoutType(type) as any), + } as unknown as typeof newApi; + return newApi; + }, [isNewBackend]); +}; diff --git a/src/hooks/useCreateBlankDashboard.ts b/src/hooks/useCreateBlankDashboard.ts index 7403ccfe..172bda0c 100644 --- a/src/hooks/useCreateBlankDashboard.ts +++ b/src/hooks/useCreateBlankDashboard.ts @@ -32,7 +32,7 @@ type UseCreateBlankDashboardReturn = CreateBlankDashboardState & { reset: () => void; }; -export const useCreateBlankDashboard = (): UseCreateBlankDashboardReturn => { +export const useCreateBlankDashboard = (layoutType = 'landing-landingPage'): UseCreateBlankDashboardReturn => { const [state, setState] = useState(initState); const create = useSetAtom(createDashboardAtom); @@ -51,12 +51,16 @@ export const useCreateBlankDashboard = (): UseCreateBlankDashboardReturn => { setState((prev) => ({ ...prev, isLoading: true, error: null })); + // layoutType: 'landing-landingPage' → displayName: 'Landing Landing Page' try { const result = await create({ dashboardName: state.name, templateBase: { - name: 'custom', - displayName: 'Custom', + name: layoutType, + displayName: layoutType + .split(/[-]|(?=[A-Z])/) + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(' '), }, templateConfig: blankTemplateConfig, setAsHomepage: state.setAsHomepage, diff --git a/src/hooks/useDashboardConfig.ts b/src/hooks/useDashboardConfig.ts index e42667f0..f4e2721f 100644 --- a/src/hooks/useDashboardConfig.ts +++ b/src/hooks/useDashboardConfig.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAtom } from 'jotai'; import DebouncePromise from 'awesome-debounce-promise'; import { templateAtom, templateIdAtom } from '../state/templateAtom'; @@ -8,21 +8,19 @@ import { LayoutTypes, PartialTemplateConfig, Variants, - getDashboardTemplates, getDefaultTemplate, mapTemplateConfigToExtendedTemplateConfig, - patchDashboardTemplate, } from '../api/dashboard-templates'; import useCurrentUser from './useCurrentUser'; import { useAddNotification } from '../state/notificationsAtom'; +import { useApi } from './useApi'; +import { useFlag } from '@unleash/proxy-client-react'; const sidebarBreakpoints = { xl: 1250, lg: 1100, md: 800, sm: 500 }; -const debouncedPatchDashboardTemplate = DebouncePromise(patchDashboardTemplate, 1500, { - onlyResolvesLast: true, -}); - -const useDashboardConfig = (layoutType: LayoutTypes = 'landingPage') => { +const useDashboardConfig = (layoutType: LayoutTypes = 'landing-landingPage') => { + const isNewBackend = useFlag('platform.widget-layout.new-backend'); + const mappedLayoutType = !isNewBackend && layoutType === 'landing-landingPage' ? 'landingPage' : layoutType; const [isLoaded, setIsLoaded] = useState(false); const [template, setTemplate] = useAtom(templateAtom); const [templateId, setTemplateId] = useAtom(templateIdAtom); @@ -30,13 +28,16 @@ const useDashboardConfig = (layoutType: LayoutTypes = 'landingPage') => { const { currentUser } = useCurrentUser(); const addNotification = useAddNotification(); const layoutRef = useRef(null); + const api = useApi(); + const debouncedPatchDashboardTemplate = useMemo(() => DebouncePromise(api.patchDashboardTemplate, 1500, { onlyResolvesLast: true }), [api]); useEffect(() => { if (!currentUser || templateId >= 0) { return; } - getDashboardTemplates(layoutType) + api + .getDashboardTemplates(mappedLayoutType) .then((templates) => { const customDefaultTemplate = getDefaultTemplate(templates); if (!customDefaultTemplate) { @@ -94,7 +95,7 @@ const useDashboardConfig = (layoutType: LayoutTypes = 'landingPage') => { })); }); - await debouncedPatchDashboardTemplate(templateId, { templateConfig } as Parameters[1]); + await debouncedPatchDashboardTemplate(templateId, { templateConfig }); } catch (error) { console.error(error); addNotification({ diff --git a/src/hooks/useDashboardTemplate.ts b/src/hooks/useDashboardTemplate.ts index 53b04c3d..b1437ad5 100644 --- a/src/hooks/useDashboardTemplate.ts +++ b/src/hooks/useDashboardTemplate.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import DebouncePromise from 'awesome-debounce-promise'; import { DashboardTemplate, @@ -6,16 +6,10 @@ import { PartialTemplateConfig, Variants, WidgetMapping, - getDashboardTemplate, - getWidgetMapping, mapTemplateConfigToExtendedTemplateConfig, - patchDashboardTemplateHub, widgetIdSeparator, } from '../api/dashboard-templates'; - -const debouncedPatchDashboardTemplate = DebouncePromise(patchDashboardTemplateHub, 1500, { - onlyResolvesLast: true, -}); +import { useApi } from './useApi'; const remapWidgetTypes = (extendedTemplate: ExtendedTemplateConfig, widgetMapping: WidgetMapping): ExtendedTemplateConfig => { // Build reverse lookup: "landing-./RhelWidget" -> "rhel" @@ -48,7 +42,8 @@ const useDashboardTemplate = (id: number) => { const [isLoaded, setIsLoaded] = useState(false); const [error, setError] = useState(null); const [dashboard, setDashboard] = useState(); - // widget mapping + const api = useApi(); + const debouncedPatchDashboardTemplate = useMemo(() => DebouncePromise(api.patchDashboardTemplateHub, 1500, { onlyResolvesLast: true }), [api]); useEffect(() => { const fetchTemplate = async () => { @@ -56,10 +51,10 @@ const useDashboardTemplate = (id: number) => { setError(null); try { - const result = await getDashboardTemplate(id); + const result = await api.getDashboardTemplate(id); setDashboard(result); const extendedTemplateConfig = mapTemplateConfigToExtendedTemplateConfig(result.templateConfig); - const widgetMap = await getWidgetMapping(); + const widgetMap = await api.getWidgetMapping(); const remappedTemplate = remapWidgetTypes(extendedTemplateConfig, widgetMap); setTemplate(remappedTemplate); } catch (err) { @@ -91,7 +86,7 @@ const useDashboardTemplate = (id: number) => { })); }); - await debouncedPatchDashboardTemplate(id, { templateConfig } as Parameters[1]); + await debouncedPatchDashboardTemplate(id, { templateConfig }); } catch (err) { console.error(err); } diff --git a/src/hooks/useExportDashboard.ts b/src/hooks/useExportDashboard.ts index 63c78e7f..5d78cfbf 100644 --- a/src/hooks/useExportDashboard.ts +++ b/src/hooks/useExportDashboard.ts @@ -1,16 +1,18 @@ import { useState } from 'react'; -import { ExportDashboardTemplate, exportDashboardTemplate } from '../api/dashboard-templates'; +import { ExportDashboardTemplate } from '../api/dashboard-templates'; +import { useApi } from './useApi'; export const useExportDashboard = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const api = useApi(); const exportDashboard = async (id: number): Promise => { setIsLoading(true); setError(null); try { - const result = await exportDashboardTemplate(id); + const result = await api.exportDashboardTemplate(id); setIsLoading(false); return result; } catch (err) { diff --git a/src/hooks/useGetDashboards.ts b/src/hooks/useGetDashboards.ts index de377c65..875b1550 100644 --- a/src/hooks/useGetDashboards.ts +++ b/src/hooks/useGetDashboards.ts @@ -1,16 +1,17 @@ import { useEffect } from 'react'; -import { getUsersDashboards } from '../api/dashboard-templates'; import { useAtom } from 'jotai'; +import { useApi } from './useApi'; import { dashboardsAtom } from '../state/dashboardsAtom'; import useCurrentUser from './useCurrentUser'; const useGetDashboards = () => { const { currentUser } = useCurrentUser(); const [dashboards, setDashboards] = useAtom(dashboardsAtom); + const api = useApi(); const fetchDashboards = async () => { try { - const userDashboards = await getUsersDashboards(); + const userDashboards = await api.getUsersDashboards(); setDashboards(userDashboards); } catch (error) { console.error('Error fetching user dashboards:', error); diff --git a/src/state/dashboardsAtom.ts b/src/state/dashboardsAtom.ts index c94c0d97..d9ad4e86 100644 --- a/src/state/dashboardsAtom.ts +++ b/src/state/dashboardsAtom.ts @@ -1,25 +1,20 @@ import { atom } from 'jotai'; -import { - DashboardTemplate, - TemplateConfig, - copyDashboardTemplate, - deleteDashboardTemplateFromHub, - getUsersDashboards, - importDashboardTemplate, - setDefaultTemplate, -} from '../api/dashboard-templates'; +import { DashboardTemplate, TemplateConfig } from '../api/dashboard-templates'; +import { getApi } from './store'; export const dashboardsAtom = atom([]); export const deleteDashboardAtom = atom(null, async (_get, set, id: DashboardTemplate['id']) => { - await deleteDashboardTemplateFromHub(id); - const dashboards = await getUsersDashboards(); + const api = getApi(); + await api.deleteDashboardTemplateFromHub(id); + const dashboards = await api.getUsersDashboards(); set(dashboardsAtom, dashboards); }); export const setDefaultDashboardAtom = atom(null, async (_get, set, id: DashboardTemplate['id']) => { - await setDefaultTemplate(id); - const dashboards = await getUsersDashboards(); + const api = getApi(); + await api.setDefaultTemplate(id); + const dashboards = await api.getUsersDashboards(); set(dashboardsAtom, dashboards); }); @@ -30,12 +25,13 @@ export const createDashboardAtom = atom( set, data: { dashboardName: string; templateBase: { name: string; displayName: string }; templateConfig: TemplateConfig; setAsHomepage?: boolean } ) => { + const api = getApi(); const { setAsHomepage, ...importData } = data; - const result = await importDashboardTemplate(importData); + const result = await api.importDashboardTemplate(importData); if (setAsHomepage) { - await setDefaultTemplate(result.id); + await api.setDefaultTemplate(result.id); } - const dashboards = await getUsersDashboards(); + const dashboards = await api.getUsersDashboards(); set(dashboardsAtom, dashboards); return result; } @@ -44,8 +40,9 @@ export const createDashboardAtom = atom( export const importDashboardAtom = atom( null, async (_get, set, data: { dashboardName: string; templateBase: { name: string; displayName: string }; templateConfig: TemplateConfig }) => { - const result = await importDashboardTemplate(data); - const dashboards = await getUsersDashboards(); + const api = getApi(); + const result = await api.importDashboardTemplate(data); + const dashboards = await api.getUsersDashboards(); set(dashboardsAtom, dashboards); return result; } @@ -54,11 +51,12 @@ export const importDashboardAtom = atom( export const duplicateDashboardAtom = atom( null, async (_get, set, data: { id: DashboardTemplate['id']; dashboardName: string; setAsHomepage?: boolean }) => { - const result = await copyDashboardTemplate(data.id, { dashboardName: data.dashboardName }); + const api = getApi(); + const result = await api.copyDashboardTemplate(data.id, { dashboardName: data.dashboardName }); if (data.setAsHomepage) { - await setDefaultTemplate(result.id); + await api.setDefaultTemplate(result.id); } - const dashboards = await getUsersDashboards(); + const dashboards = await api.getUsersDashboards(); set(dashboardsAtom, dashboards); return result; } diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 00000000..89e00519 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,12 @@ +import { atom, createStore } from 'jotai'; +import * as oldApi from '../api/dashboard-templates-old'; +import * as newApi from '../api/dashboard-templates-new'; + +export const store = createStore(); + +export const backendFlagAtom = atom(false); + +export const getApi = (): typeof newApi => { + const isNew = store.get(backendFlagAtom); + return (isNew ? newApi : oldApi) as typeof newApi; +}; diff --git a/src/state/widgetMappingAtom.ts b/src/state/widgetMappingAtom.ts index 19d6a6ca..015173b1 100644 --- a/src/state/widgetMappingAtom.ts +++ b/src/state/widgetMappingAtom.ts @@ -1,7 +1,7 @@ import { atom } from 'jotai'; import type { VisibilityFunctions } from '@redhat-cloud-services/types'; import type { WidgetMapping, WidgetPermission } from '../api/dashboard-templates'; -import { getWidgetMapping } from '../api/dashboard-templates'; +import { getApi } from './store'; const checkPermissions = async (visibilityFunctions: VisibilityFunctions, permissions: WidgetPermission[]): Promise => { const results = await Promise.all( @@ -24,7 +24,8 @@ const checkPermissions = async (visibilityFunctions: VisibilityFunctions, permis export const widgetMappingAtom = atom({}); export const resolvedWidgetMappingAtom = atom(null, async (_get, set, visibilityFunctions: VisibilityFunctions) => { - const mapping = await getWidgetMapping(); + const api = getApi(); + const mapping = await api.getWidgetMapping(); if (!mapping) { return;