mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
parent
535b74f454
commit
2147206b49
@ -3,7 +3,7 @@ import { useContext, useState } from 'react';
|
||||
import { Button, Chip, Divider, styled } from '@mui/material';
|
||||
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 { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog';
|
||||
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
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
|
||||
open={showDelDialog}
|
||||
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 { ITag, ITagType } from 'interfaces/tags';
|
||||
import { TagOption, TagsInput } from './TagsInput';
|
||||
import TagTypeSelect from './TagTypeSelect';
|
||||
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';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
|
||||
|
||||
interface IAddTagDialogProps {
|
||||
interface IManageTagsProps {
|
||||
open: 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 { createTag } = useTagApi();
|
||||
const { updateFeatureTags, loading: featureLoading } = useFeatureApi();
|
||||
@ -238,11 +240,10 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
||||
const formId = 'add-tag-form';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialogue
|
||||
open={open}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonText={`Save tags`}
|
||||
primaryButtonText="Save tags"
|
||||
title="Update tags to feature toggle"
|
||||
onClick={onSubmit}
|
||||
disabledPrimaryButton={loading || differenceCount === 0}
|
||||
@ -259,7 +260,7 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
||||
<form id={formId} onSubmit={onSubmit}>
|
||||
<StyledDialogFormContent>
|
||||
<TagTypeSelect
|
||||
autoFocus
|
||||
options={tagTypes}
|
||||
value={tagType}
|
||||
onChange={handleTagTypeChange}
|
||||
/>
|
||||
@ -274,8 +275,5 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
|
||||
</form>
|
||||
</>
|
||||
</Dialogue>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTagDialog;
|
@ -1,5 +1,3 @@
|
||||
import React from 'react';
|
||||
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteProps,
|
||||
@ -11,24 +9,31 @@ import {
|
||||
import { ITagType } from 'interfaces/tags';
|
||||
|
||||
interface ITagSelect {
|
||||
options: ITagType[];
|
||||
value: ITagType;
|
||||
disabled?: boolean;
|
||||
onChange: AutocompleteProps<ITagType, false, any, any>['onChange'];
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const ListItem = styled('li')({
|
||||
flexDirection: 'column',
|
||||
});
|
||||
const TagTypeSelect = ({ value, onChange }: ITagSelect) => {
|
||||
const { tagTypes } = useTagTypes();
|
||||
|
||||
export const TagTypeSelect = ({
|
||||
options,
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: ITagSelect) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
disabled={disabled}
|
||||
id="tag-type-select"
|
||||
sx={{ marginTop: theme => theme.spacing(2), width: 500 }}
|
||||
options={tagTypes}
|
||||
options={options}
|
||||
disableClearable
|
||||
value={value}
|
||||
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 CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import IndeterminateCheckBoxIcon from '@mui/icons-material/IndeterminateCheckBox';
|
||||
import { ITag, ITagType } from 'interfaces/tags';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { Add } from '@mui/icons-material';
|
||||
@ -19,12 +20,15 @@ export type TagOption = {
|
||||
title: string;
|
||||
inputValue?: string;
|
||||
};
|
||||
|
||||
interface ITagsInputProps {
|
||||
options: TagOption[];
|
||||
existingTags: ITag[];
|
||||
tagType: ITagType;
|
||||
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>();
|
||||
@ -32,12 +36,13 @@ const filter = createFilterOptions<TagOption>();
|
||||
export const TagsInput = ({
|
||||
options,
|
||||
selectedOptions,
|
||||
indeterminateOptions,
|
||||
tagType,
|
||||
existingTags,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: ITagsInputProps) => {
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
const getOptionLabel = (option: TagOption) => {
|
||||
// Add "xxx" option created dynamically
|
||||
@ -55,6 +60,11 @@ export const TagsInput = ({
|
||||
option: TagOption,
|
||||
{ selected }: { selected: boolean }
|
||||
) => {
|
||||
const isIndeterminate =
|
||||
indeterminateOptions?.some(
|
||||
indeterminateOption =>
|
||||
indeterminateOption.title === option.title
|
||||
) ?? false;
|
||||
return (
|
||||
<li {...props}>
|
||||
<ConditionallyRender
|
||||
@ -63,9 +73,13 @@ export const TagsInput = ({
|
||||
elseShow={
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
indeterminateIcon={
|
||||
<IndeterminateCheckBoxIcon fontSize="small" />
|
||||
}
|
||||
sx={{ mr: theme => theme.spacing(0.5) }}
|
||||
checked={selected}
|
||||
checked={selected && !isIndeterminate}
|
||||
indeterminate={isIndeterminate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@ -77,19 +91,18 @@ export const TagsInput = ({
|
||||
const renderTags = (
|
||||
tagValue: TagOption[],
|
||||
getTagProps: AutocompleteRenderGetTagProps
|
||||
) => {
|
||||
return tagValue.map((option, index) => {
|
||||
) =>
|
||||
tagValue.map((option, index) => {
|
||||
const exists = existingTags.some(
|
||||
existingTag =>
|
||||
existingTag.value === option.title &&
|
||||
existingTag.type === tagType.name
|
||||
);
|
||||
if (exists) {
|
||||
if (exists && indeterminateOptions === undefined) {
|
||||
return null;
|
||||
}
|
||||
return <Chip {...getTagProps({ index })} label={option.title} />;
|
||||
});
|
||||
};
|
||||
|
||||
const filterOptions = (
|
||||
options: TagOption[],
|
||||
@ -139,6 +152,7 @@ export const TagsInput = ({
|
||||
placeholder="Select values"
|
||||
/>
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
@ -24,7 +24,7 @@ import { FeatureSettings } from './FeatureSettings/FeatureSettings';
|
||||
import useLoading from 'hooks/useLoading';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
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 { FeatureNotFound } from 'component/feature/FeatureView/FeatureNotFound/FeatureNotFound';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
@ -260,7 +260,7 @@ export const FeatureView = () => {
|
||||
featureId={featureId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AddTagDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
||||
<ManageTagsDialog open={openTagDialog} setOpen={setOpenTagDialog} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -65,7 +65,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
|
||||
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
|
||||
import { ProjectFeaturesBatchActions } from './SelectionActionsBar/ProjectFeaturesBatchActions';
|
||||
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
|
||||
|
||||
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
|
||||
whiteSpace: 'nowrap',
|
||||
|
@ -21,7 +21,6 @@ export const ArchiveButton: VFC<IArchiveButtonProps> = ({
|
||||
const onConfirm = async () => {
|
||||
setIsDialogOpen(false);
|
||||
await refetch();
|
||||
// TODO: toast
|
||||
};
|
||||
|
||||
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 { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { ArchiveButton } from './ArchiveButton/ArchiveButton';
|
||||
import { MoreActions } from './MoreActions/MoreActions';
|
||||
import { ArchiveButton } from './ArchiveButton';
|
||||
import { MoreActions } from './MoreActions';
|
||||
import { ManageTags } from './ManageTags';
|
||||
|
||||
interface IProjectFeaturesBatchActionsProps {
|
||||
selectedIds: string[];
|
||||
@ -43,14 +44,7 @@ export const ProjectFeaturesBatchActions: FC<
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
startIcon={<Label />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Tags
|
||||
</Button>
|
||||
<ManageTags projectId={projectId} data={selectedData} />
|
||||
<MoreActions projectId={projectId} data={selectedData} />
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.featuresExportImport)}
|
@ -1,5 +1,5 @@
|
||||
import useAPI from '../useApi/useApi';
|
||||
import { TagSchema } from 'openapi/models/tagSchema';
|
||||
import type { TagSchema, TagsBulkAddSchema } from 'openapi';
|
||||
|
||||
const useTagApi = () => {
|
||||
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 {
|
||||
createTag,
|
||||
bulkUpdateTags,
|
||||
errors,
|
||||
loading,
|
||||
};
|
||||
|
@ -30,7 +30,7 @@ const useToast = () => {
|
||||
if (toast.persist) {
|
||||
setToast({ ...toast, show: true });
|
||||
} else {
|
||||
setToast({ ...toast, show: true, autoHideDuration: 6000 });
|
||||
setToast({ autoHideDuration: 6000, ...toast, show: true });
|
||||
}
|
||||
},
|
||||
[setToast]
|
||||
|
Loading…
Reference in New Issue
Block a user