diff --git a/frontend/orval.config.js b/frontend/orval.config.js index e6cb6598da..c33950dc68 100644 --- a/frontend/orval.config.js +++ b/frontend/orval.config.js @@ -30,8 +30,5 @@ module.exports = { process.env.UNLEASH_OPENAPI_URL || 'http://localhost:4242/docs/openapi.json', }, - hooks: { - afterAllFilesWrite: 'yarn fmt', - }, }, }; diff --git a/frontend/scripts/clean_orval_generated.sh b/frontend/scripts/clean_orval_generated.sh new file mode 100755 index 0000000000..28d698b591 --- /dev/null +++ b/frontend/scripts/clean_orval_generated.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +echo "Deleting generated apis..." +rm -rf src/openapi/apis + +# Remove all but last line from index.ts +echo "Cleaning index.ts..." +tail -1 src/openapi/index.ts > index_tmp ; +cat index_tmp > src/openapi/index.ts ; +rm index_tmp + +echo "Formatting..." +yarn fmt + +echo "Done!" diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx index 45cd4ec3fb..e9d83ce443 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx @@ -1,12 +1,12 @@ import { AutocompleteValue, styled, Typography } from '@mui/material'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { ITagType } from 'interfaces/tags'; +import { ITag, ITagType } from 'interfaces/tags'; import { TagOption, TagsInput } from './TagsInput'; import TagTypeSelect from './TagTypeSelect'; import useTagApi from 'hooks/api/actions/useTagApi/useTagApi'; @@ -26,11 +26,28 @@ const StyledDialogFormContent = styled('section')(({ theme }) => ({ }, })); +const tagsToOptions = (tags: ITag[]): TagOption[] => { + return tags.map(tag => { + return { + title: tag.value, + }; + }); +}; + +const optionsToTags = (options: TagOption[], type: string): ITag[] => { + return options.map(option => { + return { + value: option.title, + type: type, + }; + }); +}; + const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { const featureId = useRequiredPathParam('featureId'); const { createTag } = useTagApi(); - const { addTagToFeature, loading } = useFeatureApi(); - const { tags, refetch } = useFeatureTags(featureId); + const { updateFeatureTags, loading: featureLoading } = useFeatureApi(); + const { tags, refetch, loading: tagsLoading } = useFeatureTags(featureId); const { setToastData } = useToast(); const [tagType, setTagType] = useState({ name: 'simple', @@ -38,74 +55,129 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { icon: '', }); + const loading = featureLoading || tagsLoading; + + const [differenceCount, setDifferenceCount] = useState(0); + const { trackEvent } = usePlausibleTracker(); const [selectedTagOptions, setSelectedTagOptions] = useState( - [] + tagsToOptions(tags.filter(tag => tag.type === tagType.name)) ); const { tags: allTags, refetch: refetchAllTags } = useTags(tagType.name); const tagTypeOptions: TagOption[] = useMemo(() => { - return allTags.map(tag => { - return { - title: tag.value, - }; - }); + return tagsToOptions(allTags); }, [allTags]); + useEffect(() => { + if (tags && tagType) { + setSelectedTagOptions( + tagsToOptions(tags.filter(tag => tag.type === tagType.name)) + ); + } + }, [JSON.stringify(tags), tagType]); + const onCancel = () => { setOpen(false); setSelectedTagOptions([]); }; + function difference(array1: ITag[], array2: ITag[]) { + const added = array1 + .filter(tag => tag.type === tagType.name) + .filter( + element => + !array2.find( + e2 => + element.value === e2.value && + element.type === e2.type + ) + ); + const removed = array2 + .filter(tag => tag.type === tagType.name) + .filter( + element => + !array1.find( + e2 => + element.value === e2.value && + element.type === e2.type + ) + ); + setDifferenceCount(added.length + removed.length); + return { added, removed }; + } + + const realOptions = (allOptions: TagOption[]) => { + return allOptions.filter( + tagOption => !tagOption.title.startsWith('Create') + ); + }; + + const updateTags = async (added: ITag[], removed: ITag[]) => { + try { + await updateFeatureTags(featureId, { + addedTags: added, + removedTags: removed, + }); + await refetch(); + } catch (error: unknown) { + const message = formatUnknownError(error); + setToastData({ + type: 'error', + title: `Failed to add tag`, + text: message, + confetti: false, + }); + } + }; + + const getToastText = (addedCount: number, removedCount: number) => { + let result = 'We successfully'; + if (addedCount > 0) + result = result.concat( + ` added ${addedCount} new tag${addedCount > 1 ? 's' : ''}` + ); + + if (addedCount > 0 && removedCount > 0) { + result = result.concat(' and '); + } + + if (removedCount > 0) { + result = result.concat( + ` removed ${removedCount} tag${removedCount > 1 ? 's' : ''}` + ); + } + return result; + }; + const onSubmit = async (evt: React.SyntheticEvent) => { evt.preventDefault(); - let added = 0; - if (selectedTagOptions.length !== 0) { - for (const tagOption of selectedTagOptions) { - if ( - !tags.includes({ - type: tagType.name, - value: tagOption.title, - }) - ) { - try { - if (!tagOption.title.startsWith('Create')) { - await addTagToFeature(featureId, { - type: tagType.name, - value: tagOption.title, - }); - added++; - await refetch(); - } - } catch (error: unknown) { - const message = formatUnknownError(error); - setToastData({ - type: 'error', - title: `Failed to add tag`, - text: message, - confetti: false, - }); - } - } - } - added > 1 && + const selectedTags: ITag[] = optionsToTags( + realOptions(selectedTagOptions), + tagType.name + ); + const { added, removed } = difference(selectedTags, tags); + if (differenceCount > 0) { + await updateTags(added, removed); + differenceCount > 1 && trackEvent('suggest_tags', { props: { eventType: 'multiple_tags_added' }, }); - added > 0 && + differenceCount > 0 && setToastData({ type: 'success', - title: `Added tag${added > 1 ? 's' : ''} to toggle`, - text: `We successfully added ${added} new tag${ - added > 1 ? 's' : '' - } to your toggle`, + title: `Updated tag${ + added.length > 1 ? 's' : '' + } to toggle`, + text: getToastText(added.length, removed.length), confetti: true, }); - setOpen(false); - setSelectedTagOptions([]); } + setDifferenceCount(0); + setSelectedTagOptions([]); + setOpen(false); }; const handleTagTypeChange = ( @@ -115,6 +187,8 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { if (value != null && typeof value !== 'string') { event.preventDefault(); setTagType(value); + setSelectedTagOptions([]); + setDifferenceCount(0); } }; @@ -128,8 +202,8 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { >, reason: AutocompleteChangeReason ) => { + const clone = cloneDeep(newValue) as TagOption[]; if (reason === 'selectOption') { - const clone = cloneDeep(newValue) as TagOption[]; newValue.forEach((value, index) => { if ( typeof value !== 'string' && @@ -151,11 +225,15 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { clone[index] = value; } }); - setSelectedTagOptions(clone); } - }; + const selectedTags: ITag[] = optionsToTags( + realOptions(clone), + tagType.name + ); - const hasSelectedValues = selectedTagOptions.length !== 0; + difference(selectedTags, tags); + setSelectedTagOptions(clone); + }; const formId = 'add-tag-form'; @@ -164,10 +242,10 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { @@ -187,8 +265,9 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { /> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx index 30c2fb54d2..4371255b8a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx @@ -2,6 +2,7 @@ import { Autocomplete, AutocompleteProps, Checkbox, + Chip, createFilterOptions, FilterOptionsState, TextField, @@ -9,9 +10,10 @@ import { import React from 'react'; import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; -import { ITag } from 'interfaces/tags'; +import { ITag, ITagType } from 'interfaces/tags'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { Add } from '@mui/icons-material'; +import { AutocompleteRenderGetTagProps } from '@mui/material/Autocomplete/Autocomplete'; export type TagOption = { title: string; @@ -19,8 +21,9 @@ export type TagOption = { }; interface ITagsInputProps { options: TagOption[]; - featureTags: ITag[]; - tagType: string; + existingTags: ITag[]; + tagType: ITagType; + selectedOptions: TagOption[]; onChange: AutocompleteProps['onChange']; } @@ -28,19 +31,14 @@ const filter = createFilterOptions(); export const TagsInput = ({ options, - featureTags, + selectedOptions, tagType, + existingTags, onChange, }: ITagsInputProps) => { const icon = ; const checkedIcon = ; - const getOptionDisabled = (option: TagOption) => { - return featureTags.some( - tag => tag.type === tagType && tag.value === option.title - ); - }; - const getOptionLabel = (option: TagOption) => { // Add "xxx" option created dynamically if (option.inputValue) { @@ -57,9 +55,6 @@ export const TagsInput = ({ option: TagOption, { selected }: { selected: boolean } ) => { - const exists = featureTags.some( - tag => tag.type === tagType && tag.value === option.title - ); return (
  • theme.spacing(0.5) }} - checked={selected || exists} + checked={selected} /> } /> @@ -79,6 +74,23 @@ export const TagsInput = ({ ); }; + const renderTags = ( + tagValue: TagOption[], + getTagProps: AutocompleteRenderGetTagProps + ) => { + return tagValue.map((option, index) => { + const exists = existingTags.some( + existingTag => + existingTag.value === option.title && + existingTag.type === tagType.name + ); + if (exists) { + return null; + } + return ; + }); + }; + const filterOptions = ( options: TagOption[], params: FilterOptionsState @@ -101,11 +113,13 @@ export const TagsInput = ({ return ( theme.spacing(2), width: 500 }} disableCloseOnSelect placeholder="Select Values" options={options} + value={selectedOptions} + renderTags={renderTags} isOptionEqualToValue={(option, value) => { if (value.inputValue && value.inputValue !== '') { return option.title === value.inputValue; @@ -113,7 +127,6 @@ export const TagsInput = ({ return option.title === value.title; } }} - getOptionDisabled={getOptionDisabled} getOptionLabel={getOptionLabel} renderOption={renderOption} filterOptions={filterOptions} diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index 6a39119c8a..e6918827ba 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { ITag } from 'interfaces/tags'; import { Operation } from 'fast-json-patch'; import { IConstraint } from 'interfaces/strategy'; -import { CreateFeatureSchema } from 'openapi'; +import { CreateFeatureSchema, UpdateTagsSchema } from 'openapi'; import useAPI from '../useApi/useApi'; import { IFeatureVariant } from 'interfaces/featureToggle'; @@ -147,6 +147,26 @@ const useFeatureApi = () => { } }; + const updateFeatureTags = async ( + featureId: string, + update: UpdateTagsSchema + ) => { + // TODO: Change this path to the new API when moved. + const path = `api/admin/features/${featureId}/tags`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify({ ...update }), + }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + const archiveFeatureToggle = async ( projectId: string, featureId: string @@ -274,6 +294,7 @@ const useFeatureApi = () => { toggleFeatureEnvironmentOff, addTagToFeature, deleteTagFromFeature, + updateFeatureTags, archiveFeatureToggle, patchFeatureToggle, patchFeatureVariants,