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:
parent
c824b3e26b
commit
4dd97b97f4
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}`;
|
||||
|
||||
|
@ -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 },
|
||||
}) => {
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user