1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

chore: Add state filter to UI query; default to open (#10858)

Add an open/closed filter to the global change requests table.

To accomodate this, the PR:
- refactors the previous `ChangeRequestFilters` into its own directory
with additional files for each of the filter groups.
- updates the change request filters to work based on the table state
instead of its own external state (this was fine with only one param,
but would've gotten too complicated with two).


<img width="1108" height="442" alt="image"
src="https://github.com/user-attachments/assets/9cda0cbc-8524-45ce-b201-260e9581a346"
/>
<img width="1101" height="381" alt="image"
src="https://github.com/user-attachments/assets/ab77b17f-5449-4987-9d08-341e856cb7ac"
/>
This commit is contained in:
Thomas Heartman 2025-10-24 14:30:23 +02:00 committed by GitHub
parent fd4fa815a9
commit 1932d21336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 308 additions and 178 deletions

View File

@ -1,105 +0,0 @@
import { Box, Chip, styled, type ChipProps } from '@mui/material';
import { useState, type FC } from 'react';
const makeStyledChip = (ariaControlTarget: string) =>
styled(({ ...props }: ChipProps) => (
<Chip variant='outlined' aria-controls={ariaControlTarget} {...props} />
))<ChipProps>(({ theme, label }) => ({
padding: theme.spacing(0.5),
fontSize: theme.typography.body2.fontSize,
height: 'auto',
'&[aria-current="true"]': {
backgroundColor: theme.palette.secondary.light,
fontWeight: 'bold',
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
},
':focus-visible': {
outline: `1px solid ${theme.palette.primary.main}`,
borderColor: theme.palette.primary.main,
},
borderRadius: 0,
'&:first-of-type': {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
'&:last-of-type': {
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
'& .MuiChip-label': {
position: 'relative',
textAlign: 'center',
'&::before': {
content: `'${label}'`,
fontWeight: 'bold',
visibility: 'hidden',
height: 0,
display: 'block',
overflow: 'hidden',
userSelect: 'none',
},
},
}));
export type ChangeRequestQuickFilter = 'Created' | 'Approval Requested';
interface IChangeRequestFiltersProps {
ariaControlTarget: string;
initialSelection?: ChangeRequestQuickFilter;
onSelectionChange: (selection: ChangeRequestQuickFilter) => void;
}
const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-start',
padding: theme.spacing(1.5, 3, 0, 3),
minHeight: theme.spacing(7),
gap: theme.spacing(2),
}));
const StyledContainer = styled(Box)({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
});
export const ChangeRequestFilters: FC<IChangeRequestFiltersProps> = ({
onSelectionChange,
initialSelection,
ariaControlTarget,
}) => {
const [selected, setSelected] = useState<ChangeRequestQuickFilter>(
initialSelection || 'Created',
);
const handleSelectionChange = (value: ChangeRequestQuickFilter) => () => {
if (value === selected) {
return;
}
setSelected(value);
onSelectionChange(value);
};
const StyledChip = makeStyledChip(ariaControlTarget);
return (
<Wrapper>
<StyledContainer>
<StyledChip
label={'Created'}
aria-current={selected === 'Created'}
onClick={handleSelectionChange('Created')}
title={'Show change requests created by you'}
/>
<StyledChip
label={'Approval Requested'}
aria-current={selected === 'Approval Requested'}
onClick={handleSelectionChange('Approval Requested')}
title={'Show change requests requesting your approval'}
/>
</StyledContainer>
</Wrapper>
);
};

View File

@ -0,0 +1,67 @@
import { Box, Chip, styled, type ChipProps } from '@mui/material';
export const makeStyledChip = (ariaControlTarget: string) =>
styled(({ ...props }: ChipProps) => (
<Chip variant='outlined' aria-controls={ariaControlTarget} {...props} />
))<ChipProps>(({ theme, label }) => ({
padding: theme.spacing(0.5),
fontSize: theme.typography.body2.fontSize,
height: 'auto',
'&[aria-current="true"]': {
backgroundColor: theme.palette.secondary.light,
fontWeight: 'bold',
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
},
':focus-visible': {
outline: `1px solid ${theme.palette.primary.main}`,
borderColor: theme.palette.primary.main,
},
borderRadius: 0,
'&:first-of-type': {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
'&:last-of-type': {
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
'&:not(&[aria-current="true"])': {
'&:not(&:first-of-type)': {
borderLeftWidth: 0,
},
'&:not(&:last-of-type)': {
borderRightWidth: 0,
},
},
'& .MuiChip-label': {
position: 'relative',
textAlign: 'center',
'&::before': {
content: `'${label}'`,
fontWeight: 'bold',
visibility: 'hidden',
height: 0,
display: 'block',
overflow: 'hidden',
userSelect: 'none',
},
},
}));
export const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-start',
flexFlow: 'row wrap',
padding: theme.spacing(1.5, 3, 0, 3),
minHeight: theme.spacing(7),
gap: theme.spacing(2),
}));
export const StyledContainer = styled(Box)({
display: 'flex',
alignItems: 'center',
});

View File

@ -0,0 +1,35 @@
import type { FC } from 'react';
import type { TableState } from '../ChangeRequests.types';
import { makeStyledChip, Wrapper } from './ChangeRequestFilters.styles';
import { UserFilterChips } from './UserFilterChips.tsx';
import { StateFilterChips } from './StateFilterChips.tsx';
import type { ChangeRequestFiltersProps } from './ChangeRequestFilters.types.ts';
export const ChangeRequestFilters: FC<ChangeRequestFiltersProps> = ({
tableState,
setTableState,
userId,
ariaControlTarget,
}) => {
const updateTableState = (update: Partial<TableState>) => {
setTableState({ ...tableState, ...update, offset: 0 });
};
const StyledChip = makeStyledChip(ariaControlTarget);
return (
<Wrapper>
<UserFilterChips
tableState={tableState}
setTableState={updateTableState}
userId={userId}
StyledChip={StyledChip}
/>
<StateFilterChips
tableState={tableState}
setTableState={updateTableState}
StyledChip={StyledChip}
/>
</Wrapper>
);
};

View File

@ -0,0 +1,15 @@
import type { TableState } from '../ChangeRequests.types';
import type { makeStyledChip } from './ChangeRequestFilters.styles';
export type ChangeRequestFiltersProps = {
tableState: Readonly<TableState>;
setTableState: (update: Partial<TableState>) => void;
userId: number;
ariaControlTarget: string;
};
export type FilterChipsProps = {
tableState: ChangeRequestFiltersProps['tableState'];
setTableState: ChangeRequestFiltersProps['setTableState'];
StyledChip: ReturnType<typeof makeStyledChip>;
};

View File

@ -0,0 +1,48 @@
import type { FC } from 'react';
import { StyledContainer } from './ChangeRequestFilters.styles';
import type { FilterChipsProps } from './ChangeRequestFilters.types';
type StateFilterType = 'open' | 'closed';
const getStateFilter = (
stateValue: string | undefined,
): StateFilterType | undefined => {
if (stateValue === 'open') {
return 'open';
}
if (stateValue === 'closed') {
return 'closed';
}
};
export const StateFilterChips: FC<FilterChipsProps> = ({
tableState,
setTableState,
StyledChip,
}) => {
const activeStateFilter = getStateFilter(tableState.state?.values?.[0]);
const handleStateFilterChange = (filter: StateFilterType) => () => {
if (filter === activeStateFilter) {
return;
}
setTableState({ state: { operator: 'IS' as const, values: [filter] } });
};
return (
<StyledContainer>
<StyledChip
label={'Open'}
aria-current={activeStateFilter === 'open'}
onClick={handleStateFilterChange('open')}
title={'Show open change requests'}
/>
<StyledChip
label={'Closed'}
aria-current={activeStateFilter === 'closed'}
onClick={handleStateFilterChange('closed')}
title={'Show closed change requests'}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,82 @@
import type { FC } from 'react';
import type { TableState } from '../ChangeRequests.types';
import { StyledContainer } from './ChangeRequestFilters.styles';
import type { FilterChipsProps } from './ChangeRequestFilters.types';
type UserFilterType = 'created' | 'approval requested';
type UserFilterChipsProps = FilterChipsProps & { userId: number };
const getUserFilter = (
tableState: TableState,
userId: string,
): UserFilterType | undefined => {
if (
!tableState.requestedApproverId &&
tableState.createdBy?.values.length === 1 &&
tableState.createdBy.values[0] === userId
) {
return 'created';
}
if (
!tableState.createdBy &&
tableState.requestedApproverId?.values.length === 1 &&
tableState.requestedApproverId.values[0] === userId
) {
return 'approval requested';
}
};
export const UserFilterChips: FC<UserFilterChipsProps> = ({
tableState,
setTableState,
userId,
StyledChip,
}) => {
const userIdString = userId.toString();
const activeUserFilter: UserFilterType | undefined = getUserFilter(
tableState,
userIdString,
);
const handleUserFilterChange = (filter: UserFilterType) => () => {
if (filter === activeUserFilter) {
return;
}
const [targetProperty, otherProperty] =
filter === 'created'
? (['createdBy', 'requestedApproverId'] as const)
: (['requestedApproverId', 'createdBy'] as const);
setTableState({
[targetProperty]: {
operator: 'IS' as const,
values: [userIdString],
},
[otherProperty]:
tableState[otherProperty]?.values.length === 1 &&
tableState[otherProperty]?.values[0] === userIdString
? null
: tableState[otherProperty],
});
};
return (
<StyledContainer>
<StyledChip
label={'Created'}
aria-current={activeUserFilter === 'created'}
onClick={handleUserFilterChange('created')}
title={'Show change requests created by you'}
/>
<StyledChip
label={'Approval Requested'}
aria-current={activeUserFilter === 'approval requested'}
onClick={handleUserFilterChange('approval requested')}
title={'Show change requests requesting your approval'}
/>
</StyledContainer>
);
};

View File

@ -14,25 +14,16 @@ import { useUiFlag } from 'hooks/useUiFlag.js';
import { withTableState } from 'utils/withTableState';
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 { useQueryParams, encodeQueryParams } from 'use-query-params';
import useLoading from 'hooks/useLoading';
import { styles as themeStyles } from 'component/common';
import { FilterItemParam } from 'utils/serializeQueryParams';
import {
ChangeRequestFilters,
type ChangeRequestQuickFilter,
} from './ChangeRequestFilters.js';
import { ChangeRequestFilters } from './ChangeRequestFilters/ChangeRequestFilters.js';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser.js';
import type { IUser } from 'interfaces/user.js';
import { stateConfig } from './ChangeRequests.types.js';
const columnHelper = createColumnHelper<ChangeRequestSearchItemSchema>();
@ -49,46 +40,33 @@ const StyledPaginatedTable = styled(
},
}));
const ChangeRequestsInner = () => {
const { user } = useAuthUser();
const defaultTableState = (user: IUser) => ({
createdBy: {
operator: 'IS' as const,
values: [user.id.toString()],
},
state: {
operator: 'IS' as const,
values: ['open'],
},
});
const ChangeRequestsInner = ({ user }: { user: IUser }) => {
const urlParams = new URLSearchParams(window.location.search);
const shouldApplyDefaults =
user &&
!urlParams.has('createdBy') &&
!urlParams.has('requestedApproverId');
const initialFilter = urlParams.has('requestedApproverId')
? 'Approval Requested'
: 'Created';
const shouldApplyDefaults = !urlParams.toString();
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.id.toString()],
},
}
: {};
const initialState = shouldApplyDefaults ? defaultTableState(user) : {};
const [tableState, setTableState] = useQueryParams(stateConfig, {
updateType: 'replaceIn',
});
const effectiveTableState = useMemo(
() => ({
...tableState,
...initialState,
}),
[initialState, tableState],
);
const effectiveTableState = shouldApplyDefaults
? {
...tableState,
...initialState,
}
: tableState;
const {
changeRequests: data,
@ -172,30 +150,6 @@ const ChangeRequestsInner = () => {
}),
);
const tableId = useId();
const handleQuickFilterChange = (filter: ChangeRequestQuickFilter) => {
if (!user) {
// todo (globalChangeRequestList): handle this somehow? Or just ignore.
return;
}
const [targetProperty, otherProperty] =
filter === 'Created'
? ['createdBy', 'requestedApproverId']
: ['requestedApproverId', 'createdBy'];
// todo (globalChangeRequestList): extract and test the logic for wiping out createdby/requestedapproverid
setTableState((state) => ({
[targetProperty]: {
operator: 'IS',
values: [user.id.toString()],
},
[otherProperty]:
state[otherProperty]?.values.length === 1 &&
state[otherProperty].values[0] === user.id.toString()
? null
: state[otherProperty],
}));
};
const bodyLoadingRef = useLoading(loading);
return (
@ -205,8 +159,9 @@ const ChangeRequestsInner = () => {
>
<ChangeRequestFilters
ariaControlTarget={tableId}
initialSelection={initialFilter}
onSelectionChange={handleQuickFilterChange}
tableState={effectiveTableState}
setTableState={setTableState}
userId={user.id}
/>
<div
@ -231,6 +186,7 @@ const ChangeRequestsInner = () => {
};
export const ChangeRequests = () => {
const { user } = useAuthUser();
if (!useUiFlag('globalChangeRequestList')) {
return (
<PageContent header={<PageHeader title='Change requests' />}>
@ -239,5 +195,16 @@ export const ChangeRequests = () => {
);
}
return <ChangeRequestsInner />;
if (!user) {
return (
<PageContent header={<PageHeader title='Change requests' />}>
<p>
Failed to get your user information. Please refresh. If the
problem persists, get in touch.
</p>
</PageContent>
);
}
return <ChangeRequestsInner user={user} />;
};

View File

@ -0,0 +1,20 @@
import { DEFAULT_PAGE_LIMIT } from 'hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch';
import {
NumberParam,
StringParam,
withDefault,
type DecodedValueMap,
} from 'use-query-params';
import { FilterItemParam } from 'utils/serializeQueryParams';
export const stateConfig = {
offset: withDefault(NumberParam, 0),
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
sortBy: withDefault(StringParam, 'createdAt'),
sortOrder: withDefault(StringParam, 'desc'),
createdBy: FilterItemParam,
requestedApproverId: FilterItemParam,
state: FilterItemParam,
};
export type TableState = DecodedValueMap<typeof stateConfig>;

View File

@ -1303,6 +1303,7 @@ export * from './searchChangeRequests401.js';
export * from './searchChangeRequests403.js';
export * from './searchChangeRequests404.js';
export * from './searchChangeRequestsParams.js';
export * from './searchChangeRequestsState.js';
export * from './searchEventsParams.js';
export * from './searchFeatures401.js';
export * from './searchFeatures403.js';