1
0
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:
andreas-unleash 2023-03-15 11:35:24 +02:00 committed by GitHub
parent b194bee4ff
commit efd9e523ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 201 additions and 76 deletions

View File

@ -30,8 +30,5 @@ module.exports = {
process.env.UNLEASH_OPENAPI_URL ||
'http://localhost:4242/docs/openapi.json',
},
hooks: {
afterAllFilesWrite: 'yarn fmt',
},
},
};

View 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!"

View File

@ -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>

View File

@ -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}

View File

@ -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,