diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx index e735ed9fb4..b3c7b8562a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx @@ -3,7 +3,7 @@ import { useContext, useState } from 'react'; import { Button, Chip, Divider, styled } from '@mui/material'; import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags'; import { Add, Cancel } from '@mui/icons-material'; -import AddTagDialog from 'component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog'; +import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog'; import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import AccessContext from 'contexts/AccessContext'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; @@ -120,7 +120,7 @@ export const FeatureOverviewSidePanelTags = ({ } /> - + void; + onSubmit: (payload: Payload) => void; +} + +const StyledDialogFormContent = styled('section')(({ theme }) => ({ + ['& > *']: { + margin: theme.spacing(1, 0), + }, +})); + +const formId = 'manage-tags-form'; + +const mergeTags = (tags: ITag[], newTag: ITag) => [ + ...tags, + ...(tags.some(x => x.value === newTag.value && x.type === newTag.type) + ? [] + : [newTag]), +]; + +const filterTags = (tags: ITag[], tag: ITag) => + tags.filter(x => !(x.value === tag.value && x.type === tag.type)); + +const payloadReducer = ( + state: Payload, + action: + | { + type: 'add' | 'remove'; + payload: ITag; + } + | { + type: 'clear'; + payload: ITag[]; + } +) => { + switch (action.type) { + case 'add': + return { + ...state, + addedTags: mergeTags(state.addedTags, action.payload), + removedTags: filterTags(state.removedTags, action.payload), + }; + case 'remove': + return { + ...state, + addedTags: filterTags(state.addedTags, action.payload), + removedTags: mergeTags(state.removedTags, action.payload), + }; + case 'clear': + return { + addedTags: [], + removedTags: action.payload, + }; + default: + return state; + } +}; + +const emptyTagType = { + name: '', + description: '', + icon: '', +}; + +export const ManageBulkTagsDialog: VFC = ({ + open, + initialValues, + initialIndeterminateValues, + onCancel, + onSubmit, +}) => { + const { tagTypes, loading: tagTypesLoading } = useTagTypes(); + const [tagType, setTagType] = useState(emptyTagType); + const [selectedTags, setSelectedTags] = useState([]); + const [indeterminateTags, setIndeterminateTags] = useState([]); + const { tags, refetch: refetchTags } = useTags(tagType.name); + const { createTag } = useTagApi(); + const tagsOptions = tags.map(({ value }) => ({ title: value })); + const [payload, dispatch] = useReducer(payloadReducer, { + addedTags: [], + removedTags: [], + }); + + const resetTagType = ( + tagType: ITagType = tagTypes.length > 0 ? tagTypes[0] : emptyTagType + ) => { + setTagType(tagType); + const newIndeterminateValues = initialIndeterminateValues.filter( + ({ type }) => type === tagType.name + ); + setSelectedTags( + initialValues + .filter(({ type }) => type === tagType.name) + .filter( + ({ type, value }) => + !newIndeterminateValues.some( + tag => tag.value === value && tag.type === type + ) + ) + .map(({ value }) => ({ + title: value, + })) + ); + setIndeterminateTags( + newIndeterminateValues.map(({ value }) => ({ + title: value, + })) + ); + dispatch({ + type: 'clear', + payload: [], + }); + }; + + useEffect(() => { + if (tagTypes.length > 0) { + resetTagType(); + } + }, [tagTypesLoading]); + + const handleTagTypeChange: AutocompleteProps< + ITagType, + false, + any, + any + >['onChange'] = (event, value) => { + if (value != null && typeof value !== 'string') { + event.preventDefault(); + resetTagType(value); + } + }; + + const createNewTagOnTheFly = (value: string, type: string) => + createTag({ + value, + type, + }).then(async () => { + await refetchTags(); + setSelectedTags(prev => [...prev, { title: value }]); + dispatch({ + type: 'add', + payload: { value, type }, + }); + }); + + const handleInputChange: AutocompleteProps< + TagOption, + true, + false, + false + >['onChange'] = (_event, newValue, reason, selected) => { + if (reason === 'selectOption') { + newValue.forEach(value => { + if ( + typeof value !== 'string' && + typeof value.inputValue === 'string' && + value.inputValue && + value.title.startsWith('Create new value') + ) { + return createNewTagOnTheFly(value.inputValue, tagType.name); + } + + setSelectedTags(newValue as TagOption[]); + setIndeterminateTags((prev: TagOption[]) => + prev.filter(({ title }) => title !== value.title) + ); + if (selected?.option) { + dispatch({ + type: 'add', + payload: { + value: selected.option.title, + type: tagType.name, + }, + }); + } + }); + } else if (reason === 'clear') { + setSelectedTags([]); + dispatch({ + type: 'clear', + payload: initialValues, + }); + } else if (reason === 'removeOption') { + setSelectedTags(newValue as TagOption[]); + if (selected?.option) { + dispatch({ + type: 'remove', + payload: { + value: selected.option.title, + type: tagType.name, + }, + }); + } + } + }; + + const onClose = () => { + resetTagType(); + onCancel(); + }; + + return ( + onSubmit(payload)} + disabledPrimaryButton={ + payload.addedTags.length === 0 && + payload.removedTags.length === 0 + } + onClose={onClose} + formId={formId} + > + theme.spacing(2.5) }} + > + Tags allow you to group features together + +
onSubmit(payload)}> + + + + No{' '} + + tag types + {' '} + available. + + } + elseShow={ + + } + /> + +
+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx similarity index 82% rename from frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx rename to frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx index e9d83ce443..feb5cbc012 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx @@ -8,14 +8,15 @@ import { formatUnknownError } from 'utils/formatUnknownError'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { ITag, ITagType } from 'interfaces/tags'; import { TagOption, TagsInput } from './TagsInput'; -import TagTypeSelect from './TagTypeSelect'; +import { TagTypeSelect } from './TagTypeSelect'; import useTagApi from 'hooks/api/actions/useTagApi/useTagApi'; import { AutocompleteChangeReason } from '@mui/base/AutocompleteUnstyled/useAutocomplete'; import useTags from 'hooks/api/getters/useTags/useTags'; import cloneDeep from 'lodash.clonedeep'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes'; -interface IAddTagDialogProps { +interface IManageTagsProps { open: boolean; setOpen: React.Dispatch>; } @@ -43,7 +44,8 @@ const optionsToTags = (options: TagOption[], type: string): ITag[] => { }); }; -const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { +export const ManageTagsDialog = ({ open, setOpen }: IManageTagsProps) => { + const { tagTypes } = useTagTypes(); const featureId = useRequiredPathParam('featureId'); const { createTag } = useTagApi(); const { updateFeatureTags, loading: featureLoading } = useFeatureApi(); @@ -238,44 +240,40 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { const formId = 'add-tag-form'; return ( - <> - - <> - theme.spacing(2.5) }} - > - Tags allow you to group features together - -
- - - - -
- -
- + + <> + theme.spacing(2.5) }} + > + Tags allow you to group features together + +
+ + + + +
+ +
); }; - -export default AddTagDialog; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagTypeSelect.tsx similarity index 83% rename from frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx rename to frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagTypeSelect.tsx index 8f286a98a0..93c19011d4 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagTypeSelect.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes'; import { Autocomplete, AutocompleteProps, @@ -11,24 +9,31 @@ import { import { ITagType } from 'interfaces/tags'; interface ITagSelect { + options: ITagType[]; value: ITagType; + disabled?: boolean; onChange: AutocompleteProps['onChange']; - autoFocus?: boolean; } const ListItem = styled('li')({ flexDirection: 'column', }); -const TagTypeSelect = ({ value, onChange }: ITagSelect) => { - const { tagTypes } = useTagTypes(); + +export const TagTypeSelect = ({ + options, + value, + disabled = false, + onChange, +}: ITagSelect) => { const theme = useTheme(); return ( theme.spacing(2), width: 500 }} - options={tagTypes} + options={options} disableClearable value={value} getOptionLabel={option => option.name} @@ -54,5 +59,3 @@ const TagTypeSelect = ({ value, onChange }: ITagSelect) => { /> ); }; - -export default TagTypeSelect; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagsInput.tsx similarity index 81% rename from frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx rename to frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagsInput.tsx index 4371255b8a..9cc6cd1879 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/TagsInput.tsx @@ -10,6 +10,7 @@ import { import React from 'react'; import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import IndeterminateCheckBoxIcon from '@mui/icons-material/IndeterminateCheckBox'; import { ITag, ITagType } from 'interfaces/tags'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { Add } from '@mui/icons-material'; @@ -19,12 +20,15 @@ export type TagOption = { title: string; inputValue?: string; }; + interface ITagsInputProps { options: TagOption[]; existingTags: ITag[]; tagType: ITagType; selectedOptions: TagOption[]; - onChange: AutocompleteProps['onChange']; + indeterminateOptions?: TagOption[]; + disabled?: boolean; + onChange: AutocompleteProps['onChange']; } const filter = createFilterOptions(); @@ -32,12 +36,13 @@ const filter = createFilterOptions(); export const TagsInput = ({ options, selectedOptions, + indeterminateOptions, tagType, existingTags, + disabled = false, onChange, }: ITagsInputProps) => { const icon = ; - const checkedIcon = ; const getOptionLabel = (option: TagOption) => { // Add "xxx" option created dynamically @@ -55,6 +60,11 @@ export const TagsInput = ({ option: TagOption, { selected }: { selected: boolean } ) => { + const isIndeterminate = + indeterminateOptions?.some( + indeterminateOption => + indeterminateOption.title === option.title + ) ?? false; return (
  • } + indeterminateIcon={ + + } sx={{ mr: theme => theme.spacing(0.5) }} - checked={selected} + checked={selected && !isIndeterminate} + indeterminate={isIndeterminate} /> } /> @@ -77,19 +91,18 @@ export const TagsInput = ({ const renderTags = ( tagValue: TagOption[], getTagProps: AutocompleteRenderGetTagProps - ) => { - return tagValue.map((option, index) => { + ) => + tagValue.map((option, index) => { const exists = existingTags.some( existingTag => existingTag.value === option.title && existingTag.type === tagType.name ); - if (exists) { + if (exists && indeterminateOptions === undefined) { return null; } return ; }); - }; const filterOptions = ( options: TagOption[], @@ -139,6 +152,7 @@ export const TagsInput = ({ placeholder="Select values" /> )} + disabled={disabled} /> ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx index 121db836ab..fbd4665ed0 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx @@ -24,7 +24,7 @@ import { FeatureSettings } from './FeatureSettings/FeatureSettings'; import useLoading from 'hooks/useLoading'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; -import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog'; +import { ManageTagsDialog } from './FeatureOverview/ManageTagsDialog/ManageTagsDialog'; import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip'; import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; @@ -260,7 +260,7 @@ export const FeatureView = () => { featureId={featureId} projectId={projectId} /> - + ); }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 73247b6d6a..a5f9ffa192 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -65,7 +65,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; import { RowSelectCell } from './RowSelectCell/RowSelectCell'; import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar'; -import { ProjectFeaturesBatchActions } from './SelectionActionsBar/ProjectFeaturesBatchActions'; +import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions'; const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ whiteSpace: 'nowrap', diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ArchiveButton/ArchiveButton.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ArchiveButton.tsx similarity index 98% rename from frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ArchiveButton/ArchiveButton.tsx rename to frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ArchiveButton.tsx index 742c8c366f..e313e084be 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ArchiveButton/ArchiveButton.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ArchiveButton.tsx @@ -21,7 +21,6 @@ export const ArchiveButton: VFC = ({ const onConfirm = async () => { setIsDialogOpen(false); await refetch(); - // TODO: toast }; return ( diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ManageTags.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ManageTags.tsx new file mode 100644 index 0000000000..15c5312e8d --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ManageTags.tsx @@ -0,0 +1,111 @@ +import { useMemo, useState, VFC } from 'react'; +import { Label } from '@mui/icons-material'; +import { Button } from '@mui/material'; +import { ManageBulkTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageBulkTagsDialog'; +import type { FeatureSchema } from 'openapi'; +import { ITag } from 'interfaces/tags'; +import useTagApi from 'hooks/api/actions/useTagApi/useTagApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useProject from 'hooks/api/getters/useProject/useProject'; +import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; + +interface IManageTagsProps { + data: FeatureSchema[]; + projectId: string; +} + +export const ManageTags: VFC = ({ projectId, data }) => { + const { bulkUpdateTags } = useTagApi(); + const { refetch } = useProject(projectId); + const { setToastData, setToastApiError } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const [initialValues, indeterminateValues] = useMemo(() => { + const uniqueTags = data + .flatMap(({ tags }) => tags || []) + .reduce( + (acc, tag) => [ + ...acc, + ...(acc.some( + x => x.type === tag.type && x.value === tag.value + ) + ? [] + : [tag]), + ], + [] + ); + + const tagsNotPresentInEveryFeature = uniqueTags.filter( + tag => + !data.every(({ tags }) => + tags?.some( + x => x.type === tag.type && x.value === tag.value + ) + ) + ); + + return [uniqueTags, tagsNotPresentInEveryFeature]; + }, [data]); + + const onSubmit = async ({ + addedTags, + removedTags, + }: { + addedTags: ITag[]; + removedTags: ITag[]; + }) => { + const features = data.map(({ name }) => name); + const payload = { features, tags: { addedTags, removedTags } }; + try { + await bulkUpdateTags(payload); + refetch(); + const added = addedTags.length + ? `Added tags: ${addedTags + .map(({ type, value }) => `${type}:${value}`) + .join(', ')}.` + : ''; + const removed = removedTags.length + ? `Removed tags: ${removedTags + .map(({ type, value }) => `${type}:${value}`) + .join(', ')}.` + : ''; + + setToastData({ + title: 'Tags updated', + text: `${features.length} feature toggles updated. ${added} ${removed}`, + type: 'success', + autoHideDuration: 12000, + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + setIsOpen(false); + }; + + return ( + <> + + {({ hasAccess }) => ( + + )} + + setIsOpen(false)} + onSubmit={onSubmit} + initialValues={initialValues} + initialIndeterminateValues={indeterminateValues} + /> + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/MoreActions/MoreActions.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/MoreActions.tsx similarity index 100% rename from frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/MoreActions/MoreActions.tsx rename to frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/MoreActions.tsx diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ProjectFeaturesBatchActions.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ProjectFeaturesBatchActions.tsx similarity index 86% rename from frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ProjectFeaturesBatchActions.tsx rename to frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ProjectFeaturesBatchActions.tsx index cc88ab3b86..2390aabe3a 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/SelectionActionsBar/ProjectFeaturesBatchActions.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeaturesBatchActions/ProjectFeaturesBatchActions.tsx @@ -5,8 +5,9 @@ import type { FeatureSchema } from 'openapi'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { ArchiveButton } from './ArchiveButton/ArchiveButton'; -import { MoreActions } from './MoreActions/MoreActions'; +import { ArchiveButton } from './ArchiveButton'; +import { MoreActions } from './MoreActions'; +import { ManageTags } from './ManageTags'; interface IProjectFeaturesBatchActionsProps { selectedIds: string[]; @@ -43,14 +44,7 @@ export const ProjectFeaturesBatchActions: FC< > Export - + { const { makeRequest, createRequest, errors, loading } = useAPI({ @@ -20,8 +20,23 @@ const useTagApi = () => { } }; + const bulkUpdateTags = async (payload: TagsBulkAddSchema) => { + const path = `api/admin/tags/features`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify(payload), + }); + + try { + return await makeRequest(req.caller, req.id); + } catch (e) { + throw e; + } + }; + return { createTag, + bulkUpdateTags, errors, loading, }; diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx index f9bf8fcc5f..8510688d7a 100644 --- a/frontend/src/hooks/useToast.tsx +++ b/frontend/src/hooks/useToast.tsx @@ -30,7 +30,7 @@ const useToast = () => { if (toast.persist) { setToast({ ...toast, show: true }); } else { - setToast({ ...toast, show: true, autoHideDuration: 6000 }); + setToast({ autoHideDuration: 6000, ...toast, show: true }); } }, [setToast]