1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00
1-753/frontend-features-in-project-overview
This commit is contained in:
Tymoteusz Czech 2023-03-21 13:37:25 +01:00 committed by GitHub
parent 535b74f454
commit 2147206b49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 488 additions and 76 deletions

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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,44 +240,40 @@ const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
const formId = 'add-tag-form';
return (
<>
<Dialogue
open={open}
secondaryButtonText="Cancel"
primaryButtonText={`Save tags`}
title="Update tags to feature toggle"
onClick={onSubmit}
disabledPrimaryButton={loading || differenceCount === 0}
onClose={onCancel}
formId={formId}
>
<>
<Typography
paragraph
sx={{ marginBottom: theme => theme.spacing(2.5) }}
>
Tags allow you to group features together
</Typography>
<form id={formId} onSubmit={onSubmit}>
<StyledDialogFormContent>
<TagTypeSelect
autoFocus
value={tagType}
onChange={handleTagTypeChange}
/>
<TagsInput
options={tagTypeOptions}
existingTags={tags}
tagType={tagType}
selectedOptions={selectedTagOptions}
onChange={handleInputChange}
/>
</StyledDialogFormContent>
</form>
</>
</Dialogue>
</>
<Dialogue
open={open}
secondaryButtonText="Cancel"
primaryButtonText="Save tags"
title="Update tags to feature toggle"
onClick={onSubmit}
disabledPrimaryButton={loading || differenceCount === 0}
onClose={onCancel}
formId={formId}
>
<>
<Typography
paragraph
sx={{ marginBottom: theme => theme.spacing(2.5) }}
>
Tags allow you to group features together
</Typography>
<form id={formId} onSubmit={onSubmit}>
<StyledDialogFormContent>
<TagTypeSelect
options={tagTypes}
value={tagType}
onChange={handleTagTypeChange}
/>
<TagsInput
options={tagTypeOptions}
existingTags={tags}
tagType={tagType}
selectedOptions={selectedTagOptions}
onChange={handleInputChange}
/>
</StyledDialogFormContent>
</form>
</>
</Dialogue>
);
};
export default AddTagDialog;

View File

@ -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;

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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',

View File

@ -21,7 +21,6 @@ export const ArchiveButton: VFC<IArchiveButtonProps> = ({
const onConfirm = async () => {
setIsDialogOpen(false);
await refetch();
// TODO: toast
};
return (

View File

@ -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}
/>
</>
);
};

View File

@ -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)}

View File

@ -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,
};

View File

@ -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]