From 1932d21336e2b881b64ff28e753f6eb1908cbffc Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 24 Oct 2025 14:30:23 +0200 Subject: [PATCH] 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). image image --- .../ChangeRequests/ChangeRequestFilters.tsx | 105 ---------------- .../ChangeRequestFilters.styles.tsx | 67 +++++++++++ .../ChangeRequestFilters.tsx | 35 ++++++ .../ChangeRequestFilters.types.ts | 15 +++ .../ChangeRequestFilters/StateFilterChips.tsx | 48 ++++++++ .../ChangeRequestFilters/UserFilterChips.tsx | 82 +++++++++++++ .../ChangeRequests/ChangeRequests.tsx | 113 +++++++----------- .../ChangeRequests/ChangeRequests.types.tsx | 20 ++++ frontend/src/openapi/models/index.ts | 1 + 9 files changed, 308 insertions(+), 178 deletions(-) delete mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.styles.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.types.ts create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/StateFilterChips.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/UserFilterChips.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.types.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx deleted file mode 100644 index f4eaf5e39d..0000000000 --- a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx +++ /dev/null @@ -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) => ( - - ))(({ 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 = ({ - onSelectionChange, - initialSelection, - ariaControlTarget, -}) => { - const [selected, setSelected] = useState( - initialSelection || 'Created', - ); - const handleSelectionChange = (value: ChangeRequestQuickFilter) => () => { - if (value === selected) { - return; - } - setSelected(value); - onSelectionChange(value); - }; - - const StyledChip = makeStyledChip(ariaControlTarget); - - return ( - - - - - - - ); -}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.styles.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.styles.tsx new file mode 100644 index 0000000000..8efd153f94 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.styles.tsx @@ -0,0 +1,67 @@ +import { Box, Chip, styled, type ChipProps } from '@mui/material'; + +export const makeStyledChip = (ariaControlTarget: string) => + styled(({ ...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', +}); diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.tsx new file mode 100644 index 0000000000..73e246bf2e --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.tsx @@ -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 = ({ + tableState, + setTableState, + userId, + ariaControlTarget, +}) => { + const updateTableState = (update: Partial) => { + setTableState({ ...tableState, ...update, offset: 0 }); + }; + + const StyledChip = makeStyledChip(ariaControlTarget); + + return ( + + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.types.ts b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.types.ts new file mode 100644 index 0000000000..75d8efac97 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/ChangeRequestFilters.types.ts @@ -0,0 +1,15 @@ +import type { TableState } from '../ChangeRequests.types'; +import type { makeStyledChip } from './ChangeRequestFilters.styles'; + +export type ChangeRequestFiltersProps = { + tableState: Readonly; + setTableState: (update: Partial) => void; + userId: number; + ariaControlTarget: string; +}; + +export type FilterChipsProps = { + tableState: ChangeRequestFiltersProps['tableState']; + setTableState: ChangeRequestFiltersProps['setTableState']; + StyledChip: ReturnType; +}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/StateFilterChips.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/StateFilterChips.tsx new file mode 100644 index 0000000000..349b76fd46 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/StateFilterChips.tsx @@ -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 = ({ + 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 ( + + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/UserFilterChips.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/UserFilterChips.tsx new file mode 100644 index 0000000000..194fe49bc4 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters/UserFilterChips.tsx @@ -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 = ({ + 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 ( + + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx index ebcf6b8392..aea8fa070d 100644 --- a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx @@ -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(); @@ -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 = () => { >
{ }; export const ChangeRequests = () => { + const { user } = useAuthUser(); if (!useUiFlag('globalChangeRequestList')) { return ( }> @@ -239,5 +195,16 @@ export const ChangeRequests = () => { ); } - return ; + if (!user) { + return ( + }> +

+ Failed to get your user information. Please refresh. If the + problem persists, get in touch. +

+
+ ); + } + + return ; }; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.types.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.types.tsx new file mode 100644 index 0000000000..22572ec9ea --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.types.tsx @@ -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; diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 632e693b45..d7341ba249 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -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';