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 { 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<UnscheduledChangeRequest, 'state'>
| Pick<ScheduledChangeRequest, 'state' | 'schedule'>
| undefined;
}
const ReviewRequiredBadge: VFC = () => (
const ReviewRequiredBadge: FC = () => (
<Badge color='secondary' icon={<CircleOutlined fontSize={'small'} />}>
Review required
</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,
}) => {
const { locationSettings } = useLocationSettings();

View File

@ -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<ChangeRequestSearchItemSchema>();
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 }) => (
<GlobalChangeRequestTitleCell
value={getValue()}
row={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<string>,
) => {
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) => (
<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 } =
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 (
<PageContent
isLoading={loading}
bodyClass='no-padding'
header={<PageHeader title='Change requests' />}
>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{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>
<div className={themeStyles.fullwidth} ref={bodyLoadingRef}>
<PaginatedTable tableInstance={table} totalItems={total} />
</div>
</PageContent>
);
};

View File

@ -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}`;

View File

@ -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<IChangeRequestStatusCellProps> = ({
export const ChangeRequestStatusCell: FC<IChangeRequestStatusCellProps> = ({
value,
row: { original },
}) => {

View File

@ -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();