1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Add multiple tags (#3032)

Add Tag dialog redesign to allow batch add

Some Refactoring

Signed-off-by: andreas-unleash <andreas@getunleash.ai>

<!-- 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! ❤️ -->

## 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-611](https://linear.app/unleash/issue/1-611/create-a-auto-complete-component-for-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? -->


https://user-images.githubusercontent.com/104830839/216286897-4e392822-57c2-4e50-a5d8-e89d006b3fa5.mov

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-02-03 12:28:06 +02:00 committed by GitHub
parent a7cb20c42b
commit e589e56373
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 414 additions and 137 deletions

View File

@ -15,13 +15,13 @@ jobs:
matrix:
node-version: [14.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: yarn --frozen-lockfile
- run: yarn run test
- run: yarn run fmt:check
- run: yarn run lint:check
- run: yarn run ts:check # TODO: optimize
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: yarn --frozen-lockfile
- run: yarn run test
- run: yarn run fmt:check
- run: yarn run lint:check
- run: yarn run ts:check # TODO: optimize

View File

@ -1,37 +0,0 @@
import React from 'react';
import GeneralSelect, {
IGeneralSelectProps,
} from '../GeneralSelect/GeneralSelect';
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
interface ITagSelect {
name: string;
value: string;
onChange: IGeneralSelectProps['onChange'];
autoFocus?: boolean;
}
const TagSelect = ({ value, onChange, ...rest }: ITagSelect) => {
const { tagTypes } = useTagTypes();
const options = tagTypes.map(tagType => ({
key: tagType.name,
label: tagType.name,
title: tagType.name,
}));
return (
<>
<GeneralSelect
label="Tag type"
id="tag-select"
options={options}
value={value}
onChange={onChange}
{...rest}
/>
</>
);
};
export default TagSelect;

View File

@ -1,96 +1,152 @@
import { styled, Typography } from '@mui/material';
import React, { useState } from 'react';
import { AutocompleteValue, styled, Typography } from '@mui/material';
import React, { useMemo, useState } from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import Input from 'component/common/Input/Input';
import { trim } from 'component/common/util';
import TagSelect from 'component/common/TagSelect/TagSelect';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useTags from 'hooks/api/getters/useTags/useTags';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ITag } from 'interfaces/tags';
const StyledInput = styled(Input)(() => ({
width: '100%',
}));
import { ITagType } from 'interfaces/tags';
import { TagOption, TagsInput } from './TagsInput';
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';
interface IAddTagDialogProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
interface IDefaultTag {
type: string;
value: string;
[index: string]: string;
}
const StyledDialogFormContent = styled('section')(({ theme }) => ({
['& > *']: {
margin: '0.5rem 0',
margin: theme.spacing(1, 0),
},
}));
const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
const DEFAULT_TAG: IDefaultTag = { type: 'simple', value: '' };
const featureId = useRequiredPathParam('featureId');
const { createTag } = useTagApi();
const { addTagToFeature, loading } = useFeatureApi();
const { tags, refetch } = useTags(featureId);
const [errors, setErrors] = useState({ tagError: '' });
const { tags, refetch } = useFeatureTags(featureId);
const { setToastData } = useToast();
const [tag, setTag] = useState(DEFAULT_TAG);
const [tagType, setTagType] = useState<ITagType>({
name: 'simple',
description: 'Simple tag to get you started',
icon: '',
});
const [selectedTagOptions, setSelectedTagOptions] = useState<TagOption[]>(
[]
);
const { tags: allTags, refetch: refetchAllTags } = useTags(tagType.name);
const tagTypeOptions: TagOption[] = useMemo(() => {
return allTags.map(tag => {
return {
title: tag.value,
};
});
}, [allTags]);
const onCancel = () => {
setOpen(false);
setErrors({ tagError: '' });
setTag(DEFAULT_TAG);
setSelectedTagOptions([]);
};
const onSubmit = async (evt: React.SyntheticEvent) => {
evt.preventDefault();
if (!tag.type) {
tag.type = 'simple';
}
try {
await addTagToFeature(featureId, tag);
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 > 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`,
confetti: true,
});
setOpen(false);
setTag(DEFAULT_TAG);
refetch();
setToastData({
type: 'success',
title: 'Added tag to toggle',
text: 'We successfully added a tag to your toggle',
confetti: true,
});
} catch (error: unknown) {
const message = formatUnknownError(error);
setErrors({ tagError: message });
setSelectedTagOptions([]);
}
};
const isValueNotEmpty = (name: string) => name.length;
const isTagUnique = (tag: ITag) =>
!tags.some(
({ type, value }) => type === tag.type && value === tag.value
);
const isValid = isValueNotEmpty(tag.value) && isTagUnique(tag);
const onUpdateTag = (key: string, value: string) => {
setErrors({ tagError: '' });
const updatedTag = { ...tag, [key]: trim(value) };
if (!isTagUnique(updatedTag)) {
setErrors({
tagError: 'Tag already exists for this feature toggle.',
});
const handleTagTypeChange = (
event: React.SyntheticEvent,
value: AutocompleteValue<ITagType, false, any, any>
) => {
if (value != null && typeof value !== 'string') {
event.preventDefault();
setTagType(value);
}
setTag(updatedTag);
};
const handleInputChange = (
event: React.SyntheticEvent,
newValue: AutocompleteValue<
TagOption | string,
true,
undefined,
undefined
>,
reason: AutocompleteChangeReason
) => {
if (reason === 'selectOption') {
const clone = cloneDeep(newValue) as TagOption[];
newValue.forEach((value, index) => {
if (
typeof value !== 'string' &&
value.inputValue &&
value.inputValue !== ''
) {
const payload = {
value: value.inputValue,
type: tagType.name,
};
createTag(payload).then(() => {
refetchAllTags();
});
value.title = value.inputValue;
value.inputValue = '';
clone[index] = value;
}
});
setSelectedTagOptions(clone);
}
};
const hasSelectedValues = selectedTagOptions.length !== 0;
const formId = 'add-tag-form';
return (
@ -98,37 +154,32 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
<Dialogue
open={open}
secondaryButtonText="Cancel"
primaryButtonText="Add tag"
primaryButtonText={`Add tag (${selectedTagOptions.length})`}
title="Add tags to feature toggle"
onClick={onSubmit}
disabledPrimaryButton={loading || !isValid}
disabledPrimaryButton={loading || !hasSelectedValues}
onClose={onCancel}
formId={formId}
>
<>
<Typography paragraph>
<Typography
paragraph
sx={{ marginBottom: theme => theme.spacing(2.5) }}
>
Tags allow you to group features together
</Typography>
<form id={formId} onSubmit={onSubmit}>
<StyledDialogFormContent>
<TagSelect
<TagTypeSelect
autoFocus
name="type"
value={tag.type}
onChange={type => onUpdateTag('type', type)}
value={tagType}
onChange={handleTagTypeChange}
/>
<br />
<StyledInput
label="Value"
name="value"
placeholder="Your tag"
value={tag.value}
error={Boolean(errors.tagError)}
errorText={errors.tagError}
onChange={e =>
onUpdateTag('value', e.target.value)
}
required
<TagsInput
options={tagTypeOptions}
tagType={tagType.name}
featureTags={tags}
onChange={handleInputChange}
/>
</StyledDialogFormContent>
</form>

View File

@ -0,0 +1,58 @@
import React from 'react';
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
import {
Autocomplete,
AutocompleteProps,
styled,
TextField,
Typography,
useTheme,
} from '@mui/material';
import { ITagType } from 'interfaces/tags';
interface ITagSelect {
value: ITagType;
onChange: AutocompleteProps<ITagType, false, any, any>['onChange'];
autoFocus?: boolean;
}
const ListItem = styled('li')({
flexDirection: 'column',
});
const TagTypeSelect = ({ value, onChange }: ITagSelect) => {
const { tagTypes } = useTagTypes();
const theme = useTheme();
return (
<Autocomplete
disablePortal
id="tag-type-select"
sx={{ marginTop: theme => theme.spacing(2), width: 500 }}
options={tagTypes}
disableClearable
value={value}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
<ListItem
{...props}
style={{
alignItems: 'flex-start',
gap: theme.spacing(0.5),
}}
>
<Typography variant="body1">{option.name}</Typography>
<Typography variant="caption">
{option.description}
</Typography>
</ListItem>
)}
renderInput={params => (
<TextField {...params} label="Tag type" value={value} />
)}
onChange={onChange}
ListboxProps={{ style: { maxHeight: 200, overflow: 'auto' } }}
/>
);
};
export default TagTypeSelect;

View File

@ -0,0 +1,131 @@
import {
Autocomplete,
AutocompleteProps,
Checkbox,
createFilterOptions,
FilterOptionsState,
TextField,
} from '@mui/material';
import React from 'react';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { ITag } from 'interfaces/tags';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Add } from '@mui/icons-material';
export type TagOption = {
title: string;
inputValue?: string;
};
interface ITagsInputProps {
options: TagOption[];
featureTags: ITag[];
tagType: string;
onChange: AutocompleteProps<TagOption | string, true, any, any>['onChange'];
}
const filter = createFilterOptions<TagOption>();
export const TagsInput = ({
options,
featureTags,
tagType,
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) {
return option.inputValue;
}
// Regular option
return option.title;
};
const renderOption = (
props: JSX.IntrinsicAttributes &
React.ClassAttributes<HTMLLIElement> &
React.LiHTMLAttributes<HTMLLIElement>,
option: TagOption,
{ selected }: { selected: boolean }
) => {
const exists = featureTags.some(
tag => tag.type === tagType && tag.value === option.title
);
return (
<li {...props}>
<ConditionallyRender
condition={Boolean(option.inputValue)}
show={<Add sx={{ mr: theme => theme.spacing(0.5) }} />}
elseShow={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
sx={{ mr: theme => theme.spacing(0.5) }}
checked={selected || exists}
/>
}
/>
{option.title}
</li>
);
};
const filterOptions = (
options: TagOption[],
params: FilterOptionsState<TagOption>
) => {
const filtered = filter(options, params);
const { inputValue } = params;
// Suggest the creation of a new value
const isExisting = options.some(option => inputValue === option.title);
if (inputValue !== '' && !isExisting) {
filtered.push({
inputValue,
title: `Create new value "${inputValue}"`,
});
}
return filtered;
};
return (
<Autocomplete
multiple
id="checkboxes-tags-demo"
sx={{ marginTop: theme => theme.spacing(2), width: 500 }}
disableCloseOnSelect
placeholder="Select Values"
options={options}
isOptionEqualToValue={(option, value) => {
if (value.inputValue && value.inputValue !== '') {
return option.title === value.inputValue;
} else {
return option.title === value.title;
}
}}
getOptionDisabled={getOptionDisabled}
getOptionLabel={getOptionLabel}
renderOption={renderOption}
filterOptions={filterOptions}
ListboxProps={{ style: { maxHeight: 200, overflow: 'auto' } }}
onChange={onChange}
renderInput={params => (
<TextField
{...params}
label="Select values"
placeholder="Select values"
/>
)}
/>
);
};

View File

@ -6,7 +6,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { Edit } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import useTags from 'hooks/api/getters/useTags/useTags';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import FeatureOverviewTags from './FeatureOverviewTags/FeatureOverviewTags';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@ -69,7 +69,7 @@ const FeatureOverviewMetaData = () => {
const { uiConfig } = useUiConfig();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { tags } = useTags(featureId);
const { tags } = useFeatureTags(featureId);
const { feature } = useFeature(projectId, featureId);
const { project, description, type } = feature;

View File

@ -1,7 +1,7 @@
import React, { useContext, useState } from 'react';
import { Chip, styled } from '@mui/material';
import { Close, Label } from '@mui/icons-material';
import useTags from 'hooks/api/getters/useTags/useTags';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import slackIcon from 'assets/icons/slack.svg';
import jiraIcon from 'assets/icons/jira.svg';
import webhookIcon from 'assets/icons/webhooks.svg';
@ -58,7 +58,7 @@ const FeatureOverviewTags: React.FC<IFeatureOverviewTagsProps> = ({
type: '',
});
const featureId = useRequiredPathParam('featureId');
const { tags, refetch } = useTags(featureId);
const { tags, refetch } = useFeatureTags(featureId);
const { tagTypes } = useTagTypes();
const { deleteTagFromFeature } = useFeatureApi();
const { setToastData, setToastApiError } = useToast();

View File

@ -1,7 +1,7 @@
import { IFeatureToggle } from 'interfaces/featureToggle';
import { useContext, useState } from 'react';
import { Button, Chip, Divider, styled } from '@mui/material';
import useTags from 'hooks/api/getters/useTags/useTags';
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 { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
@ -51,7 +51,7 @@ export const FeatureOverviewSidePanelTags = ({
feature,
header,
}: IFeatureOverviewSidePanelTagsProps) => {
const { tags, refetch } = useTags(feature.name);
const { tags, refetch } = useFeatureTags(feature.name);
const { deleteTagFromFeature } = useFeatureApi();
const [openTagDialog, setOpenTagDialog] = useState(false);

View File

@ -54,7 +54,7 @@ export const StyledWidgetTitle = styled('p')(({ theme }) => ({
export const StyledParagraphGridRow = styled('div')(({ theme }) => ({
display: 'grid',
gridGap: theme.spacing(1.5),
gridTemplateColumns: `${theme.spacing(1.25)} auto auto`, //20px auto auto
gridTemplateColumns: `${theme.spacing(2.25)} auto auto`, //20px auto auto
margin: theme.spacing(1, 0, 1, 0),
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,

View File

@ -0,0 +1,30 @@
import useAPI from '../useApi/useApi';
import { TagSchema } from 'openapi/models/tagSchema';
const useTagApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createTag = async (payload: TagSchema) => {
const path = `api/admin/tags`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
return await makeRequest(req.caller, req.id);
} catch (e) {
throw e;
}
};
return {
createTag,
errors,
loading,
};
};
export default useTagApi;

View File

@ -0,0 +1,44 @@
import { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath';
import { ITag } from 'interfaces/tags';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
const useFeatureTags = (featureId: string, options: SWRConfiguration = {}) => {
const fetcher = async () => {
const path = formatApiPath(`api/admin/features/${featureId}/tags`);
const res = await fetch(path, {
method: 'GET',
}).then(handleErrorResponses('Tags'));
return res.json();
};
const KEY = `api/admin/features/${featureId}/tags`;
const { data, error } = useConditionalSWR<{ tags: ITag[] }>(
Boolean(featureId),
{ tags: [] },
KEY,
fetcher,
options
);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
tags: (data?.tags as ITag[]) || [],
error,
loading,
refetch,
};
};
export default useFeatureTags;

View File

@ -5,19 +5,19 @@ import { ITag } from 'interfaces/tags';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
const useTags = (featureId: string, options: SWRConfiguration = {}) => {
const useTags = (type: string, options: SWRConfiguration = {}) => {
const fetcher = async () => {
const path = formatApiPath(`api/admin/features/${featureId}/tags`);
const path = formatApiPath(`api/admin/tags/${type}`);
const res = await fetch(path, {
method: 'GET',
}).then(handleErrorResponses('Tags'));
return res.json();
};
const KEY = `api/admin/features/${featureId}/tags`;
const KEY = `api/admin/tags/${type}`;
const { data, error } = useConditionalSWR<{ tags: ITag[] }>(
Boolean(featureId),
Boolean(type),
{ tags: [] },
KEY,
fetcher,

View File

@ -20,6 +20,6 @@
"@server/*": ["./../../src/lib/*"]
}
},
"include": ["src"],
"include": ["./src"],
"references": [{ "path": "./tsconfig.node.json" }]
}