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';