diff --git a/.github/workflows/build_frontend_prs.yml b/.github/workflows/build_frontend_prs.yml index acbd4987076..e64e57e1352 100644 --- a/.github/workflows/build_frontend_prs.yml +++ b/.github/workflows/build_frontend_prs.yml @@ -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 diff --git a/frontend/src/component/common/TagSelect/TagSelect.tsx b/frontend/src/component/common/TagSelect/TagSelect.tsx deleted file mode 100644 index 13260d3c22d..00000000000 --- a/frontend/src/component/common/TagSelect/TagSelect.tsx +++ /dev/null @@ -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 ( - <> - - - ); -}; - -export default TagSelect; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx index 42e11289fca..393c3a4fb2a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog.tsx @@ -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>; } -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({ + name: 'simple', + description: 'Simple tag to get you started', + icon: '', + }); + + const [selectedTagOptions, setSelectedTagOptions] = useState( + [] + ); + + 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 + ) => { + 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 ( @@ -98,37 +154,32 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => { <> - + theme.spacing(2.5) }} + > Tags allow you to group features together
- onUpdateTag('type', type)} + value={tagType} + onChange={handleTagTypeChange} /> -
- - onUpdateTag('value', e.target.value) - } - required +
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx new file mode 100644 index 00000000000..8f286a98a0c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagTypeSelect.tsx @@ -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['onChange']; + autoFocus?: boolean; +} + +const ListItem = styled('li')({ + flexDirection: 'column', +}); +const TagTypeSelect = ({ value, onChange }: ITagSelect) => { + const { tagTypes } = useTagTypes(); + const theme = useTheme(); + + return ( + theme.spacing(2), width: 500 }} + options={tagTypes} + disableClearable + value={value} + getOptionLabel={option => option.name} + renderOption={(props, option) => ( + + {option.name} + + {option.description} + + + )} + renderInput={params => ( + + )} + onChange={onChange} + ListboxProps={{ style: { maxHeight: 200, overflow: 'auto' } }} + /> + ); +}; + +export default TagTypeSelect; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx new file mode 100644 index 00000000000..30c2fb54d25 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/AddTagDialog/TagsInput.tsx @@ -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['onChange']; +} + +const filter = createFilterOptions(); + +export const TagsInput = ({ + options, + featureTags, + tagType, + 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) { + return option.inputValue; + } + // Regular option + return option.title; + }; + + const renderOption = ( + props: JSX.IntrinsicAttributes & + React.ClassAttributes & + React.LiHTMLAttributes, + option: TagOption, + { selected }: { selected: boolean } + ) => { + const exists = featureTags.some( + tag => tag.type === tagType && tag.value === option.title + ); + return ( +
  • + theme.spacing(0.5) }} />} + elseShow={ + theme.spacing(0.5) }} + checked={selected || exists} + /> + } + /> + {option.title} +
  • + ); + }; + + const filterOptions = ( + options: TagOption[], + params: FilterOptionsState + ) => { + 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 ( + 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 => ( + + )} + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx index eead57694a4..713da1e5398 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx @@ -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; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx index 246cc109452..cee5999d8d5 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewTags/FeatureOverviewTags.tsx @@ -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 = ({ 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(); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx index 8c0123ce167..2e940f773f6 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags.tsx @@ -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); diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts index 7723163dcbd..e9d743ba5ba 100644 --- a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts @@ -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, diff --git a/frontend/src/hooks/api/actions/useTagApi/useTagApi.ts b/frontend/src/hooks/api/actions/useTagApi/useTagApi.ts new file mode 100644 index 00000000000..c8659ee5666 --- /dev/null +++ b/frontend/src/hooks/api/actions/useTagApi/useTagApi.ts @@ -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; diff --git a/frontend/src/hooks/api/getters/useFeatureTags/useFeatureTags.ts b/frontend/src/hooks/api/getters/useFeatureTags/useFeatureTags.ts new file mode 100644 index 00000000000..382ee6dc1b4 --- /dev/null +++ b/frontend/src/hooks/api/getters/useFeatureTags/useFeatureTags.ts @@ -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; diff --git a/frontend/src/hooks/api/getters/useTags/useTags.ts b/frontend/src/hooks/api/getters/useTags/useTags.ts index bb91a5e12df..e265eaf1e2c 100644 --- a/frontend/src/hooks/api/getters/useTags/useTags.ts +++ b/frontend/src/hooks/api/getters/useTags/useTags.ts @@ -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, diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 950b439e6a0..2dcb13545f9 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -20,6 +20,6 @@ "@server/*": ["./../../src/lib/*"] } }, - "include": ["src"], + "include": ["./src"], "references": [{ "path": "./tsconfig.node.json" }] }