From 4dd97b97f4dfebb4bb67926a587452dbf933da39 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Tue, 23 Sep 2025 14:05:11 +0200 Subject: [PATCH] chore: use paginated table for change request list (#10660) Adds a paginated table to the change request overview page and integrates it with the search API hook. The current implementation still has some rough edges to work out, but it's getting closer. There's no sort buttons in this implementation. I've got it working on the side, but TS is complaining about types not matching up, so I'm spinning that out to a separate PR. image --- .../ChangeRequestStatusBadge.tsx | 20 +- .../ChangeRequests/ChangeRequests.tsx | 393 +++++++----------- .../GlobalChangeRequestTitleCell.tsx | 5 +- .../ChangeRequestStatusCell.tsx | 14 +- .../useChangeRequestSearch.ts | 14 +- 5 files changed, 177 insertions(+), 269 deletions(-) diff --git a/frontend/src/component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge.tsx b/frontend/src/component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge.tsx index 7cf3d63e42..bd8afb879d 100644 --- a/frontend/src/component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge.tsx @@ -1,5 +1,4 @@ -import type { VFC } from 'react'; -import type { ChangeRequestType } from '../changeRequest.types'; +import type { FC } from 'react'; import { Badge } from 'component/common/Badge/Badge'; import AccessTime from '@mui/icons-material/AccessTime'; import Check from '@mui/icons-material/Check'; @@ -9,20 +8,27 @@ import ErrorIcon from '@mui/icons-material/Error'; import PauseCircle from '@mui/icons-material/PauseCircle'; import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; import { useLocationSettings } from 'hooks/useLocationSettings'; +import type { + ScheduledChangeRequest, + UnscheduledChangeRequest, +} from '../changeRequest.types'; -interface IChangeRequestStatusBadgeProps { - changeRequest: ChangeRequestType | undefined; +export interface IChangeRequestStatusBadgeProps { + changeRequest: + | Pick + | Pick + | undefined; } -const ReviewRequiredBadge: VFC = () => ( +const ReviewRequiredBadge: FC = () => ( }> Review required ); -const DraftBadge: VFC = () => Draft; +const DraftBadge: FC = () => Draft; -export const ChangeRequestStatusBadge: VFC = ({ +export const ChangeRequestStatusBadge: FC = ({ changeRequest, }) => { const { locationSettings } = useLocationSettings(); diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx index b8953e025b..f08a3ba2c0 100644 --- a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx @@ -1,285 +1,174 @@ import { useMemo } from 'react'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import { - SortableTableHeader, - Table, - TableBody, - TableCell, - TableRow, -} from 'component/common/Table'; -import { useSortBy, useTable } from 'react-table'; -import { sortTypes } from 'utils/sortTypes'; +import { PaginatedTable } from 'component/common/Table'; +import { createColumnHelper, useReactTable } from '@tanstack/react-table'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; -import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { ChangeRequestStatusCell } from 'component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell'; import { AvatarCell } from 'component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/AvatarCell'; import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; import { GlobalChangeRequestTitleCell } from './GlobalChangeRequestTitleCell.js'; import { FeaturesCell } from '../ProjectChangeRequests/ChangeRequestsTabs/FeaturesCell.js'; import { useUiFlag } from 'hooks/useUiFlag.js'; +import { withTableState } from 'utils/withTableState'; +import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; +import { + useChangeRequestSearch, + DEFAULT_PAGE_LIMIT, + type SearchChangeRequestsInput, +} from 'hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch'; +import type { ChangeRequestSearchItemSchema } from 'openapi'; +import { + NumberParam, + StringParam, + withDefault, + useQueryParams, + encodeQueryParams, +} from 'use-query-params'; +import useLoading from 'hooks/useLoading'; +import { styles as themeStyles } from 'component/common'; +import { FilterItemParam } from 'utils/serializeQueryParams'; -// Mock data with varied projects and change requests -const mockChangeRequests = [ - { - id: 101, - title: 'Activate harpoons', - project: 'payment-service', - projectName: 'Payment Service', - features: [{ name: 'securePaymentFlow' }], - segments: [], - createdBy: { username: 'alice', name: 'Alice Johnson', imageUrl: null }, - createdAt: '2024-01-10T10:22:00Z', - environment: 'Production', - state: 'Review required', - }, - { - id: 102, - title: 'change request #102', - project: 'user-management', - projectName: 'User Management', - features: [{ name: 'enhancedValidation' }], - segments: [], - createdBy: { username: 'bob', name: 'Bob Smith', imageUrl: null }, - createdAt: '2024-01-10T08:15:00Z', - environment: 'Production', - state: 'Approved', - }, - { - id: 103, - title: 'Enable new checkout flow', - project: 'e-commerce-platform', - projectName: 'E-commerce Platform', - features: [{ name: 'newCheckoutUX' }, { name: 'paymentOptionsV2' }], - segments: [], - createdBy: { username: 'carol', name: 'Carol Davis', imageUrl: null }, - createdAt: '2024-01-10T12:30:00Z', - environment: 'Testing', - state: 'Review required', - }, - { - id: 104, - title: 'Update user permissions', - project: 'user-management', - projectName: 'User Management', - features: [ - { name: 'roleBasedAccess' }, - { name: 'permissionMatrix' }, - { name: 'adminDashboard' }, - ], - segments: [], - createdBy: { username: 'david', name: 'David Wilson', imageUrl: null }, - createdAt: '2024-01-09T16:45:00Z', - environment: 'Sandbox', - state: 'Review required', - }, - { - id: 105, - title: 'Deploy feature rollback', - project: 'analytics-platform', - projectName: 'Analytics Platform', - features: [ - { name: 'performanceTracking' }, - { name: 'realTimeAnalytics' }, - { name: 'customDashboards' }, - { name: 'dataExport' }, - ], - segments: [], - createdBy: { username: 'eve', name: 'Eve Brown', imageUrl: null }, - createdAt: '2024-01-09T14:20:00Z', - environment: 'Sandbox', - state: 'Scheduled', - schedule: { - scheduledAt: '2024-01-12T09:46:51+05:30', - status: 'pending', - }, - }, - { - id: 106, - title: 'change request #106', - project: 'notification-service', - projectName: 'Notification Service', - features: [{ name: 'emailTemplates' }], - segments: [], - createdBy: { username: 'frank', name: 'Frank Miller', imageUrl: null }, - createdAt: '2024-01-08T11:00:00Z', - environment: 'Testing', - state: 'Approved', - }, - { - id: 107, - title: 'Optimize database queries', - project: 'data-warehouse', - projectName: 'Data Warehouse', - features: [{ name: 'queryOptimization' }], - segments: [], - createdBy: { username: 'grace', name: 'Grace Lee', imageUrl: null }, - createdAt: '2024-01-08T09:30:00Z', - environment: 'Testing', - state: 'Approved', - }, - { - id: 108, - title: 'change request #108', - project: 'mobile-app', - projectName: 'Mobile App', - features: [{ name: 'pushNotifications' }], - segments: [], - createdBy: { username: 'henry', name: 'Henry Chen', imageUrl: null }, - createdAt: '2024-01-07T15:20:00Z', - environment: 'Production', - state: 'Approved', - }, - { - id: 109, - title: 'Archive legacy features', - project: 'payment-service', - projectName: 'Payment Service', - features: [{ name: 'legacyPaymentGateway' }], - segments: [], - createdBy: { username: 'alice', name: 'Alice Johnson', imageUrl: null }, - createdAt: '2024-01-07T13:10:00Z', - environment: 'Production', - state: 'Scheduled', - schedule: { - scheduledAt: '2024-01-12T09:46:51+05:30', - status: 'failed', - reason: 'Mr Freeze', - }, - }, -]; +const columnHelper = createColumnHelper(); const ChangeRequestsInner = () => { - const loading = false; + const { user } = useAuthUser(); + + const shouldApplyDefaults = useMemo(() => { + const urlParams = new URLSearchParams(window.location.search); + return ( + !urlParams.has('createdBy') && + !urlParams.has('requestedApproverId') && + user + ); + }, [user]); + + const stateConfig = { + offset: withDefault(NumberParam, 0), + limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT), + sortBy: withDefault(StringParam, 'createdAt'), + sortOrder: withDefault(StringParam, 'desc'), + createdBy: FilterItemParam, + requestedApproverId: FilterItemParam, + }; + + const initialState = shouldApplyDefaults + ? { + createdBy: { + operator: 'IS' as const, + values: user ? [user.id.toString()] : [], + }, + } + : {}; + + const [tableState, setTableState] = useQueryParams(stateConfig, { + updateType: 'replaceIn', + }); + + const effectiveTableState = useMemo( + () => ({ + ...initialState, + ...tableState, + }), + [initialState, tableState], + ); + + const { + changeRequests: data, + total, + loading, + } = useChangeRequestSearch( + encodeQueryParams( + stateConfig, + effectiveTableState, + ) as SearchChangeRequestsInput, + ); + const columns = useMemo( () => [ - { + columnHelper.accessor('title', { id: 'Title', - Header: 'Title', - // todo (globalChangeRequestList): sort out width calculation. It's configured both here with a min width down in the inner cell? - width: 300, - canSort: true, - accessor: 'title', - Cell: GlobalChangeRequestTitleCell, - }, - { + header: 'Title', + meta: { width: '300px' }, + cell: ({ getValue, row }) => ( + + ), + }), + columnHelper.accessor('features', { id: 'Updated feature flags', - Header: 'Updated feature flags', - canSort: false, - accessor: 'features', - searchable: true, - filterName: 'feature', - filterParsing: (values: Array<{ name: string }>) => { - return values?.map(({ name }) => name).join('\n') || ''; - }, - filterBy: ( - row: { features: Array<{ name: string }> }, - values: Array, - ) => { - return row.features.find((feature) => - values - .map((value) => value.toLowerCase()) - .includes(feature.name.toLowerCase()), - ); - }, - Cell: ({ - value, + header: 'Updated feature flags', + enableSorting: false, + cell: ({ + getValue, row: { original: { title, project }, }, - }: any) => ( - + }) => { + const features = getValue(); + const featureObjects = features.map((name: string) => ({ + name, + })); + return ( + + ); + }, + }), + columnHelper.accessor('createdBy', { + id: 'By', + header: 'By', + meta: { width: '180px', align: 'left' }, + enableSorting: false, + cell: ({ getValue }) => , + }), + columnHelper.accessor('createdAt', { + id: 'Submitted', + header: 'Submitted', + meta: { width: '100px' }, + cell: ({ getValue }) => , + }), + columnHelper.accessor('environment', { + id: 'Environment', + header: 'Environment', + meta: { width: '100px' }, + cell: ({ getValue }) => , + }), + columnHelper.accessor('state', { + id: 'Status', + header: 'Status', + meta: { width: '170px' }, + cell: ({ getValue, row }) => ( + // @ts-expect-error (`globalChangeRequestList`) The schema (and query) needs to be updated + ), - }, - { - Header: 'By', - accessor: 'createdBy', - maxWidth: 180, - canSort: false, - Cell: AvatarCell, - align: 'left', - searchable: true, - filterName: 'by', - filterParsing: (value: { username?: string }) => - value?.username || '', - }, - { - Header: 'Submitted', - accessor: 'createdAt', - maxWidth: 100, - Cell: TimeAgoCell, - }, - { - Header: 'Environment', - accessor: 'environment', - searchable: true, - maxWidth: 100, - Cell: HighlightCell, - filterName: 'environment', - }, - { - Header: 'Status', - accessor: 'state', - searchable: true, - maxWidth: '170px', - Cell: ChangeRequestStatusCell, - filterName: 'status', - }, + }), ], [], ); - const { headerGroups, rows, prepareRow, getTableProps, getTableBodyProps } = - useTable( - { - columns: columns as any[], - data: mockChangeRequests, - initialState: { - sortBy: [ - { - id: 'createdAt', - desc: true, - }, - ], - }, - sortTypes, - autoResetHiddenColumns: false, - disableSortRemove: true, - autoResetSortBy: false, - defaultColumn: { - Cell: TextCell, - }, - }, - useSortBy, - ); + const table = useReactTable( + withTableState(effectiveTableState, setTableState, { + columns, + data, + }), + ); + + const bodyLoadingRef = useLoading(loading); return ( } > - - - - {rows.map((row) => { - prepareRow(row); - const { key, ...rowProps } = row.getRowProps(); - return ( - - {row.cells.map((cell) => { - const { key, ...cellProps } = - cell.getCellProps(); - return ( - - {cell.render('Cell')} - - ); - })} - - ); - })} - -
+
+ +
); }; diff --git a/frontend/src/component/changeRequest/ChangeRequests/GlobalChangeRequestTitleCell.tsx b/frontend/src/component/changeRequest/ChangeRequests/GlobalChangeRequestTitleCell.tsx index 678c2a0918..17f0b1275a 100644 --- a/frontend/src/component/changeRequest/ChangeRequests/GlobalChangeRequestTitleCell.tsx +++ b/frontend/src/component/changeRequest/ChangeRequests/GlobalChangeRequestTitleCell.tsx @@ -1,6 +1,7 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { Link, styled, Typography } from '@mui/material'; import { Link as RouterLink, type LinkProps } from 'react-router-dom'; +import { useProjectOverviewNameOrId } from 'hooks/api/getters/useProjectOverview/useProjectOverview'; type IGlobalChangeRequestTitleCellProps = { value?: any; @@ -41,12 +42,12 @@ export const GlobalChangeRequestTitleCell = ({ id, title, project, - projectName, features: featureChanges, segments: segmentChanges, } = original; + const projectName = useProjectOverviewNameOrId(project); const totalChanges = - (featureChanges || []).length + (segmentChanges || []).length; + featureChanges?.length ?? 0 + segmentChanges?.length ?? 0; const projectPath = `/projects/${project}`; const crPath = `${projectPath}/change-requests/${id}`; diff --git a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell.tsx b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell.tsx index 734a3f1fcc..84f7bc2618 100644 --- a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell.tsx +++ b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell.tsx @@ -1,14 +1,18 @@ -import type { VFC } from 'react'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; -import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; -import { ChangeRequestStatusBadge } from 'component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge'; +import { + ChangeRequestStatusBadge, + type IChangeRequestStatusBadgeProps, +} from 'component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge'; +import type { FC } from 'react'; interface IChangeRequestStatusCellProps { value?: string | null; // FIXME: proper type - row: { original: ChangeRequestType }; + row: { + original: IChangeRequestStatusBadgeProps['changeRequest']; + }; } -export const ChangeRequestStatusCell: VFC = ({ +export const ChangeRequestStatusCell: FC = ({ value, row: { original }, }) => { diff --git a/frontend/src/hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch.ts b/frontend/src/hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch.ts index fca192d487..3ac7d4966c 100644 --- a/frontend/src/hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch.ts +++ b/frontend/src/hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch.ts @@ -31,6 +31,12 @@ const fallbackData: ChangeRequestSearchResponseSchema = { const SWR_CACHE_SIZE = 10; const PATH = 'api/admin/search/change-requests?'; +export type SearchChangeRequestsInput = { + [K in keyof SearchChangeRequestsParams]?: + | SearchChangeRequestsParams[K] + | null; +}; + const createChangeRequestSearch = () => { const internalCache: InternalCache = {}; @@ -56,7 +62,7 @@ const createChangeRequestSearch = () => { }; return ( - params: SearchChangeRequestsParams, + params: SearchChangeRequestsInput, options: SWRConfiguration = {}, cachePrefix: string = '', ): UseChangeRequestSearchOutput => { @@ -100,11 +106,13 @@ const createChangeRequestSearch = () => { export const DEFAULT_PAGE_LIMIT = 25; -const getChangeRequestSearchFetcher = (params: SearchChangeRequestsParams) => { +const getChangeRequestSearchFetcher = (params: SearchChangeRequestsInput) => { const urlSearchParams = new URLSearchParams( Array.from( Object.entries(params) - .filter(([_, value]) => !!value) + .filter( + (param): param is [string, string | number] => !!param[1], + ) .map(([key, value]) => [key, value.toString()]), ), ).toString();