1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-19 17:52:45 +02:00

feat: global change requests table (#10650)

Adds basic table layout for the global change requests page and makes
the page accessible at `/change-requests`.

The table is based on the project-based change request table, but with a
slightly different set of columns.

Uses mock data for now. 

There's still some styling to be done for the column widths and handling
narrower screens.

<img width="1386" height="671" alt="image"
src="https://github.com/user-attachments/assets/b24ed625-d3f6-4281-ba44-30744d5063f3"
/>

If the flag is disabled, we render nothing useful.
<img width="1429" height="287" alt="image"
src="https://github.com/user-attachments/assets/289b5707-4389-4c08-bf68-55d63e186ba5"
/>


closes 1-4076
This commit is contained in:
Thomas Heartman 2025-09-11 09:15:57 +02:00 committed by GitHub
parent d3e7e67b91
commit fabf76e12c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 379 additions and 0 deletions

View File

@ -0,0 +1,297 @@
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 { 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';
// 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 ChangeRequestsInner = () => {
const loading = false;
const columns = useMemo(
() => [
{
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,
},
{
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,
row: {
original: { title, project },
},
}: any) => (
<FeaturesCell project={project} value={value} key={title} />
),
},
{
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,
);
return (
<PageContent
isLoading={loading}
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>
</PageContent>
);
};
export const ChangeRequests = () => {
if (!useUiFlag('globalChangeRequestList')) {
return (
<PageContent header={<PageHeader title='Change requests' />}>
<p>Nothing to see here. Move along.</p>
</PageContent>
);
}
return <ChangeRequestsInner />;
};

View File

@ -0,0 +1,69 @@
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';
type IGlobalChangeRequestTitleCellProps = {
value?: any;
row: { original: any };
};
const LinkContainer = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const BaseLink = styled(({ children, ...props }: LinkProps) => (
<Link component={RouterLink} {...props}>
{children}
</Link>
))(({ theme }) => ({
textDecoration: 'none',
color: 'inherit',
':hover': {
textDecoration: 'underline',
},
}));
const ChangeRequestLink = styled(BaseLink)(({ theme }) => ({
color: theme.palette.primary.main,
fontWeight: 'bold',
}));
const UpdateText = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
}));
export const GlobalChangeRequestTitleCell = ({
value,
row: { original },
}: IGlobalChangeRequestTitleCellProps) => {
const {
id,
title,
project,
projectName,
features: featureChanges,
segments: segmentChanges,
} = original;
const totalChanges =
(featureChanges || []).length + (segmentChanges || []).length;
const projectPath = `/projects/${project}`;
const crPath = `${projectPath}/change-requests/${id}`;
if (!value) {
return <TextCell />;
}
return (
<TextCell sx={{ minWidth: '300px' }}>
<LinkContainer>
<BaseLink to={projectPath}>{projectName}</BaseLink>
<span aria-hidden='true'> / </span>
<ChangeRequestLink to={crPath}>{title}</ChangeRequestLink>
</LinkContainer>
<UpdateText>
{`${totalChanges}`} {totalChanges === 1 ? `update` : 'updates'}
</UpdateText>
</TextCell>
);
};

View File

@ -53,6 +53,7 @@ import { CreateReleasePlanTemplate } from 'component/releases/ReleasePlanTemplat
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate'; import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js'; import { ExploreCounters } from 'component/counters/ExploreCounters/ExploreCounters.js';
import { UnknownFlagsTable } from 'component/unknownFlags/UnknownFlagsTable'; import { UnknownFlagsTable } from 'component/unknownFlags/UnknownFlagsTable';
import { ChangeRequests } from 'component/changeRequest/ChangeRequests/ChangeRequests';
export const routes: IRoute[] = [ export const routes: IRoute[] = [
// Splash // Splash
@ -478,6 +479,18 @@ export const routes: IRoute[] = [
menu: {}, menu: {},
}, },
// My change requests
{
path: '/change-requests',
title: 'Change Requests',
component: ChangeRequests,
type: 'protected',
menu: {},
flag: 'globalChangeRequestList',
hidden: true,
enterprise: true,
},
// Admin // Admin
{ {
path: '/admin/*', path: '/admin/*',