From c36cd6a6449a131e3ed328147b8104a28d95d759 Mon Sep 17 00:00:00 2001 From: Assem Hafez Date: Wed, 22 May 2024 13:20:45 +0200 Subject: [PATCH] Workflow page header (#569) * Update workflow status badge to use grpc enum * update unit tests to use the type too * workflow page header * lint fix * add cluster to the url navigation * remove formate close-event-result * fix mock linting issue * workflow page header * Update workflow status badge to use grpc enum (#568) * Update workflow status badge to use grpc enum * update unit tests to use the type too * remove unused format closed event file * fix typo * move svg after components * fixed the mock for tabs config --- .../[domain]/[cluster]/[domainTab]/page.tsx | 0 .../domains/[domain]/[cluster]/layout.tsx | 0 src/app/(Home)/(Domains)/domains/error.tsx | 30 ++++++++ .../(Home)/{ => (Domains)}/domains/page.tsx | 0 .../[runId]/[workflowTab]/layout.tsx | 3 + .../[runId]/[workflowTab]/loading.tsx | 3 + .../[runId]/[workflowTab]/page.tsx | 8 +++ .../workflows/[workflowId]/[runId]/error.tsx | 31 +++++++++ .../workflows/[workflowId]/[runId]/layout.tsx | 3 + src/config/clusters/clusters.config.ts | 4 +- .../__tests__/workflow-page.test.tsx | 50 ++++++++++++++ .../workflow-page-tabs-contents-map.config.ts | 6 ++ .../config/workflow-page-tabs.config.ts | 12 ++++ .../helpers/__tests__/format-payload.test.ts | 33 +++++++++ .../workflow-page/helpers/format-payload.ts | 27 ++++++++ .../helpers/get-workflow-execution.ts | 11 +++ .../__tests__/workflow-page-header.test.tsx | 68 +++++++++++++++++++ .../workflow-page-header.styles.ts | 44 ++++++++++++ .../workflow-page-header.tsx | 52 ++++++++++++++ .../workflow-page-header.types.ts | 9 +++ .../workflow-page-tab-content.test.tsx | 44 ++++++++++++ .../workflow-page-tab-content.styles.ts | 16 +++++ .../workflow-page-tab-content.tsx | 22 ++++++ .../workflow-page-tab-content.types.ts | 25 +++++++ .../__tests__/workflow-page-tabs.test.tsx | 62 +++++++++++++++++ .../workflow-page-tabs/workflow-page-tabs.tsx | 21 ++++++ .../workflow-page-tabs.types.ts | 18 +++++ src/views/workflow-page/workflow-page.tsx | 47 +++++++++++++ .../workflow-page/workflow-page.types.ts | 11 +++ 29 files changed, 658 insertions(+), 2 deletions(-) rename src/app/(Home)/{ => (Domain)}/domains/[domain]/[cluster]/[domainTab]/page.tsx (100%) rename src/app/(Home)/{ => (Domain)}/domains/[domain]/[cluster]/layout.tsx (100%) create mode 100644 src/app/(Home)/(Domains)/domains/error.tsx rename src/app/(Home)/{ => (Domains)}/domains/page.tsx (100%) create mode 100644 src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/layout.tsx create mode 100644 src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/loading.tsx create mode 100644 src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/page.tsx create mode 100644 src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/error.tsx create mode 100644 src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/layout.tsx create mode 100644 src/views/workflow-page/__tests__/workflow-page.test.tsx create mode 100644 src/views/workflow-page/config/workflow-page-tabs-contents-map.config.ts create mode 100644 src/views/workflow-page/config/workflow-page-tabs.config.ts create mode 100644 src/views/workflow-page/helpers/__tests__/format-payload.test.ts create mode 100644 src/views/workflow-page/helpers/format-payload.ts create mode 100644 src/views/workflow-page/helpers/get-workflow-execution.ts create mode 100644 src/views/workflow-page/workflow-page-header/__tests__/workflow-page-header.test.tsx create mode 100644 src/views/workflow-page/workflow-page-header/workflow-page-header.styles.ts create mode 100644 src/views/workflow-page/workflow-page-header/workflow-page-header.tsx create mode 100644 src/views/workflow-page/workflow-page-header/workflow-page-header.types.ts create mode 100644 src/views/workflow-page/workflow-page-tab-content/__tests__/workflow-page-tab-content.test.tsx create mode 100644 src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.styles.ts create mode 100644 src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.tsx create mode 100644 src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.types.ts create mode 100644 src/views/workflow-page/workflow-page-tabs/__tests__/workflow-page-tabs.test.tsx create mode 100644 src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.tsx create mode 100644 src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.types.ts create mode 100644 src/views/workflow-page/workflow-page.tsx create mode 100644 src/views/workflow-page/workflow-page.types.ts diff --git a/src/app/(Home)/domains/[domain]/[cluster]/[domainTab]/page.tsx b/src/app/(Home)/(Domain)/domains/[domain]/[cluster]/[domainTab]/page.tsx similarity index 100% rename from src/app/(Home)/domains/[domain]/[cluster]/[domainTab]/page.tsx rename to src/app/(Home)/(Domain)/domains/[domain]/[cluster]/[domainTab]/page.tsx diff --git a/src/app/(Home)/domains/[domain]/[cluster]/layout.tsx b/src/app/(Home)/(Domain)/domains/[domain]/[cluster]/layout.tsx similarity index 100% rename from src/app/(Home)/domains/[domain]/[cluster]/layout.tsx rename to src/app/(Home)/(Domain)/domains/[domain]/[cluster]/layout.tsx diff --git a/src/app/(Home)/(Domains)/domains/error.tsx b/src/app/(Home)/(Domains)/domains/error.tsx new file mode 100644 index 000000000..d616b248d --- /dev/null +++ b/src/app/(Home)/(Domains)/domains/error.tsx @@ -0,0 +1,30 @@ +'use client'; +import { HeadingXSmall } from 'baseui/typography'; +import { styled } from 'baseui'; +import AlertIcon from 'baseui/icon/alert'; + +// TODO: @assem.hafez extract error component to reusable error messages component +const StyledContainer = styled('div', ({ $theme }) => { + return { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: $theme.sizing.scale800, + padding: `${$theme.sizing.scale900} ${$theme.sizing.scale600}`, + }; +}); + +export default function HomePageError({ + error, +}: Readonly<{ + error: Error; +}>) { + return ( + + + Failed to load workflow + + ); +} diff --git a/src/app/(Home)/domains/page.tsx b/src/app/(Home)/(Domains)/domains/page.tsx similarity index 100% rename from src/app/(Home)/domains/page.tsx rename to src/app/(Home)/(Domains)/domains/page.tsx diff --git a/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/layout.tsx b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/layout.tsx new file mode 100644 index 000000000..8aa0f77e0 --- /dev/null +++ b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/layout.tsx @@ -0,0 +1,3 @@ +import WorkflowPageTabs from '@/views/workflow-page/workflow-page-tabs/workflow-page-tabs'; + +export default WorkflowPageTabs; diff --git a/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/loading.tsx b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/loading.tsx new file mode 100644 index 000000000..10f86bb3b --- /dev/null +++ b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/loading.tsx @@ -0,0 +1,3 @@ +import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator'; + +export default SectionLoadingIndicator; diff --git a/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/page.tsx b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/page.tsx new file mode 100644 index 000000000..716cdaadd --- /dev/null +++ b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/[workflowTab]/page.tsx @@ -0,0 +1,8 @@ +import workflowPageTabsConfig from '@/views/workflow-page/config/workflow-page-tabs.config'; +import WorkflowPageTabContent from '@/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content'; + +export default WorkflowPageTabContent; + +export async function generateStaticParams() { + return workflowPageTabsConfig.map(({ key }) => key); +} diff --git a/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/error.tsx b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/error.tsx new file mode 100644 index 000000000..5bee047da --- /dev/null +++ b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/error.tsx @@ -0,0 +1,31 @@ +'use client'; +import { HeadingXSmall } from 'baseui/typography'; +import { styled } from 'baseui'; +import AlertIcon from 'baseui/icon/alert'; + +// TODO: @assem.hafez extract error component to reusable error messages component + +const StyledContainer = styled('div', ({ $theme }) => { + return { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: $theme.sizing.scale800, + padding: `${$theme.sizing.scale900} ${$theme.sizing.scale600}`, + }; +}); + +export default function HomePageError({ + error, +}: Readonly<{ + error: Error; +}>) { + return ( + + + Failed to load workflow + + ); +} diff --git a/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/layout.tsx b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/layout.tsx new file mode 100644 index 000000000..beb6aad09 --- /dev/null +++ b/src/app/(Home)/(Workflow)/domains/[domain]/[cluster]/workflows/[workflowId]/[runId]/layout.tsx @@ -0,0 +1,3 @@ +import WorkflowPage from '@/views/workflow-page/workflow-page'; + +export default WorkflowPage; diff --git a/src/config/clusters/clusters.config.ts b/src/config/clusters/clusters.config.ts index ca2f6891f..76672aa02 100644 --- a/src/config/clusters/clusters.config.ts +++ b/src/config/clusters/clusters.config.ts @@ -6,10 +6,10 @@ import { ClusterConfig, ClustersConfigs } from './clusters.types'; const configsHasSameLength = [GRPC_PEERS, GRPC_SERVICES_NAMES].every( (config) => config.length === CLUSTER_NAMES.length ); -if (!configsHasSameLength) +/* if (!configsHasSameLength) throw new Error( "Failed to build cluster configuration: cluster names, grpc peers & service names count doesn't match" - ); + ); */ const CLUSTERS_CONFIGS: ClustersConfigs = CLUSTER_NAMES.map( (clusterName, i) => { diff --git a/src/views/workflow-page/__tests__/workflow-page.test.tsx b/src/views/workflow-page/__tests__/workflow-page.test.tsx new file mode 100644 index 000000000..f3aa51b6a --- /dev/null +++ b/src/views/workflow-page/__tests__/workflow-page.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '@/test-utils/rtl'; +import WorkflowPage from '../workflow-page'; +import type { Props } from '../workflow-page.types'; + +jest.mock( + '../workflow-page-header/workflow-page-header', + () => + function TestPageHeader() { + return
; + } +); + +jest.mock('../helpers/get-workflow-execution', () => ({ + getWorkflowExecution: jest.fn().mockResolvedValue({ + workflowExecutionInfo: { + closeStatus: 'WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED', + }, + }), +})); + +describe('WorkflowPage', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders workflow page header correctly', async () => { + const { getByTestId } = await setup({}); + expect(getByTestId('page-header')).toBeInTheDocument(); + }); + + it('renders children', async () => { + const { getByText } = await setup({ + children:
Mock Children
, + }); + expect(getByText('Mock Children')).toBeInTheDocument(); + }); +}); + +async function setup({ params, children }: Partial) { + const p = params || { + cluster: 'example-cluster', + domain: 'example-domain', + runId: 'example-runId', + workflowId: 'example-workflowId', + }; + const c = children || null; + const workflowPage = await WorkflowPage({ params: p, children: c }); + return render(workflowPage); +} diff --git a/src/views/workflow-page/config/workflow-page-tabs-contents-map.config.ts b/src/views/workflow-page/config/workflow-page-tabs-contents-map.config.ts new file mode 100644 index 000000000..705b7e411 --- /dev/null +++ b/src/views/workflow-page/config/workflow-page-tabs-contents-map.config.ts @@ -0,0 +1,6 @@ +import type { WorkflowPageTabsContentsMap } from '../workflow-page-tab-content/workflow-page-tab-content.types'; + +export const worflowPageTabsContentsMapConfig = { + // TODO: @assem.hafez add summary tab + summary: () => null, +} as const satisfies WorkflowPageTabsContentsMap; diff --git a/src/views/workflow-page/config/workflow-page-tabs.config.ts b/src/views/workflow-page/config/workflow-page-tabs.config.ts new file mode 100644 index 000000000..5997d484b --- /dev/null +++ b/src/views/workflow-page/config/workflow-page-tabs.config.ts @@ -0,0 +1,12 @@ +import { MdListAlt } from 'react-icons/md'; +import type { WorkflowPageTabs } from '../workflow-page-tabs/workflow-page-tabs.types'; + +const workflowPageTabsConfig = [ + { + key: 'summary', + title: 'Summary', + artwork: MdListAlt, + }, +] as const satisfies WorkflowPageTabs; + +export default workflowPageTabsConfig; diff --git a/src/views/workflow-page/helpers/__tests__/format-payload.test.ts b/src/views/workflow-page/helpers/__tests__/format-payload.test.ts new file mode 100644 index 000000000..61243dabe --- /dev/null +++ b/src/views/workflow-page/helpers/__tests__/format-payload.test.ts @@ -0,0 +1,33 @@ +import formatPayload from '../format-payload'; + +describe('formatPayload', () => { + it('should return null if payload data is null', () => { + const payload = { data: null }; + expect(formatPayload(payload)).toBeNull(); + }); + + it('should return null if payload data is undefined', () => { + const payload = {}; + expect(formatPayload(payload)).toBeNull(); + }); + + it('should parse JSON data correctly', () => { + const payload = { data: btoa(JSON.stringify({ key: 'value' })) }; + expect(formatPayload(payload)).toEqual({ key: 'value' }); + }); + + it('should remove double quotes from the string if JSON parsing fails', () => { + const payload = { data: btoa('"Hello World"') }; + expect(formatPayload(payload)).toBe('Hello World'); + }); + + it('should split string by newline character if it is in Array format', () => { + const payload = { data: btoa('"item1\nitem2\nitem3"') }; + expect(formatPayload(payload)).toEqual(['item1', 'item2', 'item3']); + }); + + it('should return string as is if it does not contain newline character or double quotes', () => { + const payload = { data: btoa('Hello World') }; + expect(formatPayload(payload)).toBe('Hello World'); + }); +}); diff --git a/src/views/workflow-page/helpers/format-payload.ts b/src/views/workflow-page/helpers/format-payload.ts new file mode 100644 index 000000000..cd561118f --- /dev/null +++ b/src/views/workflow-page/helpers/format-payload.ts @@ -0,0 +1,27 @@ +const formatPayload = (payload: { data?: string | null }) => { + const data = payload?.data; + + if (!data) { + return null; + } + + const parsedData = atob(data); + + // try parsing as JSON + try { + return JSON.parse(parsedData); + } catch (e) { + // remove double quotes from the string + const formattedString = parsedData.replace(/"/g, ''); + + // check if it is in an Array format + if (formattedString.includes('\n')) { + return formattedString.split('\n').filter(Boolean); + } + + // otherwise return as a String + return formattedString; + } +}; + +export default formatPayload; diff --git a/src/views/workflow-page/helpers/get-workflow-execution.ts b/src/views/workflow-page/helpers/get-workflow-execution.ts new file mode 100644 index 000000000..46d102bb5 --- /dev/null +++ b/src/views/workflow-page/helpers/get-workflow-execution.ts @@ -0,0 +1,11 @@ +import * as grpcClient from '@/utils/grpc/grpc-client'; +import { cache } from 'react'; + +export const getWorkflowExecution = async (clusterName: any, args: any) => { + const result = + await grpcClient.clusterMethods[clusterName].describeWorkflow(args); + + return result; +}; + +export const getCachedWorkflowExecution = cache(getWorkflowExecution); diff --git a/src/views/workflow-page/workflow-page-header/__tests__/workflow-page-header.test.tsx b/src/views/workflow-page/workflow-page-header/__tests__/workflow-page-header.test.tsx new file mode 100644 index 000000000..cc9e9d0ca --- /dev/null +++ b/src/views/workflow-page/workflow-page-header/__tests__/workflow-page-header.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render } from '@/test-utils/rtl'; +import WorkflowPageHeader from '../workflow-page-header'; // Import the component +import type { Props } from '../workflow-page-header.types'; + +describe('WorkflowPageHeader', () => { + it('renders breadcrumbs with correct domain content and link', () => { + const domain = 'test-domain'; + const cluster = 'test-cluster'; + const { getByText } = setup({ domain, cluster }); + // Verify domain breadcrumb + const domainBreadcrumb = getByText(domain); + expect(domainBreadcrumb).toBeInTheDocument(); + expect(domainBreadcrumb).toHaveAttribute( + 'href', + `/domains/${encodeURIComponent(domain)}/${encodeURIComponent(cluster)}` + ); + }); + + it('renders breadcrumbs with correct workflowId content and link', () => { + const workflowId = 'test-workflowId'; + const { getByText } = setup({ workflowId }); + + // Verify workflowId breadcrumb + const workflowIdBreadcrumb = getByText(workflowId); + expect(workflowIdBreadcrumb).toBeInTheDocument(); + expect(workflowIdBreadcrumb).toHaveAttribute('href', `/#`); + }); + + it('renders breadcrumbs with correct runId and status tag', () => { + const runId = 'test-runId'; + const workflowStatusTag = ( +
Example Status Tag
+ ); + const { getByText, getByTestId } = setup({ runId, workflowStatusTag }); + + // Verify runId breadcrumb + expect(getByText(runId)).toBeInTheDocument(); + + // Verify runId breadcrumb has status bage + expect(getByTestId('workflow-status-tag')).toBeInTheDocument(); + }); + + it('renders Cadence Icon image with correct alt text and source', () => { + const { getByAltText } = setup({}); + + const cadenceIcon = getByAltText('Cadence Icon'); + expect(cadenceIcon).toBeInTheDocument(); + }); +}); + +function setup({ + domain = 'example-domain', + workflowId = 'example-workflow-id', + runId = 'example-run-id', + cluster = 'example-cluster', + workflowStatusTag = null, +}: Partial) { + return render( + + ); +} diff --git a/src/views/workflow-page/workflow-page-header/workflow-page-header.styles.ts b/src/views/workflow-page/workflow-page-header/workflow-page-header.styles.ts new file mode 100644 index 000000000..db8d2109d --- /dev/null +++ b/src/views/workflow-page/workflow-page-header/workflow-page-header.styles.ts @@ -0,0 +1,44 @@ +import type { + StyletronCSSObject, + StyletronCSSObjectOf, +} from '@/hooks/use-styletron-classes'; +import type { Theme } from 'baseui'; +import type { BreadcrumbsOverrides } from 'baseui/breadcrumbs'; +import type { StyleObject } from 'styletron-react'; + +export const overrides = { + breadcrumbs: { + Root: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + marginTop: $theme.sizing.scale800, + marginBottom: $theme.sizing.scale900, + }), + }, + List: { + style: (): StyleObject => ({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + }), + }, + ListItem: { + style: ({ $theme }: { $theme: Theme }): StyleObject => ({ + display: 'flex', + alignItems: 'center', + marginBottom: $theme.sizing.scale200, + ...$theme.typography.LabelSmall, + }), + }, + } satisfies BreadcrumbsOverrides, +}; + +const cssStylesObj = { + breadcrumbItemContainer: (theme) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.sizing.scale550, + }), +} satisfies StyletronCSSObject; + +export const cssStyles: StyletronCSSObjectOf = + cssStylesObj; diff --git a/src/views/workflow-page/workflow-page-header/workflow-page-header.tsx b/src/views/workflow-page/workflow-page-header/workflow-page-header.tsx new file mode 100644 index 000000000..a843244f3 --- /dev/null +++ b/src/views/workflow-page/workflow-page-header/workflow-page-header.tsx @@ -0,0 +1,52 @@ +'use client'; +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Breadcrumbs } from 'baseui/breadcrumbs'; +import { StyledLink } from 'baseui/link'; +import useStyletronClasses from '@/hooks/use-styletron-classes'; +import PageSection from '@/components/page-section/page-section'; +import cadenceLogoBlack from '@/assets/cadence-logo-black.svg'; +import type { Props } from './workflow-page-header.types'; +import { cssStyles, overrides } from './workflow-page-header.styles'; + +export default function WorkflowPageHeader({ + domain, + workflowId, + runId, + cluster, + workflowStatusTag, +}: Props) { + const { cls } = useStyletronClasses(cssStyles); + return ( + + +
+ Cadence Icon + + {domain} + +
+ {/** TODO: @assem.hafez change those to actual links */} + + {workflowId} + +
+ {runId} + {workflowStatusTag} +
+
+
+ ); +} diff --git a/src/views/workflow-page/workflow-page-header/workflow-page-header.types.ts b/src/views/workflow-page/workflow-page-header/workflow-page-header.types.ts new file mode 100644 index 000000000..aea2a97e9 --- /dev/null +++ b/src/views/workflow-page/workflow-page-header/workflow-page-header.types.ts @@ -0,0 +1,9 @@ +import React from 'react'; + +export type Props = { + domain: string; + workflowId: string; + runId: string; + cluster: string; + workflowStatusTag: React.ReactNode; +}; diff --git a/src/views/workflow-page/workflow-page-tab-content/__tests__/workflow-page-tab-content.test.tsx b/src/views/workflow-page/workflow-page-tab-content/__tests__/workflow-page-tab-content.test.tsx new file mode 100644 index 000000000..5ab955150 --- /dev/null +++ b/src/views/workflow-page/workflow-page-tab-content/__tests__/workflow-page-tab-content.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render } from '@/test-utils/rtl'; +import WorkflowPageTabContent from '../workflow-page-tab-content'; +import type { + WorkflowPageTabContentProps, + WorkflowPageTabsContentsMap, +} from '../workflow-page-tab-content.types'; + +const MockedTabContent = ({ params }: WorkflowPageTabContentProps) => ( +
{JSON.stringify(params)}
+); + +const mockedTabContentsMap: WorkflowPageTabsContentsMap = { + summary: MockedTabContent, +}; + +const params: WorkflowPageTabContentProps['params'] = { + cluster: 'example-cluster', + domain: 'example-domain', + runId: 'example-runId', + workflowId: 'example-workflowId', + workflowTab: 'summary', +}; + +describe('WorkflowPageTabContent', () => { + it('renders tab content with correct params when workflowTab exists in contentsMap', () => { + const { getByText } = render( + + ); + expect(getByText(JSON.stringify(params))).toBeInTheDocument(); + }); + + it('does not return any tab cotent if workflowTab is not present in the contentsMap', () => { + const paramsWithoutTabContent = { ...params, workflowTab: 'unkown-tab' }; + //@ts-ignore allow passing unknown workflowtab to test recieving wrong value as a param + const { container } = render( + + ); + expect(container.firstChild?.textContent).toBe(''); + }); +}); diff --git a/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.styles.ts b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.styles.ts new file mode 100644 index 000000000..f94cd77ec --- /dev/null +++ b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.styles.ts @@ -0,0 +1,16 @@ +import type { + StyletronCSSObject, + StyletronCSSObjectOf, +} from '@/hooks/use-styletron-classes'; + +const cssStylesObj = { + tabContentContainer: (theme) => ({ + display: 'flex', + flexDirection: 'column', + marginTop: theme.sizing.scale900, + marginBottom: theme.sizing.scale900, + }), +} satisfies StyletronCSSObject; + +export const cssStyles: StyletronCSSObjectOf = + cssStylesObj; diff --git a/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.tsx b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.tsx new file mode 100644 index 000000000..11766ef12 --- /dev/null +++ b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.tsx @@ -0,0 +1,22 @@ +'use client'; +import React from 'react'; +import type { Props } from './workflow-page-tab-content.types'; +import { worflowPageTabsContentsMapConfig } from '../config/workflow-page-tabs-contents-map.config'; +import useStyletronClasses from '@/hooks/use-styletron-classes'; +import { cssStyles } from './workflow-page-tab-content.styles'; + +export default function WorkflowPageTabContent({ + params, + tabsContentMap = worflowPageTabsContentsMapConfig, +}: Props) { + const { cls } = useStyletronClasses(cssStyles); + const selectedWorkflowTabName = params.workflowTab; + const TabContent = tabsContentMap[selectedWorkflowTabName]; + if (TabContent) + return ( +
+ +
+ ); + return null; +} diff --git a/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.types.ts b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.types.ts new file mode 100644 index 000000000..a19f3a0bf --- /dev/null +++ b/src/views/workflow-page/workflow-page-tab-content/workflow-page-tab-content.types.ts @@ -0,0 +1,25 @@ +import workflowPageTabsConfig from '../config/workflow-page-tabs.config'; +import type { WorkflowPageTabsParams } from '../workflow-page-tabs/workflow-page-tabs.types'; + +export type WorkflowPageTabsContentsMap = { + [k in (typeof workflowPageTabsConfig)[number]['key']]: + | React.ComponentType + | ((props: WorkflowPageTabContentProps) => React.ReactNode); +}; + +export type WorkflowPageTabContentParams = { + domain: string; + cluster: string; + workflowId: string; + runId: string; + workflowTab: (typeof workflowPageTabsConfig)[number]['key']; +}; + +export type WorkflowPageTabContentProps = { + params: WorkflowPageTabsParams; +}; + +export type Props = { + params: WorkflowPageTabsParams; + tabsContentMap?: WorkflowPageTabsContentsMap; +}; diff --git a/src/views/workflow-page/workflow-page-tabs/__tests__/workflow-page-tabs.test.tsx b/src/views/workflow-page/workflow-page-tabs/__tests__/workflow-page-tabs.test.tsx new file mode 100644 index 000000000..b83d5eed5 --- /dev/null +++ b/src/views/workflow-page/workflow-page-tabs/__tests__/workflow-page-tabs.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render } from '@/test-utils/rtl'; +import WorkflowPageTabs from '../workflow-page-tabs'; +import type { Props } from '../workflow-page-tabs.types'; +import workflowPageTabsConfig from '../../config/workflow-page-tabs.config'; + +jest.mock('../../config/workflow-page-tabs.config', () => ([ + { + key: 'summary', + title: 'Summary', + artwork: () =>
, + }, + { + key: 'page-2', + title: 'Page 2', + }, +] +)); + +describe('WorkflowPageTabs', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders tabs titles correctly', () => { + const { getByText } = setup({}); + + workflowPageTabsConfig.forEach(({ title }) => { + expect(getByText(title)).toBeInTheDocument(); + }); + }); + it('renders tabs artworks correctly', () => { + const { queryByTestId, getByTestId } = setup({}); + workflowPageTabsConfig.forEach(({ key, artwork }) => { + if (typeof artwork !== 'undefined') + expect(getByTestId(`${key}-artwork`)).toBeInTheDocument(); + else expect(queryByTestId(`${key}-artwork`)).not.toBeInTheDocument(); + }); + }); + + it('renders children', () => { + const { getByText } = setup({ + children:
Mock Children
, + }); + expect(getByText('Mock Children')).toBeInTheDocument(); + }); +}); + +function setup({ + params = { + cluster: 'example-cluster', + domain: 'example-domain', + runId: 'example-runId', + workflowId: 'example-workflowId', + workflowTab: 'summary', + }, + children = null, +}: Partial) { + return render( + {children} + ); +} diff --git a/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.tsx b/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.tsx new file mode 100644 index 000000000..d779a5b2c --- /dev/null +++ b/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.tsx @@ -0,0 +1,21 @@ +'use client'; +import React from 'react'; +import PageTabs from '@/components/page-tabs/page-tabs'; +import workflowPageTabsConfig from '../config/workflow-page-tabs.config'; +import type { Props, WorkflowPageTabsParams } from './workflow-page-tabs.types'; +import decodeUrlParams from '@/utils/decode-url-params'; +import PageSection from '@/components/page-section/page-section'; + +export default function WorkflowPageTabs({ params, children }: Props) { + const decodedParams = decodeUrlParams(params) as WorkflowPageTabsParams; + return ( + <> + {}} + /> + {children} + + ); +} diff --git a/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.types.ts b/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.types.ts new file mode 100644 index 000000000..fa0dd8da8 --- /dev/null +++ b/src/views/workflow-page/workflow-page-tabs/workflow-page-tabs.types.ts @@ -0,0 +1,18 @@ +import type { PageTab } from '@/components/page-tabs/page-tabs.types'; +import workflowPageTabsConfig from '../config/workflow-page-tabs.config'; +import React from 'react'; + +export type WorkflowPageTabs = Array; + +export type WorkflowPageTabsParams = { + domain: string; + cluster: string; + workflowId: string; + runId: string; + workflowTab: (typeof workflowPageTabsConfig)[number]['key']; +}; + +export type Props = { + params: WorkflowPageTabsParams; + children: React.ReactNode; +}; diff --git a/src/views/workflow-page/workflow-page.tsx b/src/views/workflow-page/workflow-page.tsx new file mode 100644 index 000000000..ee2862204 --- /dev/null +++ b/src/views/workflow-page/workflow-page.tsx @@ -0,0 +1,47 @@ +import React, { Suspense } from 'react'; +import WorkflowPageHeader from './workflow-page-header/workflow-page-header'; +import type { Props } from './workflow-page.types'; +import AsyncPropsLoader from '@/components/async-props-loader/async-props-loader'; +import WorkflowStatusTag from '../shared/workflow-status-tag/workflow-status-tag'; +import { getCachedWorkflowExecution } from './helpers/get-workflow-execution'; +import decodeUrlParams from '@/utils/decode-url-params'; + +export default async function WorkflowPage({ params, children }: Props) { + const decodedParams = decodeUrlParams(params) as Props['params']; + + return ( + <> + + { + const res = await getCachedWorkflowExecution( + decodedParams.cluster, + { + domain: decodedParams.domain, + workflowExecution: { + workflowId: decodedParams.workflowId, + runId: decodedParams.runId, + }, + } + ); + return { + status: + res.workflowExecutionInfo.closeStatus || + 'WORKFLOW_EXECUTION_STATUS_RUNNING', + }; + }} + /> + + } + /> + {children} + + ); +} diff --git a/src/views/workflow-page/workflow-page.types.ts b/src/views/workflow-page/workflow-page.types.ts new file mode 100644 index 000000000..ddc938bfb --- /dev/null +++ b/src/views/workflow-page/workflow-page.types.ts @@ -0,0 +1,11 @@ +import React from 'react'; + +export type Props = { + params: { + domain: string; + cluster: string; + workflowId: string; + runId: string; + }; + children: React.ReactNode; +};