Skip to content

Commit

Permalink
Add multiple tags (#3032)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
andreas-unleash committed Feb 3, 2023
1 parent a7cb20c commit e589e56
Show file tree
Hide file tree
Showing 13 changed files with 412 additions and 135 deletions.
20 changes: 10 additions & 10 deletions .github/workflows/build_frontend_prs.yml
Expand Up @@ -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
37 changes: 0 additions & 37 deletions frontend/src/component/common/TagSelect/TagSelect.tsx

This file was deleted.

@@ -1,134 +1,185 @@
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) };
const handleTagTypeChange = (
event: React.SyntheticEvent,
value: AutocompleteValue<ITagType, false, any, any>
) => {
if (value != null && typeof value !== 'string') {
event.preventDefault();
setTagType(value);
}
};

if (!isTagUnique(updatedTag)) {
setErrors({
tagError: 'Tag already exists for this feature toggle.',
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);
}

setTag(updatedTag);
};

const hasSelectedValues = selectedTagOptions.length !== 0;

const formId = 'add-tag-form';

return (
<>
<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>
Expand Down
@@ -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;

0 comments on commit e589e56

Please sign in to comment.