From d2afd860179d00f85b06452afcec457d9165c419 Mon Sep 17 00:00:00 2001 From: Adhitya Mamallan Date: Fri, 13 Dec 2024 15:14:33 +0530 Subject: [PATCH] Add basic visibility table --- .../get-date-days-before-today.test.ts | 17 ++ .../datetime/get-date-days-before-today.ts | 5 + .../domain-workflows-basic-filters.config.ts | 1 + ...domain-workflows-basic-page-size.config.ts | 3 + .../domain-workflows-basic-table.config.ts | 59 ++++ .../domain-workflows-basic-filters.test.tsx | 66 +++++ .../domain-workflows-basic-filters.tsx | 14 +- .../domain-workflows-basic-table.test.tsx | 280 ++++++++++++++++++ .../domain-workflows-basic-table.styles.ts | 13 + .../domain-workflows-basic-table.tsx | 75 +++++ .../domain-workflows-basic-table.types.ts | 11 + ...-workflows-basic-error-panel-props.test.ts | 69 +++++ .../get-workflows-basic-error-panel-props.ts | 52 ++++ .../domain-workflows-basic.tsx | 6 +- .../domain-workflows-basic.types.ts | 5 + ...list-workflows-basic-query-options.test.ts | 33 +++ .../get-list-workflows-basic-query-options.ts | 43 +++ .../hooks/use-list-workflows-basic.ts | 90 ++++++ 18 files changed, 839 insertions(+), 3 deletions(-) create mode 100644 src/utils/datetime/__tests__/get-date-days-before-today.test.ts create mode 100644 src/utils/datetime/get-date-days-before-today.ts create mode 100644 src/views/domain-workflows-basic/config/domain-workflows-basic-page-size.config.ts create mode 100644 src/views/domain-workflows-basic/config/domain-workflows-basic-table.config.ts create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-filters/__tests__/domain-workflows-basic-filters.test.tsx create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/__tests__/domain-workflows-basic-table.test.tsx create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.styles.ts create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.tsx create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.types.ts create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/__tests__/get-workflows-basic-error-panel-props.test.ts create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/get-workflows-basic-error-panel-props.ts create mode 100644 src/views/domain-workflows-basic/domain-workflows-basic.types.ts create mode 100644 src/views/domain-workflows-basic/hooks/helpers/__tests__/get-list-workflows-basic-query-options.test.ts create mode 100644 src/views/domain-workflows-basic/hooks/helpers/get-list-workflows-basic-query-options.ts create mode 100644 src/views/domain-workflows-basic/hooks/use-list-workflows-basic.ts diff --git a/src/utils/datetime/__tests__/get-date-days-before-today.test.ts b/src/utils/datetime/__tests__/get-date-days-before-today.test.ts new file mode 100644 index 000000000..9b04873fc --- /dev/null +++ b/src/utils/datetime/__tests__/get-date-days-before-today.test.ts @@ -0,0 +1,17 @@ +import getDateDaysBeforeToday from '../get-date-days-before-today'; + +jest.useFakeTimers().setSystemTime(new Date('2023-05-25')); + +describe(getDateDaysBeforeToday.name, () => { + it('should return date 30 days before today', () => { + expect(getDateDaysBeforeToday(30)).toEqual( + new Date('2023-04-25T00:00:00.000Z') + ); + }); + + it('should return date 0 days before today', () => { + expect(getDateDaysBeforeToday(0)).toEqual( + new Date('2023-05-25T00:00:00.000Z') + ); + }); +}); diff --git a/src/utils/datetime/get-date-days-before-today.ts b/src/utils/datetime/get-date-days-before-today.ts new file mode 100644 index 000000000..7b37668c5 --- /dev/null +++ b/src/utils/datetime/get-date-days-before-today.ts @@ -0,0 +1,5 @@ +export default function getDateDaysBeforeToday(n: number) { + const today = new Date(); + if (n === 0) return today; + return new Date(today.setDate(today.getDate() - n)); +} diff --git a/src/views/domain-workflows-basic/config/domain-workflows-basic-filters.config.ts b/src/views/domain-workflows-basic/config/domain-workflows-basic-filters.config.ts index 1c231e956..2027aa90b 100644 --- a/src/views/domain-workflows-basic/config/domain-workflows-basic-filters.config.ts +++ b/src/views/domain-workflows-basic/config/domain-workflows-basic-filters.config.ts @@ -32,6 +32,7 @@ const domainWorkflowsBasicFiltersConfig: [ timeRangeStart: v.timeRangeStart?.toISOString(), timeRangeEnd: v.timeRangeEnd?.toISOString(), }), + // TODO: make a shared dates picker so that you can customize this one to not be clearable component: DomainWorkflowsFiltersDates, }, ] as const; diff --git a/src/views/domain-workflows-basic/config/domain-workflows-basic-page-size.config.ts b/src/views/domain-workflows-basic/config/domain-workflows-basic-page-size.config.ts new file mode 100644 index 000000000..8e16c2ec8 --- /dev/null +++ b/src/views/domain-workflows-basic/config/domain-workflows-basic-page-size.config.ts @@ -0,0 +1,3 @@ +const DOMAIN_WORKFLOWS_BASIC_PAGE_SIZE = 10; + +export default DOMAIN_WORKFLOWS_BASIC_PAGE_SIZE; diff --git a/src/views/domain-workflows-basic/config/domain-workflows-basic-table.config.ts b/src/views/domain-workflows-basic/config/domain-workflows-basic-table.config.ts new file mode 100644 index 000000000..bda7ad22b --- /dev/null +++ b/src/views/domain-workflows-basic/config/domain-workflows-basic-table.config.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react'; + +import FormattedDate from '@/components/formatted-date/formatted-date'; +import Link from '@/components/link/link'; +import { type DomainWorkflow } from '@/views/domain-page/domain-page.types'; +import WorkflowStatusTag from '@/views/shared/workflow-status-tag/workflow-status-tag'; + +import { type DomainWorkflowsBasicTableConfig } from '../domain-workflows-basic-table/domain-workflows-basic-table.types'; + +const domainWorkflowsBasicTableConfig = [ + { + name: 'Workflow ID', + id: 'WorkflowID', + renderCell: (row: DomainWorkflow) => row.workflowID, + width: '25.5%', + }, + { + name: 'Status', + id: 'CloseStatus', + renderCell: (row: DomainWorkflow) => + createElement(WorkflowStatusTag, { status: row.status }), + width: '7.5%', + }, + { + name: 'Run ID', + id: 'RunID', + renderCell: (row: DomainWorkflow) => + createElement( + Link, + { + href: `workflows/${encodeURIComponent(row.workflowID)}/${encodeURIComponent(row.runID)}`, + }, + row.runID + ), + width: '22%', + }, + { + name: 'Workflow type', + id: 'WorkflowType', + renderCell: (row: DomainWorkflow) => row.workflowName, + width: '20%', + }, + { + name: 'Started', + id: 'StartTime', + renderCell: (row: DomainWorkflow) => + createElement(FormattedDate, { timestampMs: row.startTime }), + width: '12.5%', + }, + { + name: 'Ended', + id: 'CloseTime', + renderCell: (row: DomainWorkflow) => + createElement(FormattedDate, { timestampMs: row.closeTime }), + width: '12.5%', + }, +] as const satisfies DomainWorkflowsBasicTableConfig; + +export default domainWorkflowsBasicTableConfig; diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-filters/__tests__/domain-workflows-basic-filters.test.tsx b/src/views/domain-workflows-basic/domain-workflows-basic-filters/__tests__/domain-workflows-basic-filters.test.tsx new file mode 100644 index 000000000..7947257cd --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-filters/__tests__/domain-workflows-basic-filters.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import { type Props as PageFiltersToggleProps } from '@/components/page-filters/page-filters-toggle/page-filters-toggle.types'; +import { mockDomainWorkflowsQueryParamsValues } from '@/views/domain-workflows/__fixtures__/domain-workflows-query-params'; + +import DomainWorkflowsBasicFilters from '../domain-workflows-basic-filters'; + +jest.mock( + '@/components/page-filters/page-filters-search/page-filters-search', + () => + jest.fn(({ searchPlaceholder }) => ( +
Filter search: {searchPlaceholder}
+ )) +); + +jest.mock( + '@/components/page-filters/page-filters-fields/page-filters-fields', + () => jest.fn(() =>
Filter fields
) +); + +jest.mock( + '@/components/page-filters/page-filters-toggle/page-filters-toggle', + () => + jest.fn((props: PageFiltersToggleProps) => ( + + )) +); + +const mockSetQueryParams = jest.fn(); +const mockResetAllFilters = jest.fn(); +const mockActiveFiltersCount = 2; +jest.mock('@/components/page-filters/hooks/use-page-filters', () => + jest.fn(() => ({ + resetAllFilters: mockResetAllFilters, + activeFiltersCount: mockActiveFiltersCount, + queryParams: mockDomainWorkflowsQueryParamsValues, + setQueryParams: mockSetQueryParams, + })) +); + +describe(DomainWorkflowsBasicFilters.name, () => { + it('renders page search and filters', async () => { + render(); + + expect( + await screen.findByText('Filter search: Workflow ID') + ).toBeInTheDocument(); + expect( + await screen.findByText('Filter search: Workflow Type') + ).toBeInTheDocument(); + + expect(await screen.findByText('Filter fields')).toBeInTheDocument(); + }); + + it('hides page filters when filter toggle is clicked', async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText('Filter fields')).toBeInTheDocument(); + + const filterToggle = await screen.findByText('Filter toggle'); + await user.click(filterToggle); + + expect(screen.queryByText('Filter fields')).toBeNull(); + }); +}); diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-filters/domain-workflows-basic-filters.tsx b/src/views/domain-workflows-basic/domain-workflows-basic-filters/domain-workflows-basic-filters.tsx index 53cfde0a5..343eeadc5 100644 --- a/src/views/domain-workflows-basic/domain-workflows-basic-filters/domain-workflows-basic-filters.tsx +++ b/src/views/domain-workflows-basic/domain-workflows-basic-filters/domain-workflows-basic-filters.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import usePageFilters from '@/components/page-filters/hooks/use-page-filters'; import PageFiltersFields from '@/components/page-filters/page-filters-fields/page-filters-fields'; import PageFiltersSearch from '@/components/page-filters/page-filters-search/page-filters-search'; import PageFiltersToggle from '@/components/page-filters/page-filters-toggle/page-filters-toggle'; +import getDateDaysBeforeToday from '@/utils/datetime/get-date-days-before-today'; import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config'; import domainWorkflowsBasicFiltersConfig from '../config/domain-workflows-basic-filters.config'; @@ -13,7 +14,7 @@ import DOMAIN_WORKFLOWS_BASIC_SEARCH_DEBOUNCE_MS from '../config/domain-workflow import { styled } from './domain-workflows-basic-filters.styles'; export default function DomainWorkflowsBasicFilters() { - const [areFiltersShown, setAreFiltersShown] = useState(false); + const [areFiltersShown, setAreFiltersShown] = useState(true); const { resetAllFilters, activeFiltersCount, queryParams, setQueryParams } = usePageFilters({ @@ -21,6 +22,15 @@ export default function DomainWorkflowsBasicFilters() { pageQueryParamsConfig: domainPageQueryParamsConfig, }); + useEffect(() => { + if (!queryParams.timeRangeStart && !queryParams.timeRangeEnd) { + setQueryParams({ + timeRangeStart: getDateDaysBeforeToday(30).toISOString(), + timeRangeEnd: getDateDaysBeforeToday(0).toISOString(), + }); + } + }, [queryParams.timeRangeStart, queryParams.timeRangeEnd, setQueryParams]); + return ( diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/__tests__/domain-workflows-basic-table.test.tsx b/src/views/domain-workflows-basic/domain-workflows-basic-table/__tests__/domain-workflows-basic-table.test.tsx new file mode 100644 index 000000000..c198e9198 --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/__tests__/domain-workflows-basic-table.test.tsx @@ -0,0 +1,280 @@ +import { HttpResponse } from 'msw'; + +import { render, screen, userEvent } from '@/test-utils/rtl'; + +import { type Props as LoaderProps } from '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader.types'; +import * as usePageQueryParamsModule from '@/hooks/use-page-query-params/use-page-query-params'; +import { type ListWorkflowsResponse } from '@/route-handlers/list-workflows/list-workflows.types'; +import type { Props as MSWMocksHandlersProps } from '@/test-utils/msw-mock-handlers/msw-mock-handlers.types'; +import { mockDomainWorkflowsQueryParamsValues } from '@/views/domain-workflows/__fixtures__/domain-workflows-query-params'; + +import DomainWorkflowsBasicTable from '../domain-workflows-basic-table'; + +jest.mock('@/components/error-panel/error-panel', () => + jest.fn(({ message }: { message: string }) =>
{message}
) +); + +jest.mock('../helpers/get-workflows-basic-error-panel-props', () => + jest.fn().mockImplementation(({ error }: { error: Error }) => { + return { + message: error ? 'Error loading workflows' : 'No workflows found', + }; + }) +); + +jest.mock( + '@/components/table/table-infinite-scroll-loader/table-infinite-scroll-loader', + () => + jest.fn((props: LoaderProps) => ( + + )) +); + +jest.mock('query-string', () => ({ + stringifyUrl: jest.fn( + ({ _, query }) => + `/api/domains/mock-domain/mock-cluster/workflows-basic?kind=${query.kind}` + ), +})); + +const mockSetQueryParams = jest.fn(); +jest.mock('@/hooks/use-page-query-params/use-page-query-params', () => + jest.fn(() => [mockDomainWorkflowsQueryParamsValues, mockSetQueryParams]) +); + +describe(DomainWorkflowsBasicTable.name, () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('renders workflows without error', async () => { + const { user } = setup({}); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + + expect(screen.getByText(`mock-workflow-id-0-0-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-0-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-closed`)).toBeInTheDocument(); + + await user.click(screen.getByTestId('mock-loader')); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-5-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-5-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-closed`)).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 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(); + expect(screen.getByText(`mock-workflow-id-0-0-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-0-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-closed`)).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(); + expect(screen.getByText(`mock-workflow-id-0-5-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-5-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-closed`)).toBeInTheDocument(); + }); + + it('calls only listOpen if Running status is selected', async () => { + jest.spyOn(usePageQueryParamsModule, 'default').mockReturnValue([ + { + ...mockDomainWorkflowsQueryParamsValues, + statusBasic: 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID', + }, + mockSetQueryParams, + ]); + + setup({}); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-0-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-5-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-open`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-open`)).toBeInTheDocument(); + }); + + it('calls only listClosed if a close status is selected', async () => { + jest.spyOn(usePageQueryParamsModule, 'default').mockReturnValue([ + { + ...mockDomainWorkflowsQueryParamsValues, + statusBasic: 'WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED', + }, + mockSetQueryParams, + ]); + + setup({}); + + expect(await screen.findByText('Mock end message: OK')).toBeInTheDocument(); + + expect(screen.getByText(`mock-workflow-id-0-0-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-1-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-2-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-3-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-4-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-5-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-6-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-7-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-8-closed`)).toBeInTheDocument(); + expect(screen.getByText(`mock-workflow-id-0-9-closed`)).toBeInTheDocument(); + }); +}); + +function setup({ + errorCase, +}: { + errorCase?: 'initial-fetch-error' | 'subsequent-fetch-error' | 'no-workflows'; +}) { + const openPages = generateWorkflowPages(2, true); + const closedPages = generateWorkflowPages(2); + + let currentEventIndexOpen = 0; + let currentEventIndexClosed = 0; + const user = userEvent.setup(); + + const renderResult = render( + , + { + endpointsMocks: [ + { + path: '/api/domains/:domain/:cluster/workflows-basic', + httpMethod: 'GET', + mockOnce: false, + httpResolver: async ({ request }) => { + const url = new URL(request.url); + const kind = url.searchParams.get('kind'); + + let index; + if (kind === 'closed') { + index = currentEventIndexClosed; + currentEventIndexClosed++; + } else { + index = currentEventIndexOpen; + currentEventIndexOpen++; + } + + const pages = kind === 'closed' ? closedPages : openPages; + + 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, ...renderResult }; +} + +// TODO @adhitya.mamallan - Explore using fakerjs.dev for cases like this +function generateWorkflowPages( + count: number, + isOpen?: boolean +): Array { + const pages = Array.from( + { length: count }, + (_, pageIndex): ListWorkflowsResponse => ({ + workflows: Array.from({ length: 10 }, (_, index) => ({ + workflowID: `mock-workflow-id-${pageIndex}-${index}-${isOpen ? 'open' : 'closed'}`, + runID: `mock-run-id-${pageIndex}-${index}`, + workflowName: `mock-workflow-name-${pageIndex}-${index}`, + status: isOpen + ? 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID' + : 'WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED', + startTime: 1684800000000 - pageIndex * 1000 - index * 100, + closeTime: isOpen ? undefined : 1684886400000, + })), + nextPage: `${pageIndex + 1}`, + }) + ); + + pages[pages.length - 1].nextPage = ''; + return pages; +} diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.styles.ts b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.styles.ts new file mode 100644 index 000000000..d536b2fdf --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.styles.ts @@ -0,0 +1,13 @@ +import { styled as createStyled } from 'baseui'; + +export const styled = { + TableContainer: createStyled('div', { + overflowX: 'auto', + }), + ErrorPanelContainer: createStyled('div', ({ $theme }) => ({ + padding: `${$theme.sizing.scale1200} 0px`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })), +}; diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.tsx b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.tsx new file mode 100644 index 000000000..b9039c924 --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.tsx @@ -0,0 +1,75 @@ +'use client'; +import React from 'react'; + +import ErrorPanel from '@/components/error-panel/error-panel'; +import SectionLoadingIndicator from '@/components/section-loading-indicator/section-loading-indicator'; +import Table from '@/components/table/table'; +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 domainWorkflowsBasicTableConfig from '../config/domain-workflows-basic-table.config'; +import useListWorkflowsBasic from '../hooks/use-list-workflows-basic'; + +import { styled } from './domain-workflows-basic-table.styles'; +import { type Props } from './domain-workflows-basic-table.types'; +import getWorkflowsBasicErrorPanelProps from './helpers/get-workflows-basic-error-panel-props'; + +export default function DomainWorkflowsBasicTable({ domain, cluster }: Props) { + const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + + const [ + { + data, + isLoading, + status, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + error, + refetch, + }, + ] = useListWorkflowsBasic({ domain, cluster }); + + if (isLoading) { + return ; + } + + if (data.length === 0) { + const errorPanelProps = getWorkflowsBasicErrorPanelProps({ + error, + areSearchParamsAbsent: + !queryParams.workflowId && + !queryParams.workflowType && + !queryParams.statusBasic && + !queryParams.timeRangeStart && + !queryParams.timeRangeEnd, + }); + + if (errorPanelProps) { + return ( + + + + ); + } + } + + return ( + + 0} + endMessageProps={{ + kind: 'infinite-scroll', + hasData: data.length > 0, + error: + status === 'error' ? new Error('One or more queries failed') : null, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }} + /> + + ); +} diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.types.ts b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.types.ts new file mode 100644 index 000000000..fd9087d98 --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/domain-workflows-basic-table.types.ts @@ -0,0 +1,11 @@ +import { type TableColumn } from '@/components/table/table.types'; +import { type DomainWorkflow } from '@/views/domain-page/domain-page.types'; + +export type Props = { + domain: string; + cluster: string; +}; + +export type DomainWorkflowsBasicTableConfig = Array< + Omit, 'sortable'> +>; diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/__tests__/get-workflows-basic-error-panel-props.test.ts b/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/__tests__/get-workflows-basic-error-panel-props.test.ts new file mode 100644 index 000000000..f75e5780d --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/__tests__/get-workflows-basic-error-panel-props.test.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { UseMergedInfiniteQueriesError } from '@/hooks/use-merged-infinite-queries/use-merged-infinite-queries-error'; +import { RequestError } from '@/utils/request/request-error'; + +import getWorkflowsBasicErrorPanelProps from '../get-workflows-basic-error-panel-props'; + +describe(getWorkflowsBasicErrorPanelProps.name, () => { + it('returns default error panel props for regular error', () => { + expect( + getWorkflowsBasicErrorPanelProps({ + error: new UseMergedInfiniteQueriesError('Test error', [ + new RequestError('Something went wrong', 500), + ]), + areSearchParamsAbsent: false, + }) + ).toEqual({ + message: 'Failed to fetch workflows', + actions: [{ kind: 'retry', label: 'Retry' }], + }); + }); + + it('returns validation error if the backend returns one', () => { + expect( + getWorkflowsBasicErrorPanelProps({ + error: new UseMergedInfiniteQueriesError('Test error', [ + new RequestError('Invalid input', 400, [ + { + code: z.ZodIssueCode.custom, + path: ['test-path'], + message: 'Incorrect field value', + }, + ]), + ]), + areSearchParamsAbsent: false, + }) + ).toEqual({ + message: 'Validation error: Incorrect field value', + }); + }); + + it('returns "not found" error panel props when search params are absent', () => { + expect( + getWorkflowsBasicErrorPanelProps({ + error: null, + areSearchParamsAbsent: true, + }) + ).toEqual({ + message: 'No workflows found for this domain', + omitLogging: true, + actions: [ + { + kind: 'link-external', + label: 'Get started on workflows', + link: 'https://cadenceworkflow.io/docs/concepts/workflows', + }, + ], + }); + }); + + it('returns undefined in all other cases', () => { + expect( + getWorkflowsBasicErrorPanelProps({ + error: null, + areSearchParamsAbsent: false, + }) + ).toBeUndefined(); + }); +}); diff --git a/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/get-workflows-basic-error-panel-props.ts b/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/get-workflows-basic-error-panel-props.ts new file mode 100644 index 000000000..4d731f9e6 --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic-table/helpers/get-workflows-basic-error-panel-props.ts @@ -0,0 +1,52 @@ +import { type ZodIssue } from 'zod'; + +import { type Props as ErrorPanelProps } from '@/components/error-panel/error-panel.types'; +import { type UseMergedInfiniteQueriesError } from '@/hooks/use-merged-infinite-queries/use-merged-infinite-queries-error'; +import { RequestError } from '@/utils/request/request-error'; + +export default function getWorkflowsBasicErrorPanelProps({ + error, + areSearchParamsAbsent, +}: { + error: UseMergedInfiniteQueriesError | null; + areSearchParamsAbsent: boolean; +}): ErrorPanelProps | undefined { + if (error) { + const validationErrors = error.errors.reduce( + (acc: Array, error: Error) => { + if (error instanceof RequestError && error.validationErrors) { + error.validationErrors.forEach((err) => acc.push(err)); + } + return acc; + }, + [] + ); + + if (validationErrors.length > 0) { + return { + message: 'Validation error: ' + validationErrors[0].message, + }; + } + + return { + message: 'Failed to fetch workflows', + actions: [{ kind: 'retry', label: 'Retry' }], + }; + } + + if (areSearchParamsAbsent) { + return { + message: 'No workflows found for this domain', + actions: [ + { + kind: 'link-external', + label: 'Get started on workflows', + link: 'https://cadenceworkflow.io/docs/concepts/workflows', + }, + ], + omitLogging: true, + }; + } + + return undefined; +} diff --git a/src/views/domain-workflows-basic/domain-workflows-basic.tsx b/src/views/domain-workflows-basic/domain-workflows-basic.tsx index ed3a9d8af..8cff58b6d 100644 --- a/src/views/domain-workflows-basic/domain-workflows-basic.tsx +++ b/src/views/domain-workflows-basic/domain-workflows-basic.tsx @@ -3,12 +3,16 @@ import React from 'react'; import { type DomainPageTabContentProps } from '@/views/domain-page/domain-page-content/domain-page-content.types'; import DomainWorkflowsBasicFilters from './domain-workflows-basic-filters/domain-workflows-basic-filters'; +import DomainWorkflowsBasicTable from './domain-workflows-basic-table/domain-workflows-basic-table'; export default function DomainWorkflowsBasic(props: DomainPageTabContentProps) { return ( <> - {/* */} + ); } diff --git a/src/views/domain-workflows-basic/domain-workflows-basic.types.ts b/src/views/domain-workflows-basic/domain-workflows-basic.types.ts new file mode 100644 index 000000000..ce0267c9f --- /dev/null +++ b/src/views/domain-workflows-basic/domain-workflows-basic.types.ts @@ -0,0 +1,5 @@ +import { type RouteParams as ListWorkflowsBasicRouteParams } from '@/route-handlers/list-workflows-basic/list-workflows-basic.types'; + +export type UseListWorkflowsBasicParams = ListWorkflowsBasicRouteParams & { + pageSize?: number; +}; diff --git a/src/views/domain-workflows-basic/hooks/helpers/__tests__/get-list-workflows-basic-query-options.test.ts b/src/views/domain-workflows-basic/hooks/helpers/__tests__/get-list-workflows-basic-query-options.test.ts new file mode 100644 index 000000000..de6a01a89 --- /dev/null +++ b/src/views/domain-workflows-basic/hooks/helpers/__tests__/get-list-workflows-basic-query-options.test.ts @@ -0,0 +1,33 @@ +import getListWorkflowsBasicQueryOptions from '../get-list-workflows-basic-query-options'; + +describe(getListWorkflowsBasicQueryOptions.name, () => { + it('returns the expected output', () => { + expect( + getListWorkflowsBasicQueryOptions({ + domain: 'mock-domain', + cluster: 'mock-cluster', + requestQueryParams: { + kind: 'open', + pageSize: '10', + timeRangeStart: '1733845163000', + timeRangeEnd: '1733846163000', + }, + }) + ).toMatchObject({ + queryKey: [ + 'listWorkflowsBasic', + { + cluster: 'mock-cluster', + domain: 'mock-domain', + kind: 'open', + pageSize: '10', + timeRangeEnd: '1733846163000', + timeRangeStart: '1733845163000', + }, + ], + initialPageParam: undefined, + gcTime: 0, + retry: false, + }); + }); +}); diff --git a/src/views/domain-workflows-basic/hooks/helpers/get-list-workflows-basic-query-options.ts b/src/views/domain-workflows-basic/hooks/helpers/get-list-workflows-basic-query-options.ts new file mode 100644 index 000000000..37dd7c3f1 --- /dev/null +++ b/src/views/domain-workflows-basic/hooks/helpers/get-list-workflows-basic-query-options.ts @@ -0,0 +1,43 @@ +import queryString from 'query-string'; + +import { type SingleInfiniteQueryOptions } from '@/hooks/use-merged-infinite-queries/use-merged-infinite-queries.types'; +import { + type ListWorkflowsBasicResponse, + type ListWorkflowsBasicRequestQueryParams, +} from '@/route-handlers/list-workflows-basic/list-workflows-basic.types'; +import request from '@/utils/request'; + +export default function getListWorkflowsBasicQueryOptions({ + domain, + cluster, + requestQueryParams, +}: { + domain: string; + cluster: string; + requestQueryParams: ListWorkflowsBasicRequestQueryParams; +}): SingleInfiniteQueryOptions { + return { + queryKey: [ + 'listWorkflowsBasic', + { domain, cluster, ...requestQueryParams }, + ], + queryFn: async ({ pageParam }) => + request( + queryString.stringifyUrl({ + url: `/api/domains/${domain}/${cluster}/workflows-basic`, + query: { + ...requestQueryParams, + nextPage: pageParam as string, + }, + }) + ).then((res) => res.json()), + initialPageParam: undefined, + getNextPageParam: (lastPage) => { + if (!lastPage.nextPage) return undefined; + return lastPage.nextPage; + }, + retry: false, + refetchOnWindowFocus: (query) => query.state.status !== 'error', + gcTime: 0, + }; +} diff --git a/src/views/domain-workflows-basic/hooks/use-list-workflows-basic.ts b/src/views/domain-workflows-basic/hooks/use-list-workflows-basic.ts new file mode 100644 index 000000000..52060bb5b --- /dev/null +++ b/src/views/domain-workflows-basic/hooks/use-list-workflows-basic.ts @@ -0,0 +1,90 @@ +'use client'; + +import { useMemo } from 'react'; + +import useMergedInfiniteQueries from '@/hooks/use-merged-infinite-queries/use-merged-infinite-queries'; +import usePageQueryParams from '@/hooks/use-page-query-params/use-page-query-params'; +import { type ListWorkflowsBasicRequestQueryParams } from '@/route-handlers/list-workflows-basic/list-workflows-basic.types'; +import domainPageQueryParamsConfig from '@/views/domain-page/config/domain-page-query-params.config'; + +import DOMAIN_WORKFLOWS_BASIC_PAGE_SIZE from '../config/domain-workflows-basic-page-size.config'; +import { type UseListWorkflowsBasicParams } from '../domain-workflows-basic.types'; + +import getListWorkflowsBasicQueryOptions from './helpers/get-list-workflows-basic-query-options'; + +export default function useListWorkflowsBasic({ + domain, + cluster, + pageSize = DOMAIN_WORKFLOWS_BASIC_PAGE_SIZE, +}: UseListWorkflowsBasicParams) { + const [queryParams] = usePageQueryParams(domainPageQueryParamsConfig); + + const loadOpenWorkflows = + queryParams.statusBasic === undefined || + queryParams.statusBasic === 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'; + + const loadClosedWorkflows = + queryParams.statusBasic === undefined || + queryParams.statusBasic !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID'; + + const queryConfigs = useMemo(() => { + const requestQueryParamsBase = { + workflowId: queryParams.workflowId, + workflowType: queryParams.workflowType, + timeRangeStart: queryParams.timeRangeStart?.toISOString() ?? '', + timeRangeEnd: queryParams.timeRangeEnd?.toISOString() ?? '', + pageSize: pageSize.toString(), + ...(queryParams.statusBasic !== 'ALL_CLOSED' && + queryParams.statusBasic !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID' + ? { + closeStatus: queryParams.statusBasic, + } + : {}), + } as const satisfies Omit; + + return [ + ...(loadOpenWorkflows + ? [ + getListWorkflowsBasicQueryOptions({ + domain, + cluster, + requestQueryParams: { + ...requestQueryParamsBase, + kind: 'open', + }, + }), + ] + : []), + ...(loadClosedWorkflows + ? [ + getListWorkflowsBasicQueryOptions({ + domain, + cluster, + requestQueryParams: { + ...requestQueryParamsBase, + kind: 'closed', + }, + }), + ] + : []), + ]; + }, [ + domain, + cluster, + pageSize, + loadOpenWorkflows, + loadClosedWorkflows, + queryParams.workflowId, + queryParams.workflowType, + queryParams.timeRangeStart, + queryParams.timeRangeEnd, + queryParams.statusBasic, + ]); + + return useMergedInfiniteQueries({ + queries: queryConfigs, + pageSize: DOMAIN_WORKFLOWS_BASIC_PAGE_SIZE, + flattenResponse: (result) => result.workflows, + compare: (a, b) => (a.startTime > b.startTime ? -1 : 1), + }); +}