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

This filter behaves in the same way as the `createdBy`/`requestedApprovalBy` filters.
This commit is contained in:
Thomas Heartman 2025-10-24 08:24:40 +02:00
parent 47c2bb7376
commit 07572c8137
No known key found for this signature in database
GPG Key ID: BD1F880DAED1EE78
9 changed files with 299 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,58 @@
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,
},
'& .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';