From 99c4f7111ace18602eb87df9666cf9087501143d Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 26 Sep 2025 12:42:47 +0200 Subject: [PATCH] feat: add change request table filter buttons (#10679) Adds filter buttons for filtering between "CRs created by me" and "CRs where I've been requested as an approver". The current implementation is fairly simplistic and the buttons are not connected to the actual table state directly (instead being set up with their own simple state and onChange hooks), but it covers the simple scenario. I want to defer a more complex solution until we know we need it and until we know exactly what we need. The implementation is based on the lifecycle filters that we have on the project flags page. The current logic is such that: when you land on the page, there's no query params in the URL, but the data fetch applies `createdBy:IS`. If you switch to "approval requested" (and back again), the URL will reflect this. For reference, the github workflow works like this, where each URL has a set of default filters, e.g.: - `/pulls`: `is:open is:pr assignee:thomasheartman archived:false` - `/pulls/review-requested`: `is:open is:pr review-requested:thomasheartman archived:false` But if you change the default filters or add new ones, the URL will update to `pulls?` (e.g. `/pulls?q=is%3Aopen+is%3Apr+review-requested%3Athomasheartman+archived%3Atrue`) So this takes a similar approach, but better suited to the way we do tables in general. Rendered: image image --- .../ChangeRequests/ChangeRequestFilters.tsx | 82 +++++++++++++++++++ .../ChangeRequests/ChangeRequests.tsx | 65 +++++++++++---- 2 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx new file mode 100644 index 0000000000..cc23ba2efb --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequestFilters.tsx @@ -0,0 +1,82 @@ +import { Box, Chip, styled } from '@mui/material'; +import { useState, type FC } from 'react'; + +const StyledChip = styled(Chip)(({ theme }) => ({ + borderRadius: `${theme.shape.borderRadius}px`, + 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, + }, +})); + +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)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(1), +})); + +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); + }; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx index f08a3ba2c0..1d3de4735d 100644 --- a/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx +++ b/frontend/src/component/changeRequest/ChangeRequests/ChangeRequests.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useId, useMemo } from 'react'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PaginatedTable } from 'component/common/Table'; @@ -11,7 +11,6 @@ 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, @@ -28,20 +27,24 @@ import { 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 { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser.js'; const columnHelper = createColumnHelper(); const ChangeRequestsInner = () => { const { user } = useAuthUser(); - - const shouldApplyDefaults = useMemo(() => { - const urlParams = new URLSearchParams(window.location.search); - return ( - !urlParams.has('createdBy') && - !urlParams.has('requestedApproverId') && - user - ); - }, [user]); + 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 stateConfig = { offset: withDefault(NumberParam, 0), @@ -56,7 +59,7 @@ const ChangeRequestsInner = () => { ? { createdBy: { operator: 'IS' as const, - values: user ? [user.id.toString()] : [], + values: [user.id.toString()], }, } : {}; @@ -67,8 +70,8 @@ const ChangeRequestsInner = () => { const effectiveTableState = useMemo( () => ({ - ...initialState, ...tableState, + ...initialState, }), [initialState, tableState], ); @@ -158,6 +161,30 @@ const ChangeRequestsInner = () => { data, }), ); + 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); @@ -166,7 +193,17 @@ const ChangeRequestsInner = () => { bodyClass='no-padding' header={} > -
+ + +