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;