1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00

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.

<img width="1808" height="1400" alt="image"
src="https://github.com/user-attachments/assets/bdee97b7-ee2a-46c0-8460-a8b8e14d3c92"
/>
This commit is contained in:
Thomas Heartman 2025-09-23 14:05:11 +02:00 committed by GitHub
parent c824b3e26b
commit 4dd97b97f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 177 additions and 269 deletions

View File

@ -1,5 +1,4 @@
import type { VFC } from 'react'; import type { FC } from 'react';
import type { ChangeRequestType } from '../changeRequest.types';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import AccessTime from '@mui/icons-material/AccessTime'; import AccessTime from '@mui/icons-material/AccessTime';
import Check from '@mui/icons-material/Check'; 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 PauseCircle from '@mui/icons-material/PauseCircle';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import type {
ScheduledChangeRequest,
UnscheduledChangeRequest,
} from '../changeRequest.types';
interface IChangeRequestStatusBadgeProps { export interface IChangeRequestStatusBadgeProps {
changeRequest: ChangeRequestType | undefined; changeRequest:
| Pick<UnscheduledChangeRequest, 'state'>
| Pick<ScheduledChangeRequest, 'state' | 'schedule'>
| undefined;
} }
const ReviewRequiredBadge: VFC = () => ( const ReviewRequiredBadge: FC = () => (
<Badge color='secondary' icon={<CircleOutlined fontSize={'small'} />}> <Badge color='secondary' icon={<CircleOutlined fontSize={'small'} />}>
Review required Review required
</Badge> </Badge>
); );
const DraftBadge: VFC = () => <Badge color='warning'>Draft</Badge>; const DraftBadge: FC = () => <Badge color='warning'>Draft</Badge>;
export const ChangeRequestStatusBadge: VFC<IChangeRequestStatusBadgeProps> = ({ export const ChangeRequestStatusBadge: FC<IChangeRequestStatusBadgeProps> = ({
changeRequest, changeRequest,
}) => { }) => {
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();

View File

@ -1,285 +1,174 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { import { PaginatedTable } from 'component/common/Table';
SortableTableHeader, import { createColumnHelper, useReactTable } from '@tanstack/react-table';
Table,
TableBody,
TableCell,
TableRow,
} from 'component/common/Table';
import { useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; 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 { ChangeRequestStatusCell } from 'component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestStatusCell';
import { AvatarCell } from 'component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/AvatarCell'; import { AvatarCell } from 'component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/AvatarCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { GlobalChangeRequestTitleCell } from './GlobalChangeRequestTitleCell.js'; import { GlobalChangeRequestTitleCell } from './GlobalChangeRequestTitleCell.js';
import { FeaturesCell } from '../ProjectChangeRequests/ChangeRequestsTabs/FeaturesCell.js'; import { FeaturesCell } from '../ProjectChangeRequests/ChangeRequestsTabs/FeaturesCell.js';
import { useUiFlag } from 'hooks/useUiFlag.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 columnHelper = createColumnHelper<ChangeRequestSearchItemSchema>();
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 ChangeRequestsInner = () => { 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( const columns = useMemo(
() => [ () => [
{ columnHelper.accessor('title', {
id: 'Title', id: 'Title',
Header: 'Title', header: 'Title',
// todo (globalChangeRequestList): sort out width calculation. It's configured both here with a min width down in the inner cell? meta: { width: '300px' },
width: 300, cell: ({ getValue, row }) => (
canSort: true, <GlobalChangeRequestTitleCell
accessor: 'title', value={getValue()}
Cell: GlobalChangeRequestTitleCell, row={row}
}, />
{ ),
}),
columnHelper.accessor('features', {
id: 'Updated feature flags', id: 'Updated feature flags',
Header: 'Updated feature flags', header: 'Updated feature flags',
canSort: false, enableSorting: false,
accessor: 'features', cell: ({
searchable: true, getValue,
filterName: 'feature',
filterParsing: (values: Array<{ name: string }>) => {
return values?.map(({ name }) => name).join('\n') || '';
},
filterBy: (
row: { features: Array<{ name: string }> },
values: Array<string>,
) => {
return row.features.find((feature) =>
values
.map((value) => value.toLowerCase())
.includes(feature.name.toLowerCase()),
);
},
Cell: ({
value,
row: { row: {
original: { title, project }, original: { title, project },
}, },
}: any) => ( }) => {
<FeaturesCell project={project} value={value} key={title} /> const features = getValue();
const featureObjects = features.map((name: string) => ({
name,
}));
return (
<FeaturesCell
project={project}
value={featureObjects}
key={title}
/>
);
},
}),
columnHelper.accessor('createdBy', {
id: 'By',
header: 'By',
meta: { width: '180px', align: 'left' },
enableSorting: false,
cell: ({ getValue }) => <AvatarCell value={getValue()} />,
}),
columnHelper.accessor('createdAt', {
id: 'Submitted',
header: 'Submitted',
meta: { width: '100px' },
cell: ({ getValue }) => <TimeAgoCell value={getValue()} />,
}),
columnHelper.accessor('environment', {
id: 'Environment',
header: 'Environment',
meta: { width: '100px' },
cell: ({ getValue }) => <HighlightCell value={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
<ChangeRequestStatusCell value={getValue()} row={row} />
), ),
}, }),
{
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 } = const table = useReactTable(
useTable( withTableState(effectiveTableState, setTableState, {
{ columns,
columns: columns as any[], data,
data: mockChangeRequests, }),
initialState: { );
sortBy: [
{ const bodyLoadingRef = useLoading(loading);
id: 'createdAt',
desc: true,
},
],
},
sortTypes,
autoResetHiddenColumns: false,
disableSortRemove: true,
autoResetSortBy: false,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy,
);
return ( return (
<PageContent <PageContent
isLoading={loading} bodyClass='no-padding'
header={<PageHeader title='Change requests' />} header={<PageHeader title='Change requests' />}
> >
<Table {...getTableProps()}> <div className={themeStyles.fullwidth} ref={bodyLoadingRef}>
<SortableTableHeader headerGroups={headerGroups} /> <PaginatedTable tableInstance={table} totalItems={total} />
<TableBody {...getTableBodyProps()}> </div>
{rows.map((row) => {
prepareRow(row);
const { key, ...rowProps } = row.getRowProps();
return (
<TableRow hover key={key} {...rowProps}>
{row.cells.map((cell) => {
const { key, ...cellProps } =
cell.getCellProps();
return (
<TableCell key={key} {...cellProps}>
{cell.render('Cell')}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</PageContent> </PageContent>
); );
}; };

View File

@ -1,6 +1,7 @@
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { Link, styled, Typography } from '@mui/material'; import { Link, styled, Typography } from '@mui/material';
import { Link as RouterLink, type LinkProps } from 'react-router-dom'; import { Link as RouterLink, type LinkProps } from 'react-router-dom';
import { useProjectOverviewNameOrId } from 'hooks/api/getters/useProjectOverview/useProjectOverview';
type IGlobalChangeRequestTitleCellProps = { type IGlobalChangeRequestTitleCellProps = {
value?: any; value?: any;
@ -41,12 +42,12 @@ export const GlobalChangeRequestTitleCell = ({
id, id,
title, title,
project, project,
projectName,
features: featureChanges, features: featureChanges,
segments: segmentChanges, segments: segmentChanges,
} = original; } = original;
const projectName = useProjectOverviewNameOrId(project);
const totalChanges = const totalChanges =
(featureChanges || []).length + (segmentChanges || []).length; featureChanges?.length ?? 0 + segmentChanges?.length ?? 0;
const projectPath = `/projects/${project}`; const projectPath = `/projects/${project}`;
const crPath = `${projectPath}/change-requests/${id}`; const crPath = `${projectPath}/change-requests/${id}`;

View File

@ -1,14 +1,18 @@
import type { VFC } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; import {
import { ChangeRequestStatusBadge } from 'component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge'; ChangeRequestStatusBadge,
type IChangeRequestStatusBadgeProps,
} from 'component/changeRequest/ChangeRequestStatusBadge/ChangeRequestStatusBadge';
import type { FC } from 'react';
interface IChangeRequestStatusCellProps { interface IChangeRequestStatusCellProps {
value?: string | null; // FIXME: proper type value?: string | null; // FIXME: proper type
row: { original: ChangeRequestType }; row: {
original: IChangeRequestStatusBadgeProps['changeRequest'];
};
} }
export const ChangeRequestStatusCell: VFC<IChangeRequestStatusCellProps> = ({ export const ChangeRequestStatusCell: FC<IChangeRequestStatusCellProps> = ({
value, value,
row: { original }, row: { original },
}) => { }) => {

View File

@ -31,6 +31,12 @@ const fallbackData: ChangeRequestSearchResponseSchema = {
const SWR_CACHE_SIZE = 10; const SWR_CACHE_SIZE = 10;
const PATH = 'api/admin/search/change-requests?'; const PATH = 'api/admin/search/change-requests?';
export type SearchChangeRequestsInput = {
[K in keyof SearchChangeRequestsParams]?:
| SearchChangeRequestsParams[K]
| null;
};
const createChangeRequestSearch = () => { const createChangeRequestSearch = () => {
const internalCache: InternalCache = {}; const internalCache: InternalCache = {};
@ -56,7 +62,7 @@ const createChangeRequestSearch = () => {
}; };
return ( return (
params: SearchChangeRequestsParams, params: SearchChangeRequestsInput,
options: SWRConfiguration = {}, options: SWRConfiguration = {},
cachePrefix: string = '', cachePrefix: string = '',
): UseChangeRequestSearchOutput => { ): UseChangeRequestSearchOutput => {
@ -100,11 +106,13 @@ const createChangeRequestSearch = () => {
export const DEFAULT_PAGE_LIMIT = 25; export const DEFAULT_PAGE_LIMIT = 25;
const getChangeRequestSearchFetcher = (params: SearchChangeRequestsParams) => { const getChangeRequestSearchFetcher = (params: SearchChangeRequestsInput) => {
const urlSearchParams = new URLSearchParams( const urlSearchParams = new URLSearchParams(
Array.from( Array.from(
Object.entries(params) Object.entries(params)
.filter(([_, value]) => !!value) .filter(
(param): param is [string, string | number] => !!param[1],
)
.map(([key, value]) => [key, value.toString()]), .map(([key, value]) => [key, value.toString()]),
), ),
).toString(); ).toString();