diff --git a/src/views/domain-workflows-archival/domain-workflows-archival-list/__tests__/domain-workflows-archival-list.test.tsx b/src/views/domain-workflows-archival/domain-workflows-archival-list/__tests__/domain-workflows-archival-list.test.tsx index 7155521c1..56cd818cf 100644 --- a/src/views/domain-workflows-archival/domain-workflows-archival-list/__tests__/domain-workflows-archival-list.test.tsx +++ b/src/views/domain-workflows-archival/domain-workflows-archival-list/__tests__/domain-workflows-archival-list.test.tsx @@ -1,22 +1,207 @@ -import { render, screen } from '@/test-utils/rtl'; +import { HttpResponse } from 'msw'; +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items'; +import { type ListWorkflowsResponse } from '@/route-handlers/list-workflows/list-workflows.types'; +import { type WorkflowsHeaderInputType } from '@/views/shared/workflows-header/workflows-header.types'; +import { type Props as WorkflowsListProps } from '@/views/shared/workflows-list/workflows-list.types'; + +import type { Props as MSWMocksHandlersProps } from '../../../../test-utils/msw-mock-handlers/msw-mock-handlers.types'; +import { mockDomainPageQueryParamsValues } from '../../../domain-page/__fixtures__/domain-page-query-params'; import DomainWorkflowsArchivalList from '../domain-workflows-archival-list'; +jest.mock('@/components/error-panel/error-panel', () => + jest.fn(({ message }: { message: string }) =>
{message}
) +); + +jest.mock( + '../../domain-workflows-archival-table/helpers/get-archival-error-panel-props', + () => + jest + .fn() + .mockImplementation( + ({ + error, + inputType, + }: { + error: Error; + inputType: WorkflowsHeaderInputType; + }) => { + if (inputType === 'query') { + return { + message: error ? error.message : undefined, + }; + } + return { + message: error ? 'Error loading workflows' : 'No workflows found', + }; + } + ) +); + jest.mock('@/views/shared/workflows-list/workflows-list', () => - jest.fn(() =>
Mock workflows list
) + jest.fn((props: WorkflowsListProps) => ( +
+ {props.workflows.map((wf) => ( +
{wf.workflowID}
+ ))} + +
+ )) +); + +jest.mock('query-string', () => ({ + stringifyUrl: jest.fn( + () => '/api/domains/mock-domain/mock-cluster/workflows' + ), +})); + +const mockSetQueryParams = jest.fn(); +jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => + jest.fn(() => [mockDomainPageQueryParamsValues, mockSetQueryParams]) +); + +jest.mock('../../hooks/use-archival-input-type', () => + jest.fn(() => ({ + forceQueryInputOnly: false, + inputType: mockDomainPageQueryParamsValues.inputTypeArchival, + })) ); describe(DomainWorkflowsArchivalList.name, () => { - it('renders workflows list', () => { - render( - - ); - - expect(screen.getByText('Mock workflows list')).toBeInTheDocument(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders workflows without error', async () => { + const { user } = setup({}); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-0-${i}`)).toBeInTheDocument(); + } + + await user.click(screen.getByTestId('mock-loader')); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-1-${i}`)).toBeInTheDocument(); + } + }); + + it('renders error panel if the initial call fails', async () => { + setup({ errorCase: 'initial-fetch-error' }); + + expect( + await screen.findByText('Error loading workflows') + ).toBeInTheDocument(); + }); + + it('renders workflows and allows the user to try again if there is an error', async () => { + const { user } = setup({ errorCase: 'subsequent-fetch-error' }); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-0-${i}`)).toBeInTheDocument(); + } + + await user.click(screen.getByTestId('mock-loader')); + + expect( + await screen.findByText('Mock end message: Error') + ).toBeInTheDocument(); + + await user.click(screen.getByTestId('mock-loader')); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-1-${i}`)).toBeInTheDocument(); + } }); }); + +function setup({ + errorCase, +}: { + errorCase?: 'initial-fetch-error' | 'subsequent-fetch-error'; +}) { + const pages = generateWorkflowPages(2); + let currentEventIndex = 0; + const user = userEvent.setup(); + + render( + , + { + endpointsMocks: [ + { + path: '/api/domains/:domain/:cluster/workflows', + httpMethod: 'GET', + mockOnce: false, + httpResolver: async () => { + const index = currentEventIndex; + currentEventIndex++; + + switch (errorCase) { + case 'initial-fetch-error': + return HttpResponse.json( + { message: 'Request failed' }, + { status: 500 } + ); + case 'subsequent-fetch-error': + if (index === 0) { + return HttpResponse.json(pages[0]); + } else if (index === 1) { + return HttpResponse.json( + { message: 'Request failed' }, + { status: 500 } + ); + } else { + return HttpResponse.json(pages[1]); + } + default: + if (index === 0) { + return HttpResponse.json(pages[0]); + } else { + return HttpResponse.json(pages[1]); + } + } + }, + }, + ] as MSWMocksHandlersProps['endpointsMocks'], + } + ); + + return { user }; +} + +// TODO @adhitya.mamallan - Explore using fakerjs.dev for cases like this +function generateWorkflowPages(count: number): Array { + const pages = Array.from( + { length: count }, + (_, pageIndex): ListWorkflowsResponse => ({ + workflows: Array.from({ length: 10 }, (_, index) => + getMockWorkflowListItem({ + workflowID: `mock-workflow-id-${pageIndex}-${index}`, + runID: `mock-run-id-${pageIndex}-${index}`, + workflowName: `mock-workflow-name-${pageIndex}-${index}`, + status: 'WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED', + startTime: 1684800000000, + closeTime: count > 5 ? 1684886400000 : undefined, + }) + ), + nextPage: `${pageIndex + 1}`, + }) + ); + + pages[pages.length - 1].nextPage = ''; + return pages; +} diff --git a/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.tsx b/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.tsx index 177897a4f..b046e0a15 100644 --- a/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.tsx +++ b/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.tsx @@ -1,9 +1,79 @@ 'use client'; +import ErrorPanel from '@/components/error-panel/error-panel'; +import PanelSection from '@/components/panel-section/panel-section'; +import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator'; +import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; +import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config'; +import useListWorkflows from '@/views/shared/hooks/use-list-workflows'; import WorkflowsList from '@/views/shared/workflows-list/workflows-list'; +import DOMAIN_WORKFLOWS_ARCHIVAL_PAGE_SIZE from '../config/domain-workflows-archival-page-size.config'; +import getArchivalErrorPanelProps from '../domain-workflows-archival-table/helpers/get-archival-error-panel-props'; +import useArchivalInputType from '../hooks/use-archival-input-type'; + import { type Props } from './domain-workflows-archival-list.types'; -export default function DomainWorkflowsArchivalList(_props: Props) { - return ; +export default function DomainWorkflowsArchivalList({ + domain, + cluster, + visibleColumns, + timeRangeStart, + timeRangeEnd, +}: Props) { + const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + const { inputType } = useArchivalInputType(); + + const { + workflows, + error, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useListWorkflows({ + domain, + cluster, + listType: 'archived', + pageSize: DOMAIN_WORKFLOWS_ARCHIVAL_PAGE_SIZE, + inputType, + search: queryParams.searchArchival, + statuses: queryParams.statusesArchival, + timeRangeStart, + timeRangeEnd, + sortColumn: queryParams.sortColumnArchival, + sortOrder: queryParams.sortOrderArchival, + query: queryParams.queryArchival, + }); + + if (isLoading) { + return ; + } + + if (workflows.length === 0 && error) { + return ( + + + + ); + } + + return ( + + ); } diff --git a/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.types.ts b/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.types.ts index 7c3616b74..cbde1f07e 100644 --- a/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.types.ts +++ b/src/views/domain-workflows-archival/domain-workflows-archival-list/domain-workflows-archival-list.types.ts @@ -1,6 +1,9 @@ +import { type WorkflowsListColumn } from '@/views/shared/workflows-list/workflows-list.types'; + export type Props = { domain: string; cluster: string; + visibleColumns: Array; timeRangeStart: string; timeRangeEnd: string; }; diff --git a/src/views/domain-workflows-archival/domain-workflows-archival.tsx b/src/views/domain-workflows-archival/domain-workflows-archival.tsx index ab459192b..6ae7cc867 100644 --- a/src/views/domain-workflows-archival/domain-workflows-archival.tsx +++ b/src/views/domain-workflows-archival/domain-workflows-archival.tsx @@ -6,6 +6,7 @@ import getDayjsFromDateFilterValue from '@/components/date-filter/helpers/get-da import useSuspenseConfigValue from '@/hooks/use-config-value/use-suspense-config-value'; import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; import { type DomainPageTabContentProps } from '@/views/domain-page/domain-page-content/domain-page-content.types'; +import useWorkflowsListColumns from '@/views/shared/workflows-list/hooks/use-workflows-list-columns'; import domainPageQueryParamsConfig from '../domain-page/config/domain-page-query-params.config'; import useSuspenseDomainDescription from '../shared/hooks/use-domain-description/use-suspense-domain-description'; @@ -24,6 +25,10 @@ export default function DomainWorkflowsArchival( const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + const { visibleColumns } = useWorkflowsListColumns({ + cluster: props.cluster, + }); + const { data: isNewWorkflowsListEnabled } = useSuspenseConfigValue( 'WORKFLOWS_LIST_ENABLED' ); @@ -62,6 +67,7 @@ export default function DomainWorkflowsArchival( diff --git a/src/views/domain-workflows/domain-workflows-advanced/domain-workflows-advanced.tsx b/src/views/domain-workflows/domain-workflows-advanced/domain-workflows-advanced.tsx index a9c76b93f..99991452d 100644 --- a/src/views/domain-workflows/domain-workflows-advanced/domain-workflows-advanced.tsx +++ b/src/views/domain-workflows/domain-workflows-advanced/domain-workflows-advanced.tsx @@ -4,6 +4,7 @@ import getDayjsFromDateFilterValue from '@/components/date-filter/helpers/get-da import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; import dayjs from '@/utils/datetime/dayjs'; import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config'; +import useWorkflowsListColumns from '@/views/shared/workflows-list/hooks/use-workflows-list-columns'; import DomainWorkflowsHeader from '../domain-workflows-header/domain-workflows-header'; import DomainWorkflowsList from '../domain-workflows-list/domain-workflows-list'; @@ -18,6 +19,8 @@ export default function DomainWorkflowsAdvanced({ }: Props) { const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + const { visibleColumns } = useWorkflowsListColumns({ cluster }); + const timeRangeParams = useMemo(() => { const now = dayjs(); @@ -47,6 +50,7 @@ export default function DomainWorkflowsAdvanced({ diff --git a/src/views/domain-workflows/domain-workflows-list/__tests__/domain-workflows-list.test.tsx b/src/views/domain-workflows/domain-workflows-list/__tests__/domain-workflows-list.test.tsx index ad16c72e2..8156ba99d 100644 --- a/src/views/domain-workflows/domain-workflows-list/__tests__/domain-workflows-list.test.tsx +++ b/src/views/domain-workflows/domain-workflows-list/__tests__/domain-workflows-list.test.tsx @@ -1,21 +1,230 @@ -import { render, screen } from '@/test-utils/rtl'; +import { HttpResponse } from 'msw'; +import { render, screen, userEvent, waitFor } from '@/test-utils/rtl'; + +import * as usePageQueryParamsModule from '@/hooks/use-page-query-params/use-page-query-params'; +import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items'; +import { type ListWorkflowsResponse } from '@/route-handlers/list-workflows/list-workflows.types'; +import { type WorkflowsHeaderInputType } from '@/views/shared/workflows-header/workflows-header.types'; +import { type Props as WorkflowsListProps } from '@/views/shared/workflows-list/workflows-list.types'; + +import type { Props as MSWMocksHandlersProps } from '../../../../test-utils/msw-mock-handlers/msw-mock-handlers.types'; +import { mockDomainPageQueryParamsValues } from '../../../domain-page/__fixtures__/domain-page-query-params'; import DomainWorkflowsList from '../domain-workflows-list'; +jest.mock('@/components/error-panel/error-panel', () => + jest.fn(({ message }: { message: string }) =>
{message}
) +); + +jest.mock( + '../../domain-workflows-table/helpers/get-workflows-error-panel-props', + () => + jest + .fn() + .mockImplementation( + ({ + error, + inputType, + }: { + error: Error; + inputType: WorkflowsHeaderInputType; + }) => { + if (inputType === 'query') { + return { + message: error ? error.message : undefined, + }; + } + return { + message: error ? 'Error loading workflows' : 'No workflows found', + }; + } + ) +); + jest.mock('@/views/shared/workflows-list/workflows-list', () => - jest.fn(() =>
Mock workflows list
) + jest.fn((props: WorkflowsListProps) => ( +
+ {props.workflows.map((wf) => ( +
{wf.workflowID}
+ ))} + +
+ )) +); + +jest.mock('query-string', () => ({ + stringifyUrl: jest.fn( + () => '/api/domains/mock-domain/mock-cluster/workflows' + ), +})); + +const mockSetQueryParams = jest.fn(); +jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => + jest.fn(() => [mockDomainPageQueryParamsValues, mockSetQueryParams]) ); describe(DomainWorkflowsList.name, () => { - it('renders workflows list', () => { - render( - - ); - - expect(screen.getByText('Mock workflows list')).toBeInTheDocument(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders workflows without error', async () => { + const { user } = setup({}); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-0-${i}`)).toBeInTheDocument(); + } + + await user.click(screen.getByTestId('mock-loader')); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-1-${i}`)).toBeInTheDocument(); + } + }); + + it('renders error panel if the initial call fails', async () => { + setup({ errorCase: 'initial-fetch-error' }); + + expect( + await screen.findByText('Error loading workflows') + ).toBeInTheDocument(); + }); + + it('renders error panel in search mode if no workflows are found', async () => { + setup({ errorCase: 'no-workflows' }); + + expect(await screen.findByText('No workflows found')).toBeInTheDocument(); + }); + + it('renders empty list in query mode if no workflows are found', async () => { + jest + .spyOn(usePageQueryParamsModule, 'default') + .mockReturnValue([ + { ...mockDomainPageQueryParamsValues, inputType: 'query' }, + mockSetQueryParams, + ]); + + setup({ errorCase: 'no-workflows' }); + const progressbar = await screen.findByRole('progressbar'); + + await waitFor(() => { + expect(progressbar).not.toBeInTheDocument(); + }); + + expect(screen.queryByText('No workflows found')).not.toBeInTheDocument(); + }); + + it('renders workflows and allows the user to try again if there is an error', async () => { + const { user } = setup({ errorCase: 'subsequent-fetch-error' }); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-0-${i}`)).toBeInTheDocument(); + } + + await user.click(screen.getByTestId('mock-loader')); + + expect( + await screen.findByText('Mock end message: Error') + ).toBeInTheDocument(); + + await user.click(screen.getByTestId('mock-loader')); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + for (let i = 0; i < 10; i++) { + expect(screen.getByText(`mock-workflow-id-1-${i}`)).toBeInTheDocument(); + } }); }); + +function setup({ + errorCase, +}: { + errorCase?: 'initial-fetch-error' | 'subsequent-fetch-error' | 'no-workflows'; +}) { + const pages = generateWorkflowPages(2); + let currentEventIndex = 0; + const user = userEvent.setup(); + + render( + , + { + endpointsMocks: [ + { + path: '/api/domains/:domain/:cluster/workflows', + httpMethod: 'GET', + mockOnce: false, + httpResolver: async () => { + const index = currentEventIndex; + currentEventIndex++; + + switch (errorCase) { + case 'no-workflows': + return HttpResponse.json({ + workflows: [], + nextPage: undefined, + }); + case 'initial-fetch-error': + return HttpResponse.json( + { message: 'Request failed' }, + { status: 500 } + ); + case 'subsequent-fetch-error': + if (index === 0) { + return HttpResponse.json(pages[0]); + } else if (index === 1) { + return HttpResponse.json( + { message: 'Request failed' }, + { status: 500 } + ); + } else { + return HttpResponse.json(pages[1]); + } + default: + if (index === 0) { + return HttpResponse.json(pages[0]); + } else { + return HttpResponse.json(pages[1]); + } + } + }, + }, + ] as MSWMocksHandlersProps['endpointsMocks'], + } + ); + + return { user }; +} + +// TODO @adhitya.mamallan - Explore using fakerjs.dev for cases like this +function generateWorkflowPages(count: number): Array { + const pages = Array.from( + { length: count }, + (_, pageIndex): ListWorkflowsResponse => ({ + workflows: Array.from({ length: 10 }, (_, index) => + getMockWorkflowListItem({ + workflowID: `mock-workflow-id-${pageIndex}-${index}`, + runID: `mock-run-id-${pageIndex}-${index}`, + workflowName: `mock-workflow-name-${pageIndex}-${index}`, + status: 'WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED', + startTime: 1684800000000, + closeTime: count > 5 ? 1684886400000 : undefined, + }) + ), + nextPage: `${pageIndex + 1}`, + }) + ); + + pages[pages.length - 1].nextPage = ''; + return pages; +} diff --git a/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.tsx b/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.tsx index fc1bb821e..7a0b0c0a2 100644 --- a/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.tsx +++ b/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.tsx @@ -1,9 +1,82 @@ 'use client'; +import ErrorPanel from '@/components/error-panel/error-panel'; +import PanelSection from '@/components/panel-section/panel-section'; +import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator'; +import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; +import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config'; +import useListWorkflows from '@/views/shared/hooks/use-list-workflows'; import WorkflowsList from '@/views/shared/workflows-list/workflows-list'; +import DOMAIN_WORKFLOWS_PAGE_SIZE from '../config/domain-workflows-page-size.config'; +import getWorkflowsErrorPanelProps from '../domain-workflows-table/helpers/get-workflows-error-panel-props'; + import { type Props } from './domain-workflows-list.types'; -export default function DomainWorkflowsList(_props: Props) { - return ; +export default function DomainWorkflowsList({ + domain, + cluster, + visibleColumns, + timeRangeStart, + timeRangeEnd, +}: Props) { + const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + + const { + workflows, + error, + isLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useListWorkflows({ + domain, + cluster, + listType: 'default', + pageSize: DOMAIN_WORKFLOWS_PAGE_SIZE, + inputType: queryParams.inputType, + search: queryParams.search, + statuses: queryParams.statuses, + timeRangeStart, + timeRangeEnd, + sortColumn: queryParams.sortColumn, + sortOrder: queryParams.sortOrder, + query: queryParams.query, + }); + + if (isLoading) { + return ; + } + + if (workflows.length === 0) { + const errorPanelProps = getWorkflowsErrorPanelProps({ + inputType: queryParams.inputType, + error, + areSearchParamsAbsent: + !queryParams.search && + !queryParams.statuses && + !queryParams.timeRangeStart && + !queryParams.timeRangeEnd, + }); + + if (errorPanelProps) { + return ( + + + + ); + } + } + + return ( + + ); } diff --git a/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.types.ts b/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.types.ts index f61bb837d..339862524 100644 --- a/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.types.ts +++ b/src/views/domain-workflows/domain-workflows-list/domain-workflows-list.types.ts @@ -1,6 +1,9 @@ +import { type WorkflowsListColumn } from '@/views/shared/workflows-list/workflows-list.types'; + export type Props = { domain: string; cluster: string; + visibleColumns: Array; timeRangeStart?: string; timeRangeEnd: string; }; diff --git a/src/views/shared/workflows-list/__fixtures__/mock-workflows-list-columns.ts b/src/views/shared/workflows-list/__fixtures__/mock-workflows-list-columns.ts new file mode 100644 index 000000000..19c4e9f91 --- /dev/null +++ b/src/views/shared/workflows-list/__fixtures__/mock-workflows-list-columns.ts @@ -0,0 +1,48 @@ +import { type DomainWorkflow } from '@/views/domain-page/domain-page.types'; + +import { + type WorkflowsListColumn, + type WorkflowsListColumnConfig, +} from '../workflows-list.types'; + +export const mockWorkflowsListColumnsConfig: ReadonlyArray = + [ + { + match: (name: string) => name === 'WorkflowID', + name: 'Workflow ID', + width: 'minmax(200px, 3fr)', + renderCell: (row: DomainWorkflow, attributeName: string) => + `${attributeName}:${row.workflowID}`, + }, + { + match: (name: string) => name === 'CloseStatus', + name: 'Status', + width: 'minmax(100px, 1fr)', + renderCell: (row: DomainWorkflow, attributeName: string) => + `${attributeName}:${row.workflowID}`, + }, + { + match: (_name: string, type: string) => + type === 'INDEXED_VALUE_TYPE_DATETIME', + width: 'minmax(150px, 1.5fr)', + renderCell: (row: DomainWorkflow, attributeName: string) => + `${attributeName}:${row.workflowID}`, + }, + ]; + +export const mockWorkflowsListColumns: Array = [ + { + id: 'WorkflowID', + name: 'Workflow ID', + width: 'minmax(200px, 3fr)', + isSystem: true, + renderCell: (row) => row.workflowID, + }, + { + id: 'Status', + name: 'Status', + width: 'minmax(100px, 1fr)', + isSystem: true, + renderCell: (row) => row.status, + }, +]; diff --git a/src/views/shared/workflows-list/__tests__/workflows-list.test.tsx b/src/views/shared/workflows-list/__tests__/workflows-list.test.tsx new file mode 100644 index 000000000..4bd2562ea --- /dev/null +++ b/src/views/shared/workflows-list/__tests__/workflows-list.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; + +import { render, screen } from '@/test-utils/rtl'; + +import { type Props as LoaderProps } from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader.types'; +import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items'; + +import { mockWorkflowsListColumns } from '../__fixtures__/mock-workflows-list-columns'; +import WorkflowsList from '../workflows-list'; + +jest.mock( + '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader', + () => + jest.fn((props: LoaderProps) => ( +
+ {props.error ? 'Error' : 'OK'} + {props.hasNextPage ? ' | has-next' : ' | no-next'} + {props.isFetchingNextPage ? ' | fetching' : ' | idle'} + {props.hasData ? ' | has-data' : ' | no-data'} +
+ )) +); + +const MOCK_WORKFLOWS = [ + getMockWorkflowListItem({ + workflowID: 'wf-1', + runID: 'run-1', + }), + getMockWorkflowListItem({ + workflowID: 'wf-2', + runID: 'run-2', + }), +]; + +describe(WorkflowsList.name, () => { + it('renders column headers', () => { + setup({}); + + expect(screen.getByText('Workflow ID')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('renders workflow rows when not loading and workflows exist', () => { + setup({}); + + expect(screen.getByText('wf-1')).toBeInTheDocument(); + expect(screen.getByText('wf-2')).toBeInTheDocument(); + }); + + it('does not render workflow rows when workflows array is empty', () => { + setup({ workflows: [] }); + + expect(screen.queryByText('wf-1')).not.toBeInTheDocument(); + }); + + it('renders each row as a link with the correct href', () => { + setup({}); + + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', '/workflows/wf-1/run-1'); + expect(links[1]).toHaveAttribute('href', '/workflows/wf-2/run-2'); + }); + + it('encodes workflow and run IDs in the link href', () => { + setup({ + workflows: [ + getMockWorkflowListItem({ + workflowID: 'wf/special id', + runID: 'run/special id', + }), + ], + }); + + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + '/workflows/wf%2Fspecial%20id/run%2Fspecial%20id' + ); + }); + + it('renders cell content for each column', () => { + setup({}); + + expect(screen.getByText('wf-1')).toBeInTheDocument(); + expect(screen.getByText('wf-2')).toBeInTheDocument(); + expect( + screen.getAllByText('WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID') + ).toHaveLength(2); + }); + + it('passes correct props to TableInfiniteScrollLoader', () => { + setup({ + hasNextPage: true, + isFetchingNextPage: true, + error: new Error('test error'), + }); + + const loader = screen.getByTestId('mock-loader'); + expect(loader).toHaveTextContent('Error'); + expect(loader).toHaveTextContent('has-next'); + expect(loader).toHaveTextContent('fetching'); + expect(loader).toHaveTextContent('has-data'); + }); + + it('renders "None" placeholder when a column renderCell returns null', () => { + setup({ + workflows: [MOCK_WORKFLOWS[0]], + columns: [ + { + id: 'NullableCol', + name: 'Nullable Column', + width: '100px', + isSystem: false, + renderCell: () => null, + }, + ], + }); + + expect(screen.getByText('None')).toBeInTheDocument(); + }); + + it('passes hasData as false to loader when workflows is empty', () => { + setup({ workflows: [] }); + + const loader = screen.getByTestId('mock-loader'); + expect(loader).toHaveTextContent('no-data'); + }); +}); + +function setup({ + workflows = MOCK_WORKFLOWS, + columns = mockWorkflowsListColumns, + error = null, + hasNextPage = false, + isFetchingNextPage = false, +}: Partial> = {}) { + render( + + ); +} diff --git a/src/views/shared/workflows-list/config/workflows-list-columns.config.ts b/src/views/shared/workflows-list/config/workflows-list-columns.config.ts index 1ffbc685b..bc770fa69 100644 --- a/src/views/shared/workflows-list/config/workflows-list-columns.config.ts +++ b/src/views/shared/workflows-list/config/workflows-list-columns.config.ts @@ -1,9 +1,11 @@ import { createElement } from 'react'; +import isNil from 'lodash/isNil'; + import FormattedDate from '@/components/formatted-date/formatted-date'; -import formatPayload from '@/utils/data-formatters/format-payload'; import WorkflowStatusTag from '@/views/shared/workflow-status-tag/workflow-status-tag'; +import getSearchAttributeValue from '../helpers/get-search-attribute-value'; import { type WorkflowsListColumnConfig } from '../workflows-list.types'; const workflowsListColumnsConfig: ReadonlyArray = [ @@ -64,7 +66,7 @@ const workflowsListColumnsConfig: ReadonlyArray = [ match: (name) => name === 'HistoryLength', name: 'History Length', width: '140px', - renderCell: (row) => String(row.historyLength ?? ''), + renderCell: (row) => row.historyLength ?? null, }, { match: (name) => name === 'TaskList', @@ -81,25 +83,27 @@ const workflowsListColumnsConfig: ReadonlyArray = [ { match: (name) => name === 'ClusterAttributeScope', name: 'Cluster Attribute Scope', - width: '160px', - renderCell: (row) => row.clusterAttributeScope ?? '', + width: 'minmax(200px, 1.5fr)', + renderCell: (row) => row.clusterAttributeScope ?? null, }, { match: (name) => name === 'ClusterAttributeName', name: 'Cluster Attribute Name', - width: '160px', - renderCell: (row) => row.clusterAttributeName ?? '', + width: 'minmax(200px, 1.5fr)', + renderCell: (row) => row.clusterAttributeName ?? null, }, { match: (_name, type) => type === 'INDEXED_VALUE_TYPE_DATETIME', width: '200px', renderCell: (row, attributeName) => { - const value = formatPayload(row.searchAttributes?.[attributeName]); + const value = getSearchAttributeValue(row, attributeName); const timestamp = typeof value === 'string' ? Date.parse(value) : null; - if (timestamp != null && !isNaN(timestamp)) { + + if (timestamp !== null && !isNaN(timestamp)) { return createElement(FormattedDate, { timestampMs: timestamp }); } - return String(value ?? ''); + + return isNil(value) ? null : String(value); }, }, ]; diff --git a/src/views/shared/workflows-list/helpers/__tests__/get-search-attribute-value.test.ts b/src/views/shared/workflows-list/helpers/__tests__/get-search-attribute-value.test.ts new file mode 100644 index 000000000..a3bf093ce --- /dev/null +++ b/src/views/shared/workflows-list/helpers/__tests__/get-search-attribute-value.test.ts @@ -0,0 +1,37 @@ +import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items'; + +import getSearchAttributeValue from '../get-search-attribute-value'; + +describe(getSearchAttributeValue.name, () => { + it('returns the decoded value for a base64-encoded JSON search attribute', () => { + const row = getMockWorkflowListItem({ + searchAttributes: { + MyField: { data: btoa('"hello"') }, + }, + }); + + expect(getSearchAttributeValue(row, 'MyField')).toBe('hello'); + }); + + it('returns null when the attribute is not present', () => { + const row = getMockWorkflowListItem({ searchAttributes: {} }); + + expect(getSearchAttributeValue(row, 'MissingField')).toBeNull(); + }); + + it('returns null when searchAttributes is undefined', () => { + const row = getMockWorkflowListItem({ searchAttributes: undefined }); + + expect(getSearchAttributeValue(row, 'AnyField')).toBeNull(); + }); + + it('returns a parsed number for a numeric payload', () => { + const row = getMockWorkflowListItem({ + searchAttributes: { + Count: { data: btoa('42') }, + }, + }); + + expect(getSearchAttributeValue(row, 'Count')).toBe(42); + }); +}); diff --git a/src/views/shared/workflows-list/helpers/__tests__/get-workflows-list-column-from-search-attribute.test.ts b/src/views/shared/workflows-list/helpers/__tests__/get-workflows-list-column-from-search-attribute.test.ts index 7480c0575..7e18ac2d8 100644 --- a/src/views/shared/workflows-list/helpers/__tests__/get-workflows-list-column-from-search-attribute.test.ts +++ b/src/views/shared/workflows-list/helpers/__tests__/get-workflows-list-column-from-search-attribute.test.ts @@ -1,34 +1,11 @@ import { getMockWorkflowListItem } from '@/route-handlers/list-workflows/__fixtures__/mock-workflow-list-items'; -import { type DomainWorkflow } from '@/views/domain-page/domain-page.types'; -import { type WorkflowsListColumnConfig } from '../../workflows-list.types'; +import { mockWorkflowsListColumnsConfig } from '../../__fixtures__/mock-workflows-list-columns'; import getWorkflowsListColumnFromSearchAttribute from '../get-workflows-list-column-from-search-attribute'; jest.mock('../../config/workflows-list-columns.config', () => ({ __esModule: true, - default: [ - { - match: (name: string) => name === 'WorkflowID', - name: 'Workflow ID', - width: 'minmax(200px, 3fr)', - renderCell: (row: DomainWorkflow, attributeName: string) => - `${attributeName}:${row.workflowID}`, - }, - { - match: (name: string) => name === 'CloseStatus', - name: 'Status', - width: 'minmax(100px, 1fr)', - renderCell: (row: DomainWorkflow, attributeName: string) => - `${attributeName}:${row.workflowID}`, - }, - { - match: (_name: string, type: string) => - type === 'INDEXED_VALUE_TYPE_DATETIME', - width: 'minmax(150px, 1.5fr)', - renderCell: (row: DomainWorkflow, attributeName: string) => - `${attributeName}:${row.workflowID}`, - }, - ] satisfies ReadonlyArray, + default: mockWorkflowsListColumnsConfig, })); const mockRow = getMockWorkflowListItem({ @@ -112,13 +89,13 @@ describe(getWorkflowsListColumnFromSearchAttribute.name, () => { expect(column?.renderCell(row)).toBe('custom-value'); }); - it('renders empty string when custom attribute value is missing', () => { + it('renders null when custom attribute value is missing', () => { const row = getMockWorkflowListItem({ searchAttributes: {} }); const column = getWorkflowsListColumnFromSearchAttribute( 'MyCustomField', 'INDEXED_VALUE_TYPE_KEYWORD' ); - expect(column?.renderCell(row)).toBe(''); + expect(column?.renderCell(row)).toBe(null); }); }); diff --git a/src/views/shared/workflows-list/helpers/get-search-attribute-value.ts b/src/views/shared/workflows-list/helpers/get-search-attribute-value.ts new file mode 100644 index 000000000..669136b7f --- /dev/null +++ b/src/views/shared/workflows-list/helpers/get-search-attribute-value.ts @@ -0,0 +1,9 @@ +import formatPayload from '@/utils/data-formatters/format-payload'; +import { type DomainWorkflow } from '@/views/domain-page/domain-page.types'; + +export default function getSearchAttributeValue( + row: DomainWorkflow, + attributeName: string +): unknown { + return formatPayload(row.searchAttributes?.[attributeName]); +} diff --git a/src/views/shared/workflows-list/helpers/get-workflows-list-column-from-search-attribute.ts b/src/views/shared/workflows-list/helpers/get-workflows-list-column-from-search-attribute.ts index 37f3e5490..23882ce79 100644 --- a/src/views/shared/workflows-list/helpers/get-workflows-list-column-from-search-attribute.ts +++ b/src/views/shared/workflows-list/helpers/get-workflows-list-column-from-search-attribute.ts @@ -1,11 +1,12 @@ import { type IndexedValueType } from '@/__generated__/proto-ts/uber/cadence/api/v1/IndexedValueType'; import { SYSTEM_SEARCH_ATTRIBUTES } from '@/route-handlers/get-search-attributes/get-search-attributes.constants'; -import formatPayload from '@/utils/data-formatters/format-payload'; import workflowsListColumnsConfig from '../config/workflows-list-columns.config'; import { DEFAULT_WORKFLOWS_LIST_COLUMN_WIDTH } from '../workflows-list.constants'; import { type WorkflowsListColumn } from '../workflows-list.types'; +import getSearchAttributeValue from './get-search-attribute-value'; + export default function getWorkflowsListColumnFromSearchAttribute( attributeName: string, attributeType: IndexedValueType @@ -25,7 +26,9 @@ export default function getWorkflowsListColumnFromSearchAttribute( isSystem, renderCell: config ? (row) => config.renderCell(row, attributeName) - : (row) => - String(formatPayload(row.searchAttributes?.[attributeName]) ?? ''), + : (row) => { + const value = getSearchAttributeValue(row, attributeName); + return value === null ? null : String(value); + }, }; } diff --git a/src/views/shared/workflows-list/workflows-list.styles.ts b/src/views/shared/workflows-list/workflows-list.styles.ts new file mode 100644 index 000000000..fb7e2b3f9 --- /dev/null +++ b/src/views/shared/workflows-list/workflows-list.styles.ts @@ -0,0 +1,73 @@ +import { styled as createStyled, type Theme } from 'baseui'; + +export const styled = { + ScrollArea: createStyled('div', { + position: 'relative', + }), + Container: createStyled('div', { + overflowX: 'scroll', + scrollbarWidth: 'none', + '::-webkit-scrollbar': { + display: 'none', + }, + }), + GridHeader: createStyled<'div', { $gridTemplateColumns: string }>( + 'div', + ({ $theme, $gridTemplateColumns }) => ({ + display: 'grid', + gridTemplateColumns: $gridTemplateColumns, + minWidth: 'min-content', + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + borderBottomColor: $theme.colors.borderOpaque, + }) + ), + HeaderCell: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + ...$theme.typography.LabelSmall, + color: $theme.colors.contentSecondary, + paddingTop: $theme.sizing.scale400, + paddingBottom: $theme.sizing.scale400, + paddingLeft: $theme.sizing.scale600, + paddingRight: $theme.sizing.scale600, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + })), + GridRow: createStyled<'a', { $gridTemplateColumns: string }>( + 'a', + ({ $theme, $gridTemplateColumns }) => ({ + display: 'grid', + gridTemplateColumns: $gridTemplateColumns, + minWidth: 'min-content', + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + borderBottomColor: $theme.colors.borderOpaque, + cursor: 'pointer', + textDecoration: 'none', + color: 'inherit', + ':hover': { + backgroundColor: $theme.colors.backgroundSecondary, + }, + }) + ), + GridCell: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + ...$theme.typography.ParagraphSmall, + paddingTop: $theme.sizing.scale400, + paddingBottom: $theme.sizing.scale400, + paddingLeft: $theme.sizing.scale600, + paddingRight: $theme.sizing.scale600, + overflowWrap: 'break-word', + wordBreak: 'break-word', + minWidth: 0, + })), + CellPlaceholder: createStyled('span', ({ $theme }: { $theme: Theme }) => ({ + color: $theme.colors.contentTertiary, + fontStyle: 'italic', + })), + FooterContainer: createStyled('div', ({ $theme }: { $theme: Theme }) => ({ + display: 'flex', + justifyContent: 'center', + paddingTop: $theme.sizing.scale800, + paddingBottom: $theme.sizing.scale800, + })), +}; diff --git a/src/views/shared/workflows-list/workflows-list.tsx b/src/views/shared/workflows-list/workflows-list.tsx index 45c427030..a168cede8 100644 --- a/src/views/shared/workflows-list/workflows-list.tsx +++ b/src/views/shared/workflows-list/workflows-list.tsx @@ -1,3 +1,72 @@ -export default function WorkflowsList() { - return
Placeholder for new Workflows List
; +import { useMemo } from 'react'; + +import isNil from 'lodash/isNil'; +import NextLink from 'next/link'; + +import TableInfiniteScrollLoader from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader'; + +import { styled } from './workflows-list.styles'; +import { type Props } from './workflows-list.types'; + +export default function WorkflowsList({ + workflows, + columns, + error, + hasNextPage, + fetchNextPage, + isFetchingNextPage, +}: Props) { + const gridTemplateColumns = useMemo( + () => columns.map((col) => col.width).join(' '), + [columns] + ); + + const hasWorkflows = workflows.length > 0; + + return ( +
+ + {/* TODO @adhitya.mamallan - add a scroll shadow here */} + + + {columns.map((col) => ( + {col.name} + ))} + + {hasWorkflows && + workflows.map((workflow, index) => ( + + {columns.map((col) => { + const content = col.renderCell(workflow); + return ( + + {isNil(content) ? ( + None + ) : ( + content + )} + + ); + })} + + ))} + + + + + +
+ ); } diff --git a/src/views/shared/workflows-list/workflows-list.types.ts b/src/views/shared/workflows-list/workflows-list.types.ts index 982943954..df9b455dd 100644 --- a/src/views/shared/workflows-list/workflows-list.types.ts +++ b/src/views/shared/workflows-list/workflows-list.types.ts @@ -21,7 +21,6 @@ export type WorkflowsListColumn = { export type Props = { workflows: Array; columns: Array; - isLoading: boolean; error: Error | null; hasNextPage: boolean; fetchNextPage: () => void;