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 && (
+ }
+ onClick={() => setMobileFiltersOpen((prev) => !prev)}
+ fullWidth
+ sx={{ mb: 2 }}
+ >
+ {mobileFiltersOpen
+ ? formatMessage('flowFilters.hideFilters')
+ : formatMessage('flowFilters.showFilters')}
+
+ )}
+
+ {/* 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) && (
(
)}