diff --git a/packages/backend/src/helpers/user-ability.js b/packages/backend/src/helpers/user-ability.js index 74f4620b..b28ea508 100644 --- a/packages/backend/src/helpers/user-ability.js +++ b/packages/backend/src/helpers/user-ability.js @@ -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; diff --git a/packages/web/src/components/FlowFilters/index.jsx b/packages/web/src/components/FlowFilters/index.jsx new file mode 100644 index 00000000..16acfefe --- /dev/null +++ b/packages/web/src/components/FlowFilters/index.jsx @@ -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 ( + + {/* Mobile: Toggle Button for Filters */} + {isMobile && ( + + )} + + {/* Filters Box (Always Visible on Large Screens) */} + + + {/* User Flows Filter */} + {currentUserReadFlowsConditions && + !currentUserReadFlowsConditions?.isCreator && ( + + + {formatMessage('flowFilters.flowsFilterLabel')} + + + + + )} + + {/* Status Filter */} + + + {formatMessage('flowFilters.statusFilterLabel')} + + + + + + + + ); +} diff --git a/packages/web/src/components/Folders/index.jsx b/packages/web/src/components/Folders/index.jsx index 221accbe..a8f341f8 100644 --- a/packages/web/src/components/Folders/index.jsx +++ b/packages/web/src/components/Folders/index.jsx @@ -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 ( <> - + { + return { ...acc, [key]: value }; + }, {}); +} diff --git a/packages/web/src/helpers/userAbility.js b/packages/web/src/helpers/userAbility.js index 4114ad25..97b6c2d5 100644 --- a/packages/web/src/helpers/userAbility.js +++ b/packages/web/src/helpers/userAbility.js @@ -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); } diff --git a/packages/web/src/hooks/useCurrentUserRuleConditions.js b/packages/web/src/hooks/useCurrentUserRuleConditions.js new file mode 100644 index 00000000..28ee8748 --- /dev/null +++ b/packages/web/src/hooks/useCurrentUserRuleConditions.js @@ -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; + }; +} diff --git a/packages/web/src/hooks/useFlowFilters.js b/packages/web/src/hooks/useFlowFilters.js new file mode 100644 index 00000000..20563841 --- /dev/null +++ b/packages/web/src/hooks/useFlowFilters.js @@ -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, + }; +} diff --git a/packages/web/src/hooks/useFlows.js b/packages/web/src/hooks/useFlows.js index 97d9a8a3..dfffcde1 100644 --- a/packages/web/src/hooks/useFlows.js +++ b/packages/web/src/hooks/useFlows.js @@ -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, }); diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index f3a4673c..be164512 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -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" } diff --git a/packages/web/src/pages/Flows/index.jsx b/packages/web/src/pages/Flows/index.jsx index 3a8cc925..3cf4bff3 100644 --- a/packages/web/src/pages/Flows/index.jsx +++ b/packages/web/src/pages/Flows/index.jsx @@ -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() { + + {(isLoading || navigateToLastPage) && ( ( )}