mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: Generate new OAS types, tags component upgrade (#3269)
<!-- Thanks for creating a PR! To make it easier for reviewers and everyone else to understand what your changes relate to, please add some relevant content to the headings below. Feel free to ignore or delete sections that you don't think are relevant. Thank you! ❤️ --> Create/Add/Remove at the same time per tag type ## About the changes <!-- Describe the changes introduced. What are they and why are they being introduced? Feel free to also add screenshots or steps to view the changes if they're visual. --> <!-- Does it close an issue? Multiple? --> Closes # [1-769](https://linear.app/unleash/issue/1-769/refactor-existing-tag-component-to-also-allow-removing-tags) <!-- (For internal contributors): Does it relate to an issue on public roadmap? --> <!-- Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: # --> ### Important files <!-- PRs can contain a lot of changes, but not all changes are equally important. Where should a reviewer start looking to get an overview of the changes? Are any files particularly important? --> ## Discussion points <!-- Anything about the PR you'd like to discuss before it gets merged? Got any questions or doubts? --> --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai> Co-authored-by: sjaanus <sellinjaanus@gmail.com>
This commit is contained in:
parent
b194bee4ff
commit
efd9e523ed
@ -30,8 +30,5 @@ module.exports = {
|
||||
process.env.UNLEASH_OPENAPI_URL ||
|
||||
'http://localhost:4242/docs/openapi.json',
|
||||
},
|
||||
hooks: {
|
||||
afterAllFilesWrite: 'yarn fmt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
15
frontend/scripts/clean_orval_generated.sh
Executable file
15
frontend/scripts/clean_orval_generated.sh
Executable file
@ -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!"
|
@ -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<ITagType>({
|
||||
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<TagOption[]>(
|
||||
[]
|
||||
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) => {
|
||||
<Dialogue
|
||||
open={open}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonText={`Add tag (${selectedTagOptions.length})`}
|
||||
title="Add tags to feature toggle"
|
||||
primaryButtonText={`Save tags`}
|
||||
title="Update tags to feature toggle"
|
||||
onClick={onSubmit}
|
||||
disabledPrimaryButton={loading || !hasSelectedValues}
|
||||
disabledPrimaryButton={loading || differenceCount === 0}
|
||||
onClose={onCancel}
|
||||
formId={formId}
|
||||
>
|
||||
@ -187,8 +265,9 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
||||
/>
|
||||
<TagsInput
|
||||
options={tagTypeOptions}
|
||||
tagType={tagType.name}
|
||||
featureTags={tags}
|
||||
existingTags={tags}
|
||||
tagType={tagType}
|
||||
selectedOptions={selectedTagOptions}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</StyledDialogFormContent>
|
||||
|
@ -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<TagOption | string, true, any, any>['onChange'];
|
||||
}
|
||||
|
||||
@ -28,19 +31,14 @@ const filter = createFilterOptions<TagOption>();
|
||||
|
||||
export const TagsInput = ({
|
||||
options,
|
||||
featureTags,
|
||||
selectedOptions,
|
||||
tagType,
|
||||
existingTags,
|
||||
onChange,
|
||||
}: ITagsInputProps) => {
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
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 (
|
||||
<li {...props}>
|
||||
<ConditionallyRender
|
||||
@ -70,7 +65,7 @@ export const TagsInput = ({
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
sx={{ mr: theme => 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 <Chip {...getTagProps({ index })} label={option.title} />;
|
||||
});
|
||||
};
|
||||
|
||||
const filterOptions = (
|
||||
options: TagOption[],
|
||||
params: FilterOptionsState<TagOption>
|
||||
@ -101,11 +113,13 @@ export const TagsInput = ({
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
id="checkboxes-tags-demo"
|
||||
id="checkboxes-tag"
|
||||
sx={{ marginTop: theme => 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}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user