mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-23 00:22:19 +01:00
Merge pull request #608 from Unleash/feat/create-feature
feat: create and edit feature screen (NEW)
This commit is contained in:
commit
a382744fd0
@ -24,7 +24,9 @@ const BreadcrumbNav = () => {
|
||||
item !== 'strategies' &&
|
||||
item !== 'features' &&
|
||||
item !== 'features2' &&
|
||||
item !== 'create-toggle'
|
||||
item !== 'create-toggle'&&
|
||||
item !== 'settings'
|
||||
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -11,6 +11,8 @@ interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onChange: (e: any) => any;
|
||||
onFocus?: (e: any) => any;
|
||||
onBlur?: (e: any) => any;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
const Input = ({
|
||||
|
@ -0,0 +1,105 @@
|
||||
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import FeatureForm from '../FeatureForm/FeatureForm';
|
||||
import useFeatureForm from '../hooks/useFeatureForm';
|
||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from '../../../../hooks/useToast';
|
||||
import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
|
||||
import { CREATE_FEATURE } from '../../../providers/AccessProvider/permissions';
|
||||
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
|
||||
import { CF_CREATE_BTN_ID } from '../../../../testIds';
|
||||
|
||||
const CreateFeature = () => {
|
||||
/* @ts-ignore */
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
type,
|
||||
setType,
|
||||
name,
|
||||
setName,
|
||||
project,
|
||||
setProject,
|
||||
description,
|
||||
setDescription,
|
||||
getTogglePayload,
|
||||
validateName,
|
||||
clearErrors,
|
||||
errors,
|
||||
} = useFeatureForm();
|
||||
|
||||
const { createFeatureToggle, loading } = useFeatureApi();
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
await validateName(name);
|
||||
const payload = getTogglePayload();
|
||||
try {
|
||||
await createFeatureToggle(project, payload);
|
||||
history.push(`/projects/${project}/features2/${name}`);
|
||||
setToastData({
|
||||
title: 'Toggle created successfully',
|
||||
text: 'Now you can start using your toggle.',
|
||||
confetti: true,
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e: any) {
|
||||
setToastApiError(e.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const formatApiCode = () => {
|
||||
return `curl --location --request POST '${
|
||||
uiConfig.unleashUrl
|
||||
}/api/admin/projects/${project}/features' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(getTogglePayload(), undefined, 2)}'`;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
title="Create Feature toggle"
|
||||
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
||||
The feature toggle is disabled when created and you decide when to enable"
|
||||
documentationLink="https://docs.getunleash.io/"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<FeatureForm
|
||||
type={type}
|
||||
name={name}
|
||||
project={project}
|
||||
description={description}
|
||||
setType={setType}
|
||||
setName={setName}
|
||||
setProject={setProject}
|
||||
setDescription={setDescription}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
mode="Create"
|
||||
clearErrors={clearErrors}
|
||||
>
|
||||
<PermissionButton
|
||||
onClick={handleSubmit}
|
||||
permission={CREATE_FEATURE}
|
||||
projectId={project}
|
||||
type="submit"
|
||||
data-test={CF_CREATE_BTN_ID}
|
||||
>
|
||||
Create toggle
|
||||
</PermissionButton>
|
||||
</FeatureForm>
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateFeature;
|
@ -0,0 +1,114 @@
|
||||
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import FeatureForm from '../FeatureForm/FeatureForm';
|
||||
import useFeatureForm from '../hooks/useFeatureForm';
|
||||
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from '../../../../hooks/useToast';
|
||||
import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
|
||||
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
|
||||
import { IFeatureViewParams } from '../../../../interfaces/params';
|
||||
import * as jsonpatch from 'fast-json-patch';
|
||||
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
|
||||
import { UPDATE_FEATURE } from '../../../providers/AccessProvider/permissions';
|
||||
|
||||
const EditFeature = () => {
|
||||
/* @ts-ignore */
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
const history = useHistory();
|
||||
const { projectId, featureId } = useParams<IFeatureViewParams>();
|
||||
const { patchFeatureToggle, loading } = useFeatureApi();
|
||||
const { feature } = useFeature(projectId, featureId);
|
||||
|
||||
const {
|
||||
type,
|
||||
setType,
|
||||
name,
|
||||
setName,
|
||||
project,
|
||||
setProject,
|
||||
description,
|
||||
setDescription,
|
||||
clearErrors,
|
||||
errors,
|
||||
} = useFeatureForm(
|
||||
feature?.name,
|
||||
feature?.type,
|
||||
feature?.project,
|
||||
feature?.description
|
||||
);
|
||||
|
||||
const createPatch = () => {
|
||||
const comparison = { ...feature, type, description };
|
||||
const patch = jsonpatch.compare(feature, comparison);
|
||||
return patch;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
const patch = createPatch();
|
||||
try {
|
||||
await patchFeatureToggle(project, featureId, patch);
|
||||
history.push(`/projects/${project}/features2/${name}`);
|
||||
setToastData({
|
||||
title: 'Toggle updated successfully',
|
||||
text: 'Now you can start using your toggle.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e: any) {
|
||||
setToastApiError(e.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const formatApiCode = () => {
|
||||
return `curl --location --request PATCH '${
|
||||
uiConfig.unleashUrl
|
||||
}/api/admin/projects/${projectId}/features/${featureId}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(createPatch(), undefined, 2)}'`;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
title="Create Feature toggle"
|
||||
description="Feature toggles support different use cases, each with their own specific needs such as simple static routing or more complex routing.
|
||||
The feature toggle is disabled when created and you decide when to enable"
|
||||
documentationLink="https://docs.getunleash.io/"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<FeatureForm
|
||||
type={type}
|
||||
name={name}
|
||||
project={project}
|
||||
description={description}
|
||||
setType={setType}
|
||||
setName={setName}
|
||||
setProject={setProject}
|
||||
setDescription={setDescription}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
mode="Edit"
|
||||
clearErrors={clearErrors}
|
||||
>
|
||||
<PermissionButton
|
||||
onClick={handleSubmit}
|
||||
permission={UPDATE_FEATURE}
|
||||
projectId={project}
|
||||
type="submit"
|
||||
>
|
||||
Edit toggle
|
||||
</PermissionButton>
|
||||
</FeatureForm>
|
||||
</FormTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditFeature;
|
@ -0,0 +1,61 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
maxWidth: '400px',
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
},
|
||||
input: { width: '100%', marginBottom: '1rem' },
|
||||
selectInput: {
|
||||
marginBottom: '1rem',
|
||||
minWidth: '400px',
|
||||
[theme.breakpoints.down(600)]: {
|
||||
minWidth: '379px',
|
||||
},
|
||||
},
|
||||
label: {
|
||||
minWidth: '300px',
|
||||
[theme.breakpoints.down(600)]: {
|
||||
minWidth: 'auto',
|
||||
},
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
cancelButton: {
|
||||
marginLeft: '1.5rem',
|
||||
},
|
||||
inputDescription: {
|
||||
marginBottom: '0.5rem',
|
||||
},
|
||||
typeDescription: {
|
||||
//@ts-ignore
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.grey[600],
|
||||
top: '-13px',
|
||||
position: 'relative',
|
||||
},
|
||||
formHeader: {
|
||||
fontWeight: 'normal',
|
||||
marginTop: '0',
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
permissionErrorContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
errorMessage: {
|
||||
//@ts-ignore
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.error.main,
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
},
|
||||
}));
|
@ -0,0 +1,148 @@
|
||||
import { CREATE_FEATURE } from '../../../providers/AccessProvider/permissions';
|
||||
import Input from '../../../common/Input/Input';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { useStyles } from './FeatureForm.styles';
|
||||
import FeatureTypeSelect from '../../FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect';
|
||||
import {
|
||||
CF_DESC_ID,
|
||||
CF_NAME_ID,
|
||||
CF_TYPE_ID,
|
||||
} from '../../../../testIds';
|
||||
import useFeatureTypes from '../../../../hooks/api/getters/useFeatureTypes/useFeatureTypes';
|
||||
import { KeyboardArrowDownOutlined } from '@material-ui/icons';
|
||||
import useUser from '../../../../hooks/api/getters/useUser/useUser';
|
||||
import { projectFilterGenerator } from '../../../../utils/project-filter-generator';
|
||||
import FeatureProjectSelect from '../../FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect';
|
||||
import ConditionallyRender from '../../../common/ConditionallyRender';
|
||||
import { trim } from '../../../common/util';
|
||||
|
||||
interface IFeatureToggleForm {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
project: string;
|
||||
setType: React.Dispatch<React.SetStateAction<string>>;
|
||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProject: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleSubmit: (e: any) => void;
|
||||
handleCancel: () => void;
|
||||
errors: { [key: string]: string };
|
||||
mode: string;
|
||||
clearErrors: () => void;
|
||||
}
|
||||
|
||||
const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
children,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
project,
|
||||
setType,
|
||||
setName,
|
||||
setDescription,
|
||||
setProject,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
errors,
|
||||
mode,
|
||||
clearErrors,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const { featureTypes } = useFeatureTypes();
|
||||
const { permissions } = useUser();
|
||||
const editable = mode !== 'Edit';
|
||||
|
||||
const renderToggleDescription = () => {
|
||||
return featureTypes.find(toggle => toggle.id === type)?.description;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.inputDescription}>
|
||||
What kind of feature toggle do you want to create?
|
||||
</p>
|
||||
<FeatureTypeSelect
|
||||
value={type}
|
||||
onChange={(e: React.SyntheticEvent) =>
|
||||
setType(e.target.value)
|
||||
}
|
||||
label={'Toggle type'}
|
||||
id="feature-type-select"
|
||||
editable
|
||||
inputProps={{
|
||||
'data-test': CF_TYPE_ID,
|
||||
}}
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
className={styles.selectInput}
|
||||
/>
|
||||
<p className={styles.typeDescription}>
|
||||
{renderToggleDescription()}
|
||||
</p>
|
||||
|
||||
<p className={styles.inputDescription}>
|
||||
What would you like to call your toggle?
|
||||
</p>
|
||||
<Input
|
||||
disabled={mode === 'Edit'}
|
||||
className={styles.input}
|
||||
label="Name"
|
||||
error={Boolean(errors.name)}
|
||||
errorText={errors.name}
|
||||
onFocus={() => clearErrors()}
|
||||
value={name}
|
||||
onChange={e => setName(trim(e.target.value))}
|
||||
inputProps={{
|
||||
'data-test': CF_NAME_ID,
|
||||
}}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={editable}
|
||||
show={
|
||||
<p className={styles.inputDescription}>
|
||||
In which project do you want to save the toggle?
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<FeatureProjectSelect
|
||||
value={project}
|
||||
onChange={e => setProject(e.target.value)}
|
||||
enabled={editable}
|
||||
label="Project"
|
||||
filter={projectFilterGenerator(
|
||||
{ permissions },
|
||||
CREATE_FEATURE
|
||||
)}
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
className={styles.selectInput}
|
||||
/>
|
||||
|
||||
<p className={styles.inputDescription}>
|
||||
How would you describe your feature toggle?
|
||||
</p>
|
||||
<Input
|
||||
className={styles.input}
|
||||
multiline
|
||||
rows={4}
|
||||
label="Description"
|
||||
placeholder="A short description of the feature toggle"
|
||||
value={description}
|
||||
inputProps={{
|
||||
'data-test': CF_DESC_ID,
|
||||
}}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{children}
|
||||
<Button onClick={handleCancel} className={styles.cancelButton}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureForm;
|
@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
|
||||
import useQueryParams from '../../../../hooks/useQueryParams';
|
||||
import { IFeatureViewParams } from '../../../../interfaces/params';
|
||||
|
||||
const useFeatureForm = (
|
||||
initialName = '',
|
||||
initialType = 'release',
|
||||
initialProject = 'default',
|
||||
initialDescription = ''
|
||||
) => {
|
||||
const { projectId } = useParams<IFeatureViewParams>();
|
||||
const params = useQueryParams();
|
||||
const { validateFeatureToggleName } = useFeatureApi();
|
||||
const toggleQueryName = params.get('name');
|
||||
const [type, setType] = useState(initialType);
|
||||
const [name, setName] = useState(toggleQueryName || initialName);
|
||||
const [project, setProject] = useState(projectId || initialProject);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
setType(initialType);
|
||||
}, [initialType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toggleQueryName) setName(initialName);
|
||||
else setName(toggleQueryName);
|
||||
}, [initialName, toggleQueryName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) setProject(initialProject);
|
||||
else setProject(projectId);
|
||||
}, [initialProject, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(initialDescription);
|
||||
}, [initialDescription]);
|
||||
|
||||
const getTogglePayload = () => {
|
||||
return {
|
||||
type: type,
|
||||
name: name,
|
||||
projectId: project,
|
||||
description: description,
|
||||
};
|
||||
};
|
||||
|
||||
const validateName = async (name: string) => {
|
||||
if (name.length === 0) {
|
||||
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
|
||||
return false;
|
||||
}
|
||||
if (name.length > 0) {
|
||||
try {
|
||||
await validateFeatureToggleName(name);
|
||||
} catch (err: any) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
name:
|
||||
err && err.message
|
||||
? err.message
|
||||
: 'Could not check name',
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearErrors = () => {
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return {
|
||||
type,
|
||||
setType,
|
||||
name,
|
||||
setName,
|
||||
project,
|
||||
setProject,
|
||||
description,
|
||||
setDescription,
|
||||
getTogglePayload,
|
||||
validateName,
|
||||
clearErrors,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFeatureForm;
|
@ -134,6 +134,7 @@ const FeatureOverviewEnvironment = ({
|
||||
name
|
||||
)}
|
||||
arrow
|
||||
key={name}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
@ -47,6 +47,15 @@ Array [
|
||||
"title": "Copy",
|
||||
"type": "protected",
|
||||
},
|
||||
Object {
|
||||
"component": [Function],
|
||||
"layout": "main",
|
||||
"menu": Object {},
|
||||
"parent": "/projects",
|
||||
"path": "/projects/:projectId/features2/:featureId/settings",
|
||||
"title": "Edit Feature",
|
||||
"type": "protected",
|
||||
},
|
||||
Object {
|
||||
"component": [Function],
|
||||
"flags": "E",
|
||||
|
@ -31,7 +31,6 @@ import RedirectFeatureViewPage from '../../page/features/redirect';
|
||||
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
|
||||
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
|
||||
import FeatureView2 from '../feature/FeatureView2/FeatureView2';
|
||||
import FeatureCreate from '../feature/FeatureCreate/FeatureCreate';
|
||||
import ProjectRoles from '../admin/project-roles/ProjectRoles/ProjectRoles';
|
||||
import CreateProjectRole from '../admin/project-roles/CreateProjectRole/CreateProjectRole';
|
||||
import EditProjectRole from '../admin/project-roles/EditProjectRole/EditProjectRole';
|
||||
@ -46,6 +45,8 @@ import EditTagType from '../tagTypes/EditTagType/EditTagType';
|
||||
import CreateTagType from '../tagTypes/CreateTagType/CreateTagType';
|
||||
import EditProject from '../project/Project/EditProject/EditProject';
|
||||
import CreateProject from '../project/Project/CreateProject/CreateProject';
|
||||
import CreateFeature from '../feature/CreateFeature/CreateFeature/CreateFeature';
|
||||
import EditFeature from '../feature/CreateFeature/EditFeature/EditFeature';
|
||||
|
||||
export const routes = [
|
||||
// Project
|
||||
@ -95,6 +96,15 @@ export const routes = [
|
||||
layout: 'main',
|
||||
menu: {},
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/features2/:featureId/settings',
|
||||
parent: '/projects',
|
||||
title: 'Edit Feature',
|
||||
component: EditFeature,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
menu: {},
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/features2/:featureId',
|
||||
parent: '/projects',
|
||||
@ -118,7 +128,7 @@ export const routes = [
|
||||
path: '/projects/:projectId/create-toggle',
|
||||
parent: '/projects/:id/features',
|
||||
title: 'Create feature toggle',
|
||||
component: FeatureCreate,
|
||||
component: CreateFeature,
|
||||
type: 'protected',
|
||||
layout: 'main',
|
||||
menu: {},
|
||||
|
@ -8,9 +8,7 @@ const useFeatureApi = () => {
|
||||
propagateErrors: true,
|
||||
});
|
||||
|
||||
const validateFeatureToggleName = async (
|
||||
name: string,
|
||||
) => {
|
||||
const validateFeatureToggleName = async (name: string) => {
|
||||
const path = `api/admin/features/validate`;
|
||||
const req = createRequest(path, {
|
||||
method: 'POST',
|
||||
@ -26,10 +24,9 @@ const useFeatureApi = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const createFeatureToggle = async (
|
||||
projectId: string,
|
||||
featureToggle: IFeatureToggleDTO,
|
||||
featureToggle: IFeatureToggleDTO
|
||||
) => {
|
||||
const path = `api/admin/projects/${projectId}/features`;
|
||||
const req = createRequest(path, {
|
||||
@ -183,7 +180,11 @@ const useFeatureApi = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const patchFeatureVariants = async (projectId: string, featureId: string, patchPayload: Operation[]) => {
|
||||
const patchFeatureVariants = async (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
patchPayload: Operation[]
|
||||
) => {
|
||||
const path = `api/admin/projects/${projectId}/features/${featureId}/variants`;
|
||||
const req = createRequest(path, {
|
||||
method: 'PATCH',
|
||||
|
Loading…
Reference in New Issue
Block a user