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:
parent
47c2bb7376
commit
07572c8137
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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',
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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} />;
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user