Merge pull request #2408 from automatisch/flow-filters
feat(flows): add flow filters
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
mongoQueryMatcher,
|
||||
} from '@casl/ability';
|
||||
|
||||
// Must be kept in sync with `packages/web/src/helpers/userAbility.ts`!
|
||||
// Must be kept in sync with `packages/web/src/helpers/userAbility.js`!
|
||||
export default function userAbility(user) {
|
||||
const permissions = user?.permissions;
|
||||
const role = user?.role;
|
||||
|
||||
121
packages/web/src/components/FlowFilters/index.jsx
Normal file
121
packages/web/src/components/FlowFilters/index.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
import Can from 'components/Can';
|
||||
import useCurrentUserRuleConditions from 'hooks/useCurrentUserRuleConditions';
|
||||
import useFlowFilters from 'hooks/useFlowFilters';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
export default function FlowFilters({ onFilterChange }) {
|
||||
const theme = useTheme();
|
||||
const formatMessage = useFormatMessage();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const currentUserRuleConditions = useCurrentUserRuleConditions();
|
||||
|
||||
const { filters, filterByStatus, filterByOwnership } = useFlowFilters();
|
||||
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const currentUserReadFlowsConditions = currentUserRuleConditions(
|
||||
'read',
|
||||
'Flow',
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Mobile: Toggle Button for Filters */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<FilterAltIcon />}
|
||||
onClick={() => setMobileFiltersOpen((prev) => !prev)}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{mobileFiltersOpen
|
||||
? formatMessage('flowFilters.hideFilters')
|
||||
: formatMessage('flowFilters.showFilters')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Filters Box (Always Visible on Large Screens) */}
|
||||
<Collapse in={!isMobile || mobileFiltersOpen}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
alignItems: { md: 'center' },
|
||||
}}
|
||||
>
|
||||
{/* User Flows Filter */}
|
||||
{currentUserReadFlowsConditions &&
|
||||
!currentUserReadFlowsConditions?.isCreator && (
|
||||
<FormControl
|
||||
fullWidth
|
||||
sx={{ maxWidth: { md: 200 } }}
|
||||
variant="outlined"
|
||||
>
|
||||
<InputLabel shrink>
|
||||
{formatMessage('flowFilters.flowsFilterLabel')}
|
||||
</InputLabel>
|
||||
|
||||
<Select
|
||||
label={formatMessage('flowFilters.flowsFilterLabel')}
|
||||
value={filters.onlyOwnedFlows}
|
||||
displayEmpty
|
||||
onChange={(e) => filterByOwnership(e.target.value)}
|
||||
key={filters.onlyOwnedFlows}
|
||||
>
|
||||
<MenuItem value={undefined}>
|
||||
{formatMessage('flowFilters.flowsFilterAllOption')}
|
||||
</MenuItem>
|
||||
<MenuItem value={true}>
|
||||
{formatMessage('flowFilters.flowsFilterOnlyMineOption')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<FormControl fullWidth sx={{ maxWidth: { md: 200 } }}>
|
||||
<InputLabel shrink>
|
||||
{formatMessage('flowFilters.statusFilterLabel')}
|
||||
</InputLabel>
|
||||
|
||||
<Select
|
||||
label={formatMessage('flowFilters.statusFilterLabel')}
|
||||
value={filters.status}
|
||||
displayEmpty
|
||||
onChange={(e) => filterByStatus(e.target.value)}
|
||||
key={filters.status}
|
||||
>
|
||||
<MenuItem value={undefined}>
|
||||
{formatMessage('flowFilters.statusFilterAnyOption')}
|
||||
</MenuItem>
|
||||
<MenuItem value="published">
|
||||
{formatMessage('flowFilters.statusFilterPublishedOption')}
|
||||
</MenuItem>
|
||||
<MenuItem value="draft">
|
||||
{formatMessage('flowFilters.statusFilterDraftOption')}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Card from '@mui/material/Card';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
@@ -23,6 +24,8 @@ import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useFolders from 'hooks/useFolders';
|
||||
import useDeleteFolder from 'hooks/useDeleteFolder';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import objectifyUrlSearchParams from 'helpers/objectifyUrlSearchParams';
|
||||
import useFlowFilters from 'hooks/useFlowFilters';
|
||||
|
||||
import { ListItemIcon } from './style';
|
||||
|
||||
@@ -35,6 +38,8 @@ export default function Folders() {
|
||||
const { data: folders } = useFolders();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const { enhanceExistingSearchParams } = useFlowFilters();
|
||||
|
||||
const {
|
||||
mutateAsync: deleteFolder,
|
||||
error: deleteFolderError,
|
||||
@@ -53,8 +58,8 @@ export default function Folders() {
|
||||
(folder) => folder.id === selectedFolderId,
|
||||
);
|
||||
|
||||
const allFlowsFolder = new URLSearchParams().toString();
|
||||
const unassignedFlowsFolder = new URLSearchParams('folderId=null').toString();
|
||||
const allFlowsFolder = enhanceExistingSearchParams('folderId', undefined);
|
||||
const unassignedFlowsFolder = enhanceExistingSearchParams('folderId', 'null');
|
||||
|
||||
const allFlowsFolderSelected = selectedFolderId === null;
|
||||
const unassignedFlowsFolderSelected = selectedFolderId === 'null'; // intendedly stringified
|
||||
@@ -86,9 +91,7 @@ export default function Folders() {
|
||||
};
|
||||
|
||||
const getFolderSearchParams = (folderId) => {
|
||||
const searchParams = new URLSearchParams(`folderId=${folderId}`);
|
||||
|
||||
return searchParams.toString();
|
||||
return enhanceExistingSearchParams('folderId', folderId).toString();
|
||||
};
|
||||
|
||||
const generateFolderItem = (folder) => {
|
||||
@@ -148,10 +151,7 @@ export default function Folders() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component={Card}
|
||||
// sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}
|
||||
>
|
||||
<Box component={Card}>
|
||||
<List component="nav" aria-label="static folders">
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
|
||||
5
packages/web/src/helpers/objectifyUrlSearchParams.js
Normal file
5
packages/web/src/helpers/objectifyUrlSearchParams.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function objectifyUrlSearchParams(searchParams) {
|
||||
return Array.from(searchParams.entries()).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [key]: value };
|
||||
}, {});
|
||||
}
|
||||
@@ -3,17 +3,21 @@ import {
|
||||
fieldPatternMatcher,
|
||||
mongoQueryMatcher,
|
||||
} from '@casl/ability';
|
||||
// Must be kept in sync with `packages/backend/src/helpers/user-ability.ts`!
|
||||
|
||||
// Must be kept in sync with `packages/backend/src/helpers/user-ability.js`!
|
||||
export default function userAbility(user) {
|
||||
const permissions = user?.permissions;
|
||||
const role = user?.role;
|
||||
|
||||
// We're not using mongo, but our fields, conditions match
|
||||
const options = {
|
||||
conditionsMatcher: mongoQueryMatcher,
|
||||
fieldMatcher: fieldPatternMatcher,
|
||||
};
|
||||
|
||||
if (!role || !permissions) {
|
||||
return new PureAbility([], options);
|
||||
}
|
||||
|
||||
return new PureAbility(permissions, options);
|
||||
}
|
||||
|
||||
20
packages/web/src/hooks/useCurrentUserRuleConditions.js
Normal file
20
packages/web/src/hooks/useCurrentUserRuleConditions.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
|
||||
export default function useCurrentUserRuleConditions() {
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
|
||||
return function canCurrentUser(action, subject) {
|
||||
const can = currentUserAbility.can(action, subject);
|
||||
|
||||
if (!can) return false;
|
||||
|
||||
const relevantRule = currentUserAbility.relevantRuleFor(action, subject);
|
||||
|
||||
const conditions = relevantRule?.conditions || [];
|
||||
const conditionMap = Object.fromEntries(
|
||||
conditions.map((condition) => [condition, true]),
|
||||
);
|
||||
|
||||
return conditionMap;
|
||||
};
|
||||
}
|
||||
67
packages/web/src/hooks/useFlowFilters.js
Normal file
67
packages/web/src/hooks/useFlowFilters.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import api from 'helpers/api';
|
||||
import objectifyUrlSearchParams from 'helpers/objectifyUrlSearchParams';
|
||||
|
||||
export default function useFlowFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const searchParamsObject = objectifyUrlSearchParams(searchParams);
|
||||
|
||||
const { folderId, status } = searchParamsObject;
|
||||
const onlyOwnedFlows =
|
||||
searchParamsObject.onlyOwnedFlows === 'true' || undefined;
|
||||
|
||||
const filterByStatus = (status) => {
|
||||
setSearchParams((current) => {
|
||||
const { status: currentStatus, ...rest } = searchParamsObject;
|
||||
|
||||
if (status) {
|
||||
return { ...rest, status };
|
||||
}
|
||||
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const filterByOwnership = (onlyOwnedFlows) => {
|
||||
setSearchParams((current) => {
|
||||
const { onlyOwnedFlows: currentOnlyOwnedFlows, ...rest } =
|
||||
searchParamsObject;
|
||||
|
||||
if (onlyOwnedFlows) {
|
||||
return { ...rest, onlyOwnedFlows: true };
|
||||
}
|
||||
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const enhanceExistingSearchParams = (key, value) => {
|
||||
const searchParamsObject = objectifyUrlSearchParams(searchParams);
|
||||
|
||||
if (value === undefined) {
|
||||
const { [key]: keyToRemove, ...remainingSearchParams } =
|
||||
searchParamsObject;
|
||||
|
||||
return new URLSearchParams(remainingSearchParams).toString();
|
||||
}
|
||||
|
||||
return new URLSearchParams({
|
||||
...searchParamsObject,
|
||||
[key]: value,
|
||||
}).toString();
|
||||
};
|
||||
|
||||
return {
|
||||
filters: {
|
||||
folderId,
|
||||
status,
|
||||
onlyOwnedFlows,
|
||||
},
|
||||
filterByStatus,
|
||||
filterByOwnership,
|
||||
enhanceExistingSearchParams,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import api from 'helpers/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export default function useFlows({ flowName, page, folderId }) {
|
||||
export default function useFlows({
|
||||
flowName,
|
||||
page,
|
||||
folderId,
|
||||
status,
|
||||
onlyOwnedFlows,
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: ['flows', flowName, { page, folderId }],
|
||||
queryKey: ['flows', { flowName, page, folderId, status, onlyOwnedFlows }],
|
||||
queryFn: async ({ signal }) => {
|
||||
const { data } = await api.get('/v1/flows', {
|
||||
params: { name: flowName, page, folderId },
|
||||
params: { name: flowName, page, folderId, status, onlyOwnedFlows },
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -386,5 +386,14 @@
|
||||
"adminCreateTemplate.titleFieldLabel": "Template name",
|
||||
"adminCreateTemplate.submit": "Create",
|
||||
"adminTemplateContextMenu.delete": "Delete",
|
||||
"adminTemplateContextMenu.successfullyDeleted": "The template has been deleted."
|
||||
"adminTemplateContextMenu.successfullyDeleted": "The template has been deleted.",
|
||||
"flowFilters.hideFilters": "Hide filters",
|
||||
"flowFilters.showFilters": "Show Filters",
|
||||
"flowFilters.flowsFilterLabel": "Flows",
|
||||
"flowFilters.flowsFilterAllOption": "All",
|
||||
"flowFilters.flowsFilterOnlyMineOption": "Only mine",
|
||||
"flowFilters.statusFilterLabel": "Status",
|
||||
"flowFilters.statusFilterAnyOption": "All",
|
||||
"flowFilters.statusFilterPublishedOption": "Published",
|
||||
"flowFilters.statusFilterDraftOption": "Draft"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Can from 'components/Can';
|
||||
import FlowFilters from 'components/FlowFilters';
|
||||
import FlowsButtons from 'components/FlowsButtons';
|
||||
import ConditionalIconButton from 'components/ConditionalIconButton';
|
||||
import Container from 'components/Container';
|
||||
@@ -30,6 +31,7 @@ import * as URLS from 'config/urls';
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
import useFlows from 'hooks/useFlows';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import objectifyUrlSearchParams from 'helpers/objectifyUrlSearchParams';
|
||||
|
||||
export default function Flows() {
|
||||
const formatMessage = useFormatMessage();
|
||||
@@ -38,10 +40,18 @@ export default function Flows() {
|
||||
const page = parseInt(searchParams.get('page') || '', 10) || 1;
|
||||
const flowName = searchParams.get('flowName') || '';
|
||||
const folderId = searchParams.get('folderId');
|
||||
const status = searchParams.get('status');
|
||||
const onlyOwnedFlows = searchParams.get('onlyOwnedFlows');
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
const [searchValue, setSearchValue] = React.useState(flowName);
|
||||
|
||||
const { data, isSuccess, isLoading } = useFlows({ flowName, page, folderId });
|
||||
const { data, isSuccess, isLoading } = useFlows({
|
||||
flowName,
|
||||
page,
|
||||
folderId,
|
||||
status,
|
||||
onlyOwnedFlows,
|
||||
});
|
||||
|
||||
const flows = data?.data || [];
|
||||
const pageInfo = data?.meta;
|
||||
@@ -51,36 +61,33 @@ export default function Flows() {
|
||||
const onSearchChange = React.useCallback(
|
||||
(event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
setSearchValue(value);
|
||||
|
||||
setSearchParams({
|
||||
flowName: value,
|
||||
...(folderId && { folderId }),
|
||||
...(onlyOwnedFlows && { onlyOwnedFlows }),
|
||||
...(status && { status }),
|
||||
});
|
||||
},
|
||||
[folderId, setSearchParams],
|
||||
[folderId, setSearchParams, onlyOwnedFlows, status],
|
||||
);
|
||||
|
||||
const getPathWithSearchParams = (page, flowName) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
const getPathWithSearchParams = (page) => {
|
||||
const searchParamsObject = objectifyUrlSearchParams(searchParams);
|
||||
|
||||
if (folderId) {
|
||||
searchParams.set('folderId', folderId);
|
||||
}
|
||||
const newSearchParams = new URLSearchParams({
|
||||
...searchParamsObject,
|
||||
page,
|
||||
});
|
||||
|
||||
if (flowName) {
|
||||
searchParams.set('flowName', flowName);
|
||||
}
|
||||
|
||||
if (page > 1) {
|
||||
searchParams.set('page', page);
|
||||
}
|
||||
|
||||
return { search: searchParams.toString() };
|
||||
return { search: newSearchParams.toString() };
|
||||
};
|
||||
|
||||
const onDuplicateFlow = () => {
|
||||
if (pageInfo?.currentPage > 1) {
|
||||
navigate(getPathWithSearchParams(1, flowName));
|
||||
navigate(getPathWithSearchParams(1));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +103,7 @@ export default function Flows() {
|
||||
React.useEffect(
|
||||
function redirectToLastPage() {
|
||||
if (navigateToLastPage) {
|
||||
navigate(getPathWithSearchParams(pageInfo.totalPages, flowName));
|
||||
navigate(getPathWithSearchParams(pageInfo.totalPages));
|
||||
}
|
||||
},
|
||||
[navigateToLastPage],
|
||||
@@ -145,6 +152,8 @@ export default function Flows() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={9}>
|
||||
<FlowFilters />
|
||||
|
||||
{(isLoading || navigateToLastPage) && (
|
||||
<CircularProgress
|
||||
sx={{ display: 'block', margin: '20px auto' }}
|
||||
@@ -180,7 +189,7 @@ export default function Flows() {
|
||||
renderItem={(item) => (
|
||||
<PaginationItem
|
||||
component={Link}
|
||||
to={getPathWithSearchParams(item.page, flowName)}
|
||||
to={getPathWithSearchParams(item.page)}
|
||||
{...item}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user