mirror of
https://github.com/Unleash/unleash.git
synced 2025-10-18 11:14:57 +02:00
parent
535b74f454
commit
2147206b49
@ -3,7 +3,7 @@ import { useContext, useState } from 'react';
|
|||||||
import { Button, Chip, Divider, styled } from '@mui/material';
|
import { Button, Chip, Divider, styled } from '@mui/material';
|
||||||
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
|
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
|
||||||
import { Add, Cancel } from '@mui/icons-material';
|
import { Add, Cancel } from '@mui/icons-material';
|
||||||
import AddTagDialog from 'component/feature/FeatureView/FeatureOverview/AddTagDialog/AddTagDialog';
|
import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog';
|
||||||
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
@ -120,7 +120,7 @@ export const FeatureOverviewSidePanelTags = ({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AddTagDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
<ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
||||||
<Dialogue
|
<Dialogue
|
||||||
open={showDelDialog}
|
open={showDelDialog}
|
||||||
primaryButtonText="Delete tag"
|
primaryButtonText="Delete tag"
|
||||||
|
@ -0,0 +1,278 @@
|
|||||||
|
import { useEffect, useReducer, useState, VFC } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { AutocompleteProps, Link, styled, Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { TagTypeSelect } from './TagTypeSelect';
|
||||||
|
import { TagOption, TagsInput } from './TagsInput';
|
||||||
|
import useTags from 'hooks/api/getters/useTags/useTags';
|
||||||
|
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
|
||||||
|
import { ITag, ITagType } from 'interfaces/tags';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
addedTags: ITag[];
|
||||||
|
removedTags: ITag[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IManageBulkTagsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
initialValues: ITag[];
|
||||||
|
initialIndeterminateValues: ITag[];
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (payload: Payload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledDialogFormContent = styled('section')(({ theme }) => ({
|
||||||
|
['& > *']: {
|
||||||
|
margin: theme.spacing(1, 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formId = 'manage-tags-form';
|
||||||
|
|
||||||
|
const mergeTags = (tags: ITag[], newTag: ITag) => [
|
||||||
|
...tags,
|
||||||
|
...(tags.some(x => x.value === newTag.value && x.type === newTag.type)
|
||||||
|
? []
|
||||||
|
: [newTag]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterTags = (tags: ITag[], tag: ITag) =>
|
||||||
|
tags.filter(x => !(x.value === tag.value && x.type === tag.type));
|
||||||
|
|
||||||
|
const payloadReducer = (
|
||||||
|
state: Payload,
|
||||||
|
action:
|
||||||
|
| {
|
||||||
|
type: 'add' | 'remove';
|
||||||
|
payload: ITag;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'clear';
|
||||||
|
payload: ITag[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'add':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
addedTags: mergeTags(state.addedTags, action.payload),
|
||||||
|
removedTags: filterTags(state.removedTags, action.payload),
|
||||||
|
};
|
||||||
|
case 'remove':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
addedTags: filterTags(state.addedTags, action.payload),
|
||||||
|
removedTags: mergeTags(state.removedTags, action.payload),
|
||||||
|
};
|
||||||
|
case 'clear':
|
||||||
|
return {
|
||||||
|
addedTags: [],
|
||||||
|
removedTags: action.payload,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTagType = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManageBulkTagsDialog: VFC<IManageBulkTagsDialogProps> = ({
|
||||||
|
open,
|
||||||
|
initialValues,
|
||||||
|
initialIndeterminateValues,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { tagTypes, loading: tagTypesLoading } = useTagTypes();
|
||||||
|
const [tagType, setTagType] = useState<typeof tagTypes[0]>(emptyTagType);
|
||||||
|
const [selectedTags, setSelectedTags] = useState<TagOption[]>([]);
|
||||||
|
const [indeterminateTags, setIndeterminateTags] = useState<TagOption[]>([]);
|
||||||
|
const { tags, refetch: refetchTags } = useTags(tagType.name);
|
||||||
|
const { createTag } = useTagApi();
|
||||||
|
const tagsOptions = tags.map(({ value }) => ({ title: value }));
|
||||||
|
const [payload, dispatch] = useReducer(payloadReducer, {
|
||||||
|
addedTags: [],
|
||||||
|
removedTags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetTagType = (
|
||||||
|
tagType: ITagType = tagTypes.length > 0 ? tagTypes[0] : emptyTagType
|
||||||
|
) => {
|
||||||
|
setTagType(tagType);
|
||||||
|
const newIndeterminateValues = initialIndeterminateValues.filter(
|
||||||
|
({ type }) => type === tagType.name
|
||||||
|
);
|
||||||
|
setSelectedTags(
|
||||||
|
initialValues
|
||||||
|
.filter(({ type }) => type === tagType.name)
|
||||||
|
.filter(
|
||||||
|
({ type, value }) =>
|
||||||
|
!newIndeterminateValues.some(
|
||||||
|
tag => tag.value === value && tag.type === type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(({ value }) => ({
|
||||||
|
title: value,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setIndeterminateTags(
|
||||||
|
newIndeterminateValues.map(({ value }) => ({
|
||||||
|
title: value,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
dispatch({
|
||||||
|
type: 'clear',
|
||||||
|
payload: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagTypes.length > 0) {
|
||||||
|
resetTagType();
|
||||||
|
}
|
||||||
|
}, [tagTypesLoading]);
|
||||||
|
|
||||||
|
const handleTagTypeChange: AutocompleteProps<
|
||||||
|
ITagType,
|
||||||
|
false,
|
||||||
|
any,
|
||||||
|
any
|
||||||
|
>['onChange'] = (event, value) => {
|
||||||
|
if (value != null && typeof value !== 'string') {
|
||||||
|
event.preventDefault();
|
||||||
|
resetTagType(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewTagOnTheFly = (value: string, type: string) =>
|
||||||
|
createTag({
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
}).then(async () => {
|
||||||
|
await refetchTags();
|
||||||
|
setSelectedTags(prev => [...prev, { title: value }]);
|
||||||
|
dispatch({
|
||||||
|
type: 'add',
|
||||||
|
payload: { value, type },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputChange: AutocompleteProps<
|
||||||
|
TagOption,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
>['onChange'] = (_event, newValue, reason, selected) => {
|
||||||
|
if (reason === 'selectOption') {
|
||||||
|
newValue.forEach(value => {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' &&
|
||||||
|
typeof value.inputValue === 'string' &&
|
||||||
|
value.inputValue &&
|
||||||
|
value.title.startsWith('Create new value')
|
||||||
|
) {
|
||||||
|
return createNewTagOnTheFly(value.inputValue, tagType.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTags(newValue as TagOption[]);
|
||||||
|
setIndeterminateTags((prev: TagOption[]) =>
|
||||||
|
prev.filter(({ title }) => title !== value.title)
|
||||||
|
);
|
||||||
|
if (selected?.option) {
|
||||||
|
dispatch({
|
||||||
|
type: 'add',
|
||||||
|
payload: {
|
||||||
|
value: selected.option.title,
|
||||||
|
type: tagType.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (reason === 'clear') {
|
||||||
|
setSelectedTags([]);
|
||||||
|
dispatch({
|
||||||
|
type: 'clear',
|
||||||
|
payload: initialValues,
|
||||||
|
});
|
||||||
|
} else if (reason === 'removeOption') {
|
||||||
|
setSelectedTags(newValue as TagOption[]);
|
||||||
|
if (selected?.option) {
|
||||||
|
dispatch({
|
||||||
|
type: 'remove',
|
||||||
|
payload: {
|
||||||
|
value: selected.option.title,
|
||||||
|
type: tagType.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
resetTagType();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonText="Save tags"
|
||||||
|
title="Update tags to feature toggle"
|
||||||
|
onClick={() => onSubmit(payload)}
|
||||||
|
disabledPrimaryButton={
|
||||||
|
payload.addedTags.length === 0 &&
|
||||||
|
payload.removedTags.length === 0
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
formId={formId}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
paragraph
|
||||||
|
sx={{ marginBottom: theme => theme.spacing(2.5) }}
|
||||||
|
>
|
||||||
|
Tags allow you to group features together
|
||||||
|
</Typography>
|
||||||
|
<form id={formId} onSubmit={() => onSubmit(payload)}>
|
||||||
|
<StyledDialogFormContent>
|
||||||
|
<TagTypeSelect
|
||||||
|
key={tagTypesLoading ? 'loading' : tagTypes.length}
|
||||||
|
options={tagTypes}
|
||||||
|
disabled={tagTypesLoading || tagTypes.length === 0}
|
||||||
|
value={tagType}
|
||||||
|
onChange={handleTagTypeChange}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!tagTypesLoading && tagTypes.length === 0}
|
||||||
|
show={
|
||||||
|
<Typography variant="body1">
|
||||||
|
No{' '}
|
||||||
|
<Link component={RouterLink} to="/tag-types">
|
||||||
|
tag types
|
||||||
|
</Link>{' '}
|
||||||
|
available.
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TagsInput
|
||||||
|
disabled={tagTypesLoading}
|
||||||
|
options={tagsOptions}
|
||||||
|
existingTags={initialValues}
|
||||||
|
indeterminateOptions={indeterminateTags}
|
||||||
|
tagType={tagType}
|
||||||
|
selectedOptions={selectedTags}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledDialogFormContent>
|
||||||
|
</form>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
@ -8,14 +8,15 @@ import { formatUnknownError } from 'utils/formatUnknownError';
|
|||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { ITag, ITagType } from 'interfaces/tags';
|
import { ITag, ITagType } from 'interfaces/tags';
|
||||||
import { TagOption, TagsInput } from './TagsInput';
|
import { TagOption, TagsInput } from './TagsInput';
|
||||||
import TagTypeSelect from './TagTypeSelect';
|
import { TagTypeSelect } from './TagTypeSelect';
|
||||||
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
|
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
|
||||||
import { AutocompleteChangeReason } from '@mui/base/AutocompleteUnstyled/useAutocomplete';
|
import { AutocompleteChangeReason } from '@mui/base/AutocompleteUnstyled/useAutocomplete';
|
||||||
import useTags from 'hooks/api/getters/useTags/useTags';
|
import useTags from 'hooks/api/getters/useTags/useTags';
|
||||||
import cloneDeep from 'lodash.clonedeep';
|
import cloneDeep from 'lodash.clonedeep';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
|
||||||
|
|
||||||
interface IAddTagDialogProps {
|
interface IManageTagsProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@ -43,7 +44,8 @@ const optionsToTags = (options: TagOption[], type: string): ITag[] => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
export const ManageTagsDialog = ({ open, setOpen }: IManageTagsProps) => {
|
||||||
|
const { tagTypes } = useTagTypes();
|
||||||
const featureId = useRequiredPathParam('featureId');
|
const featureId = useRequiredPathParam('featureId');
|
||||||
const { createTag } = useTagApi();
|
const { createTag } = useTagApi();
|
||||||
const { updateFeatureTags, loading: featureLoading } = useFeatureApi();
|
const { updateFeatureTags, loading: featureLoading } = useFeatureApi();
|
||||||
@ -238,11 +240,10 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
|||||||
const formId = 'add-tag-form';
|
const formId = 'add-tag-form';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Dialogue
|
<Dialogue
|
||||||
open={open}
|
open={open}
|
||||||
secondaryButtonText="Cancel"
|
secondaryButtonText="Cancel"
|
||||||
primaryButtonText={`Save tags`}
|
primaryButtonText="Save tags"
|
||||||
title="Update tags to feature toggle"
|
title="Update tags to feature toggle"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabledPrimaryButton={loading || differenceCount === 0}
|
disabledPrimaryButton={loading || differenceCount === 0}
|
||||||
@ -259,7 +260,7 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
|||||||
<form id={formId} onSubmit={onSubmit}>
|
<form id={formId} onSubmit={onSubmit}>
|
||||||
<StyledDialogFormContent>
|
<StyledDialogFormContent>
|
||||||
<TagTypeSelect
|
<TagTypeSelect
|
||||||
autoFocus
|
options={tagTypes}
|
||||||
value={tagType}
|
value={tagType}
|
||||||
onChange={handleTagTypeChange}
|
onChange={handleTagTypeChange}
|
||||||
/>
|
/>
|
||||||
@ -274,8 +275,5 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
|||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
</Dialogue>
|
</Dialogue>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddTagDialog;
|
|
@ -1,5 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
|
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
AutocompleteProps,
|
AutocompleteProps,
|
||||||
@ -11,24 +9,31 @@ import {
|
|||||||
import { ITagType } from 'interfaces/tags';
|
import { ITagType } from 'interfaces/tags';
|
||||||
|
|
||||||
interface ITagSelect {
|
interface ITagSelect {
|
||||||
|
options: ITagType[];
|
||||||
value: ITagType;
|
value: ITagType;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: AutocompleteProps<ITagType, false, any, any>['onChange'];
|
onChange: AutocompleteProps<ITagType, false, any, any>['onChange'];
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListItem = styled('li')({
|
const ListItem = styled('li')({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
});
|
});
|
||||||
const TagTypeSelect = ({ value, onChange }: ITagSelect) => {
|
|
||||||
const { tagTypes } = useTagTypes();
|
export const TagTypeSelect = ({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: ITagSelect) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
disablePortal
|
disablePortal
|
||||||
|
disabled={disabled}
|
||||||
id="tag-type-select"
|
id="tag-type-select"
|
||||||
sx={{ marginTop: theme => theme.spacing(2), width: 500 }}
|
sx={{ marginTop: theme => theme.spacing(2), width: 500 }}
|
||||||
options={tagTypes}
|
options={options}
|
||||||
disableClearable
|
disableClearable
|
||||||
value={value}
|
value={value}
|
||||||
getOptionLabel={option => option.name}
|
getOptionLabel={option => option.name}
|
||||||
@ -54,5 +59,3 @@ const TagTypeSelect = ({ value, onChange }: ITagSelect) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TagTypeSelect;
|
|
@ -10,6 +10,7 @@ import {
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||||
|
import IndeterminateCheckBoxIcon from '@mui/icons-material/IndeterminateCheckBox';
|
||||||
import { ITag, ITagType } from 'interfaces/tags';
|
import { ITag, ITagType } from 'interfaces/tags';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { Add } from '@mui/icons-material';
|
import { Add } from '@mui/icons-material';
|
||||||
@ -19,12 +20,15 @@ export type TagOption = {
|
|||||||
title: string;
|
title: string;
|
||||||
inputValue?: string;
|
inputValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ITagsInputProps {
|
interface ITagsInputProps {
|
||||||
options: TagOption[];
|
options: TagOption[];
|
||||||
existingTags: ITag[];
|
existingTags: ITag[];
|
||||||
tagType: ITagType;
|
tagType: ITagType;
|
||||||
selectedOptions: TagOption[];
|
selectedOptions: TagOption[];
|
||||||
onChange: AutocompleteProps<TagOption | string, true, any, any>['onChange'];
|
indeterminateOptions?: TagOption[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: AutocompleteProps<TagOption, true, false, false>['onChange'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = createFilterOptions<TagOption>();
|
const filter = createFilterOptions<TagOption>();
|
||||||
@ -32,12 +36,13 @@ const filter = createFilterOptions<TagOption>();
|
|||||||
export const TagsInput = ({
|
export const TagsInput = ({
|
||||||
options,
|
options,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
indeterminateOptions,
|
||||||
tagType,
|
tagType,
|
||||||
existingTags,
|
existingTags,
|
||||||
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: ITagsInputProps) => {
|
}: ITagsInputProps) => {
|
||||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
|
||||||
|
|
||||||
const getOptionLabel = (option: TagOption) => {
|
const getOptionLabel = (option: TagOption) => {
|
||||||
// Add "xxx" option created dynamically
|
// Add "xxx" option created dynamically
|
||||||
@ -55,6 +60,11 @@ export const TagsInput = ({
|
|||||||
option: TagOption,
|
option: TagOption,
|
||||||
{ selected }: { selected: boolean }
|
{ selected }: { selected: boolean }
|
||||||
) => {
|
) => {
|
||||||
|
const isIndeterminate =
|
||||||
|
indeterminateOptions?.some(
|
||||||
|
indeterminateOption =>
|
||||||
|
indeterminateOption.title === option.title
|
||||||
|
) ?? false;
|
||||||
return (
|
return (
|
||||||
<li {...props}>
|
<li {...props}>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
@ -63,9 +73,13 @@ export const TagsInput = ({
|
|||||||
elseShow={
|
elseShow={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checkedIcon={checkedIcon}
|
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||||
|
indeterminateIcon={
|
||||||
|
<IndeterminateCheckBoxIcon fontSize="small" />
|
||||||
|
}
|
||||||
sx={{ mr: theme => theme.spacing(0.5) }}
|
sx={{ mr: theme => theme.spacing(0.5) }}
|
||||||
checked={selected}
|
checked={selected && !isIndeterminate}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -77,19 +91,18 @@ export const TagsInput = ({
|
|||||||
const renderTags = (
|
const renderTags = (
|
||||||
tagValue: TagOption[],
|
tagValue: TagOption[],
|
||||||
getTagProps: AutocompleteRenderGetTagProps
|
getTagProps: AutocompleteRenderGetTagProps
|
||||||
) => {
|
) =>
|
||||||
return tagValue.map((option, index) => {
|
tagValue.map((option, index) => {
|
||||||
const exists = existingTags.some(
|
const exists = existingTags.some(
|
||||||
existingTag =>
|
existingTag =>
|
||||||
existingTag.value === option.title &&
|
existingTag.value === option.title &&
|
||||||
existingTag.type === tagType.name
|
existingTag.type === tagType.name
|
||||||
);
|
);
|
||||||
if (exists) {
|
if (exists && indeterminateOptions === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <Chip {...getTagProps({ index })} label={option.title} />;
|
return <Chip {...getTagProps({ index })} label={option.title} />;
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const filterOptions = (
|
const filterOptions = (
|
||||||
options: TagOption[],
|
options: TagOption[],
|
||||||
@ -139,6 +152,7 @@ export const TagsInput = ({
|
|||||||
placeholder="Select values"
|
placeholder="Select values"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
@ -24,7 +24,7 @@ import { FeatureSettings } from './FeatureSettings/FeatureSettings';
|
|||||||
import useLoading from 'hooks/useLoading';
|
import useLoading from 'hooks/useLoading';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
|
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
|
||||||
import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog';
|
import { ManageTagsDialog } from './FeatureOverview/ManageTagsDialog/ManageTagsDialog';
|
||||||
import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip';
|
import { FeatureStatusChip } from 'component/common/FeatureStatusChip/FeatureStatusChip';
|
||||||
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
import { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
@ -260,7 +260,7 @@ export const FeatureView = () => {
|
|||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
<AddTagDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
<ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -65,7 +65,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||||
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
|
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
|
||||||
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
|
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
|
||||||
import { ProjectFeaturesBatchActions } from './SelectionActionsBar/ProjectFeaturesBatchActions';
|
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
|
||||||
|
|
||||||
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
@ -21,7 +21,6 @@ export const ArchiveButton: VFC<IArchiveButtonProps> = ({
|
|||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
await refetch();
|
await refetch();
|
||||||
// TODO: toast
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
@ -0,0 +1,111 @@
|
|||||||
|
import { useMemo, useState, VFC } from 'react';
|
||||||
|
import { Label } from '@mui/icons-material';
|
||||||
|
import { Button } from '@mui/material';
|
||||||
|
import { ManageBulkTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageBulkTagsDialog';
|
||||||
|
import type { FeatureSchema } from 'openapi';
|
||||||
|
import { ITag } from 'interfaces/tags';
|
||||||
|
import useTagApi from 'hooks/api/actions/useTagApi/useTagApi';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import useProject from 'hooks/api/getters/useProject/useProject';
|
||||||
|
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
|
||||||
|
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
||||||
|
|
||||||
|
interface IManageTagsProps {
|
||||||
|
data: FeatureSchema[];
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManageTags: VFC<IManageTagsProps> = ({ projectId, data }) => {
|
||||||
|
const { bulkUpdateTags } = useTagApi();
|
||||||
|
const { refetch } = useProject(projectId);
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [initialValues, indeterminateValues] = useMemo(() => {
|
||||||
|
const uniqueTags = data
|
||||||
|
.flatMap(({ tags }) => tags || [])
|
||||||
|
.reduce<ITag[]>(
|
||||||
|
(acc, tag) => [
|
||||||
|
...acc,
|
||||||
|
...(acc.some(
|
||||||
|
x => x.type === tag.type && x.value === tag.value
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: [tag]),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagsNotPresentInEveryFeature = uniqueTags.filter(
|
||||||
|
tag =>
|
||||||
|
!data.every(({ tags }) =>
|
||||||
|
tags?.some(
|
||||||
|
x => x.type === tag.type && x.value === tag.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [uniqueTags, tagsNotPresentInEveryFeature];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const onSubmit = async ({
|
||||||
|
addedTags,
|
||||||
|
removedTags,
|
||||||
|
}: {
|
||||||
|
addedTags: ITag[];
|
||||||
|
removedTags: ITag[];
|
||||||
|
}) => {
|
||||||
|
const features = data.map(({ name }) => name);
|
||||||
|
const payload = { features, tags: { addedTags, removedTags } };
|
||||||
|
try {
|
||||||
|
await bulkUpdateTags(payload);
|
||||||
|
refetch();
|
||||||
|
const added = addedTags.length
|
||||||
|
? `Added tags: ${addedTags
|
||||||
|
.map(({ type, value }) => `${type}:${value}`)
|
||||||
|
.join(', ')}.`
|
||||||
|
: '';
|
||||||
|
const removed = removedTags.length
|
||||||
|
? `Removed tags: ${removedTags
|
||||||
|
.map(({ type, value }) => `${type}:${value}`)
|
||||||
|
.join(', ')}.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
setToastData({
|
||||||
|
title: 'Tags updated',
|
||||||
|
text: `${features.length} feature toggles updated. ${added} ${removed}`,
|
||||||
|
type: 'success',
|
||||||
|
autoHideDuration: 12000,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PermissionHOC projectId={projectId} permission={UPDATE_FEATURE}>
|
||||||
|
{({ hasAccess }) => (
|
||||||
|
<Button
|
||||||
|
disabled={!hasAccess || isOpen}
|
||||||
|
startIcon={<Label />}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PermissionHOC>
|
||||||
|
<ManageBulkTagsDialog
|
||||||
|
key={data.length}
|
||||||
|
open={isOpen}
|
||||||
|
onCancel={() => setIsOpen(false)}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
initialIndeterminateValues={indeterminateValues}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -5,8 +5,9 @@ import type { FeatureSchema } from 'openapi';
|
|||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { ArchiveButton } from './ArchiveButton/ArchiveButton';
|
import { ArchiveButton } from './ArchiveButton';
|
||||||
import { MoreActions } from './MoreActions/MoreActions';
|
import { MoreActions } from './MoreActions';
|
||||||
|
import { ManageTags } from './ManageTags';
|
||||||
|
|
||||||
interface IProjectFeaturesBatchActionsProps {
|
interface IProjectFeaturesBatchActionsProps {
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
@ -43,14 +44,7 @@ export const ProjectFeaturesBatchActions: FC<
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ManageTags projectId={projectId} data={selectedData} />
|
||||||
disabled
|
|
||||||
startIcon={<Label />}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
Tags
|
|
||||||
</Button>
|
|
||||||
<MoreActions projectId={projectId} data={selectedData} />
|
<MoreActions projectId={projectId} data={selectedData} />
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
|
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
|
@ -1,5 +1,5 @@
|
|||||||
import useAPI from '../useApi/useApi';
|
import useAPI from '../useApi/useApi';
|
||||||
import { TagSchema } from 'openapi/models/tagSchema';
|
import type { TagSchema, TagsBulkAddSchema } from 'openapi';
|
||||||
|
|
||||||
const useTagApi = () => {
|
const useTagApi = () => {
|
||||||
const { makeRequest, createRequest, errors, loading } = useAPI({
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
@ -20,8 +20,23 @@ const useTagApi = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bulkUpdateTags = async (payload: TagsBulkAddSchema) => {
|
||||||
|
const path = `api/admin/tags/features`;
|
||||||
|
const req = createRequest(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await makeRequest(req.caller, req.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createTag,
|
createTag,
|
||||||
|
bulkUpdateTags,
|
||||||
errors,
|
errors,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
@ -30,7 +30,7 @@ const useToast = () => {
|
|||||||
if (toast.persist) {
|
if (toast.persist) {
|
||||||
setToast({ ...toast, show: true });
|
setToast({ ...toast, show: true });
|
||||||
} else {
|
} else {
|
||||||
setToast({ ...toast, show: true, autoHideDuration: 6000 });
|
setToast({ autoHideDuration: 6000, ...toast, show: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setToast]
|
[setToast]
|
||||||
|
Loading…
Reference in New Issue
Block a user