mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: Project scoped stickiness (#3289)
Project scoped stickiness <!-- Thanks for creating a PR! To make it easier for reviewers and everyone else to understand what your changes relate to, please add some relevant content to the headings below. Feel free to ignore or delete sections that you don't think are relevant. Thank you! ❤️ --> Adds `projectScopedStickiness` flag to experimental.ts Refactor Stickiness select for reusability Modify FlexibleStrategy to respect the setting. Modify EnvironmentVariantModal to respect the setting ## About the changes <!-- Describe the changes introduced. What are they and why are they being introduced? Feel free to also add screenshots or steps to view the changes if they're visual. --> <!-- Does it close an issue? Multiple? --> Closes # <!-- (For internal contributors): Does it relate to an issue on public roadmap? --> <!-- Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: # --> ### Important files <!-- PRs can contain a lot of changes, but not all changes are equally important. Where should a reviewer start looking to get an overview of the changes? Are any files particularly important? --> ## Discussion points <!-- Anything about the PR you'd like to discuss before it gets merged? Got any questions or doubts? --> --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
99a5b96c20
commit
3193423d2d
@ -17,8 +17,9 @@ import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessP
|
||||
import { WeightType } from 'constants/variantTypes';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import { updateWeightEdit } from 'component/common/util';
|
||||
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
|
||||
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
|
||||
|
||||
const StyledFormSubtitle = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -65,10 +66,10 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
marginTop: theme.spacing(4),
|
||||
}));
|
||||
|
||||
const StyledVariantForms = styled('div')(({ theme }) => ({
|
||||
const StyledVariantForms = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
}));
|
||||
});
|
||||
|
||||
const StyledStickinessContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -83,7 +84,7 @@ const StyledDescription = styled('p')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
|
||||
const StyledStickinessSelect = styled(StickinessSelect)(({ theme }) => ({
|
||||
minWidth: theme.spacing(20),
|
||||
width: '100%',
|
||||
}));
|
||||
@ -134,6 +135,7 @@ export const EnvironmentVariantsModal = ({
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { context } = useUnleashContext();
|
||||
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
|
||||
|
||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
||||
const { data } = usePendingChangeRequests(projectId);
|
||||
@ -161,7 +163,7 @@ export const EnvironmentVariantsModal = ({
|
||||
stickiness:
|
||||
variantsEdit?.length > 0
|
||||
? variantsEdit[0].stickiness
|
||||
: 'default',
|
||||
: defaultStickiness,
|
||||
new: true,
|
||||
isValid: false,
|
||||
id: uuidv4(),
|
||||
@ -225,7 +227,7 @@ export const EnvironmentVariantsModal = ({
|
||||
isChangeRequestConfigured(environment?.name || '') &&
|
||||
uiConfig.flags.crOnVariants;
|
||||
|
||||
const stickiness = variants[0]?.stickiness || 'default';
|
||||
const stickiness = variants[0]?.stickiness || defaultStickiness;
|
||||
const stickinessOptions = useMemo(
|
||||
() => [
|
||||
'default',
|
||||
@ -258,7 +260,6 @@ export const EnvironmentVariantsModal = ({
|
||||
setError(apiPayload.error);
|
||||
}
|
||||
}, [apiPayload.error]);
|
||||
|
||||
return (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
@ -378,10 +379,13 @@ export const EnvironmentVariantsModal = ({
|
||||
</a>
|
||||
</StyledDescription>
|
||||
<div>
|
||||
<StyledGeneralSelect
|
||||
options={options}
|
||||
<StyledStickinessSelect
|
||||
value={stickiness}
|
||||
onChange={onStickinessChange}
|
||||
label={''}
|
||||
editable
|
||||
onChange={e =>
|
||||
onStickinessChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Typography } from '@mui/material';
|
||||
import { IFeatureStrategyParameters } from 'interfaces/strategy';
|
||||
import RolloutSlider from '../RolloutSlider/RolloutSlider';
|
||||
import Select from 'component/common/select';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import {
|
||||
FLEXIBLE_STRATEGY_GROUP_ID,
|
||||
@ -12,13 +11,10 @@ import {
|
||||
parseParameterNumber,
|
||||
parseParameterString,
|
||||
} from 'utils/parseParameter';
|
||||
|
||||
const builtInStickinessOptions = [
|
||||
{ key: 'default', label: 'default' },
|
||||
{ key: 'userId', label: 'userId' },
|
||||
{ key: 'sessionId', label: 'sessionId' },
|
||||
{ key: 'random', label: 'random' },
|
||||
];
|
||||
import { StickinessSelect } from './StickinessSelect/StickinessSelect';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||
import { useDefaultProjectStickiness } from '../../../../hooks/useDefaultProjectStickiness';
|
||||
|
||||
interface IFlexibleStrategyProps {
|
||||
parameters: IFeatureStrategyParameters;
|
||||
@ -30,9 +26,11 @@ interface IFlexibleStrategyProps {
|
||||
const FlexibleStrategy = ({
|
||||
updateParameter,
|
||||
parameters,
|
||||
context,
|
||||
editable = true,
|
||||
}: IFlexibleStrategyProps) => {
|
||||
const projectId = useOptionalPathParam('projectId');
|
||||
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
|
||||
|
||||
const onUpdate = (field: string) => (newValue: string) => {
|
||||
updateParameter(field, newValue);
|
||||
};
|
||||
@ -41,26 +39,25 @@ const FlexibleStrategy = ({
|
||||
updateParameter('rollout', value.toString());
|
||||
};
|
||||
|
||||
const resolveStickiness = () =>
|
||||
builtInStickinessOptions.concat(
|
||||
context
|
||||
// @ts-expect-error
|
||||
.filter(c => c.stickiness)
|
||||
.filter(
|
||||
// @ts-expect-error
|
||||
c => !builtInStickinessOptions.find(s => s.key === c.name)
|
||||
)
|
||||
// @ts-expect-error
|
||||
.map(c => ({ key: c.name, label: c.name }))
|
||||
);
|
||||
|
||||
const stickinessOptions = resolveStickiness();
|
||||
|
||||
const rollout =
|
||||
parameters.rollout !== undefined
|
||||
? parseParameterNumber(parameters.rollout)
|
||||
: 100;
|
||||
|
||||
const resolveStickiness = () => {
|
||||
if (parameters.stickiness === '') {
|
||||
return defaultStickiness;
|
||||
}
|
||||
|
||||
return parseParameterString(parameters.stickiness);
|
||||
};
|
||||
|
||||
const stickiness = resolveStickiness();
|
||||
|
||||
if (parameters.stickiness === '') {
|
||||
onUpdate('stickiness')(stickiness);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RolloutSlider
|
||||
@ -84,14 +81,11 @@ const FlexibleStrategy = ({
|
||||
Stickiness
|
||||
<HelpIcon tooltip="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random." />
|
||||
</Typography>
|
||||
<Select
|
||||
id="stickiness-select"
|
||||
name="stickiness"
|
||||
<StickinessSelect
|
||||
label="Stickiness"
|
||||
options={stickinessOptions}
|
||||
value={parseParameterString(parameters.stickiness)}
|
||||
disabled={!editable}
|
||||
data-testid={FLEXIBLE_STRATEGY_STICKINESS_ID}
|
||||
value={stickiness}
|
||||
editable={editable}
|
||||
dataTestId={FLEXIBLE_STRATEGY_STICKINESS_ID}
|
||||
onChange={e => onUpdate('stickiness')(e.target.value)}
|
||||
/>
|
||||
|
||||
|
@ -0,0 +1,57 @@
|
||||
import Select from 'component/common/select';
|
||||
import { SelectChangeEvent } from '@mui/material';
|
||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||
const builtInStickinessOptions = [
|
||||
{ key: 'default', label: 'default' },
|
||||
{ key: 'userId', label: 'userId' },
|
||||
{ key: 'sessionId', label: 'sessionId' },
|
||||
{ key: 'random', label: 'random' },
|
||||
];
|
||||
|
||||
interface IStickinessSelectProps {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
editable: boolean;
|
||||
onChange: (event: SelectChangeEvent) => void;
|
||||
dataTestId?: string;
|
||||
}
|
||||
export const StickinessSelect = ({
|
||||
label,
|
||||
editable,
|
||||
value,
|
||||
onChange,
|
||||
dataTestId,
|
||||
}: IStickinessSelectProps) => {
|
||||
const { context } = useUnleashContext();
|
||||
|
||||
const resolveStickinessOptions = () =>
|
||||
builtInStickinessOptions.concat(
|
||||
context
|
||||
.filter(contextDefinition => contextDefinition.stickiness)
|
||||
.filter(
|
||||
contextDefinition =>
|
||||
!builtInStickinessOptions.find(
|
||||
builtInStickinessOption =>
|
||||
builtInStickinessOption.key ===
|
||||
contextDefinition.name
|
||||
)
|
||||
)
|
||||
.map(c => ({ key: c.name, label: c.name }))
|
||||
);
|
||||
|
||||
const stickinessOptions = resolveStickinessOptions();
|
||||
|
||||
return (
|
||||
<Select
|
||||
id="stickiness-select"
|
||||
name="stickiness"
|
||||
label={label}
|
||||
options={stickinessOptions}
|
||||
value={value}
|
||||
disabled={!editable}
|
||||
data-testid={dataTestId}
|
||||
onChange={onChange}
|
||||
style={{ width: 'inherit', minWidth: '100%' }}
|
||||
/>
|
||||
);
|
||||
};
|
@ -10,6 +10,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { GO_BACK } from 'constants/navigate';
|
||||
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
|
||||
|
||||
const CreateProject = () => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
@ -27,11 +28,16 @@ const CreateProject = () => {
|
||||
clearErrors,
|
||||
validateProjectId,
|
||||
validateName,
|
||||
setProjectStickiness,
|
||||
projectStickiness,
|
||||
errors,
|
||||
} = useProjectForm();
|
||||
|
||||
const { createProject, loading } = useProjectApi();
|
||||
|
||||
const { setDefaultProjectStickiness } =
|
||||
useDefaultProjectStickiness(projectId);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
clearErrors();
|
||||
@ -42,6 +48,7 @@ const CreateProject = () => {
|
||||
const payload = getProjectPayload();
|
||||
try {
|
||||
await createProject(payload);
|
||||
setDefaultProjectStickiness(payload.projectStickiness);
|
||||
refetchUser();
|
||||
navigate(`/projects/${projectId}`);
|
||||
setToastData({
|
||||
@ -85,6 +92,8 @@ const CreateProject = () => {
|
||||
projectId={projectId}
|
||||
setProjectId={setProjectId}
|
||||
projectName={projectName}
|
||||
projectStickiness={projectStickiness}
|
||||
setProjectStickiness={setProjectStickiness}
|
||||
setProjectName={setProjectName}
|
||||
projectDesc={projectDesc}
|
||||
setProjectDesc={setProjectDesc}
|
||||
|
@ -14,6 +14,7 @@ import { useContext } from 'react';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { Alert } from '@mui/material';
|
||||
import { GO_BACK } from 'constants/navigate';
|
||||
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
|
||||
|
||||
const EditProject = () => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
@ -21,21 +22,30 @@ const EditProject = () => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const id = useRequiredPathParam('projectId');
|
||||
const { project } = useProject(id);
|
||||
const { defaultStickiness, setDefaultProjectStickiness } =
|
||||
useDefaultProjectStickiness(id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
projectId,
|
||||
projectName,
|
||||
projectDesc,
|
||||
projectStickiness,
|
||||
setProjectId,
|
||||
setProjectName,
|
||||
setProjectDesc,
|
||||
setProjectStickiness,
|
||||
getProjectPayload,
|
||||
clearErrors,
|
||||
validateProjectId,
|
||||
validateName,
|
||||
errors,
|
||||
} = useProjectForm(id, project.name, project.description);
|
||||
} = useProjectForm(
|
||||
id,
|
||||
project.name,
|
||||
project.description,
|
||||
defaultStickiness
|
||||
);
|
||||
|
||||
const formatApiCode = () => {
|
||||
return `curl --location --request PUT '${
|
||||
@ -58,6 +68,7 @@ const EditProject = () => {
|
||||
if (validName) {
|
||||
try {
|
||||
await editProject(id, payload);
|
||||
setDefaultProjectStickiness(payload.projectStickiness);
|
||||
refetch();
|
||||
navigate(`/projects/${id}`);
|
||||
setToastData({
|
||||
@ -98,6 +109,8 @@ const EditProject = () => {
|
||||
setProjectId={setProjectId}
|
||||
projectName={projectName}
|
||||
setProjectName={setProjectName}
|
||||
projectStickiness={projectStickiness}
|
||||
setProjectStickiness={setProjectStickiness}
|
||||
projectDesc={projectDesc}
|
||||
setProjectDesc={setProjectDesc}
|
||||
mode="Edit"
|
||||
|
@ -9,10 +9,15 @@ import {
|
||||
StyledButtonContainer,
|
||||
StyledButton,
|
||||
} from './ProjectForm.styles';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
interface IProjectForm {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
projectDesc: string;
|
||||
projectStickiness?: string;
|
||||
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjectId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjectName: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
|
||||
@ -31,14 +36,19 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
projectId,
|
||||
projectName,
|
||||
projectDesc,
|
||||
projectStickiness,
|
||||
setProjectId,
|
||||
setProjectName,
|
||||
setProjectDesc,
|
||||
setProjectStickiness,
|
||||
errors,
|
||||
mode,
|
||||
validateProjectId,
|
||||
clearErrors,
|
||||
}) => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { projectScopedStickiness } = uiConfig.flags;
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<StyledContainer>
|
||||
@ -80,6 +90,29 @@ const ProjectForm: React.FC<IProjectForm> = ({
|
||||
value={projectDesc}
|
||||
onChange={e => setProjectDesc(e.target.value)}
|
||||
/>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
Boolean(projectScopedStickiness) &&
|
||||
setProjectStickiness != null
|
||||
}
|
||||
show={
|
||||
<>
|
||||
<StyledDescription>
|
||||
What is the default stickiness for the project?
|
||||
</StyledDescription>
|
||||
<StickinessSelect
|
||||
label="Stickiness"
|
||||
value={projectStickiness}
|
||||
onChange={e =>
|
||||
setProjectStickiness &&
|
||||
setProjectStickiness(e.target.value)
|
||||
}
|
||||
editable
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
<StyledButtonContainer>
|
||||
|
@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
|
||||
|
||||
const useProjectForm = (
|
||||
initialProjectId = '',
|
||||
initialProjectName = '',
|
||||
initialProjectDesc = ''
|
||||
initialProjectDesc = '',
|
||||
initialProjectStickiness = 'default'
|
||||
) => {
|
||||
const [projectId, setProjectId] = useState(initialProjectId);
|
||||
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
|
||||
|
||||
const [projectName, setProjectName] = useState(initialProjectName);
|
||||
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
|
||||
const [projectStickiness, setProjectStickiness] = useState(
|
||||
defaultStickiness || initialProjectStickiness
|
||||
);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const { validateId } = useProjectApi();
|
||||
|
||||
useEffect(() => {
|
||||
@ -30,6 +38,7 @@ const useProjectForm = (
|
||||
id: projectId,
|
||||
name: projectName,
|
||||
description: projectDesc,
|
||||
projectStickiness,
|
||||
};
|
||||
};
|
||||
|
||||
@ -64,9 +73,11 @@ const useProjectForm = (
|
||||
projectId,
|
||||
projectName,
|
||||
projectDesc,
|
||||
projectStickiness,
|
||||
setProjectId,
|
||||
setProjectName,
|
||||
setProjectDesc,
|
||||
setProjectStickiness,
|
||||
getProjectPayload,
|
||||
validateName,
|
||||
validateProjectId,
|
||||
|
35
frontend/src/hooks/useDefaultProjectStickiness.ts
Normal file
35
frontend/src/hooks/useDefaultProjectStickiness.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
|
||||
import { usePlausibleTracker } from './usePlausibleTracker';
|
||||
|
||||
const DEFAULT_STICKINESS = 'default';
|
||||
export const useDefaultProjectStickiness = (projectId?: string) => {
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const key = `defaultStickiness.${projectId}`;
|
||||
const { projectScopedStickiness } = uiConfig.flags;
|
||||
const projectStickiness = localStorage.getItem(key);
|
||||
|
||||
const defaultStickiness =
|
||||
Boolean(projectScopedStickiness) &&
|
||||
projectStickiness != null &&
|
||||
projectId
|
||||
? projectStickiness
|
||||
: DEFAULT_STICKINESS;
|
||||
|
||||
const setDefaultProjectStickiness = (stickiness: string) => {
|
||||
if (
|
||||
Boolean(projectScopedStickiness) &&
|
||||
projectId &&
|
||||
stickiness !== ''
|
||||
) {
|
||||
localStorage.setItem(key, stickiness);
|
||||
trackEvent('project_stickiness_set');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
defaultStickiness,
|
||||
setDefaultProjectStickiness,
|
||||
};
|
||||
};
|
@ -21,6 +21,7 @@ export type CustomEvents =
|
||||
| 'unknown_ui_error'
|
||||
| 'export_import'
|
||||
| 'project_api_tokens'
|
||||
| 'project_stickiness_set'
|
||||
| 'notifications';
|
||||
|
||||
export const usePlausibleTracker = () => {
|
||||
|
@ -50,6 +50,7 @@ export interface IFlags {
|
||||
loginHistory?: boolean;
|
||||
bulkOperations?: boolean;
|
||||
projectScopedSegments?: boolean;
|
||||
projectScopedStickiness?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -69,7 +69,7 @@ test('createFeatureStrategy with parameters', () => {
|
||||
"groupId": "a",
|
||||
"rollout": "50",
|
||||
"s": "",
|
||||
"stickiness": "default",
|
||||
"stickiness": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
@ -40,7 +40,7 @@ const createFeatureStrategyParameterValue = (
|
||||
}
|
||||
|
||||
if (parameter.name === 'stickiness') {
|
||||
return 'default';
|
||||
return '';
|
||||
}
|
||||
|
||||
if (parameter.name === 'groupId') {
|
||||
|
@ -83,6 +83,7 @@ exports[`should create default config 1`] = `
|
||||
"notifications": false,
|
||||
"proPlanAutoCharge": false,
|
||||
"projectScopedSegments": false,
|
||||
"projectScopedStickiness": false,
|
||||
"projectStatusApi": false,
|
||||
"proxyReturnAllToggles": false,
|
||||
"responseTimeWithAppNameKillSwitch": false,
|
||||
@ -108,6 +109,7 @@ exports[`should create default config 1`] = `
|
||||
"notifications": false,
|
||||
"proPlanAutoCharge": false,
|
||||
"projectScopedSegments": false,
|
||||
"projectScopedStickiness": false,
|
||||
"projectStatusApi": false,
|
||||
"proxyReturnAllToggles": false,
|
||||
"responseTimeWithAppNameKillSwitch": false,
|
||||
|
@ -72,6 +72,10 @@ const flags = {
|
||||
process.env.PROJECT_SCOPED_SEGMENTS,
|
||||
false,
|
||||
),
|
||||
projectScopedStickiness: parseEnvVarBoolean(
|
||||
process.env.PROJECT_SCOPED_STICKINESS,
|
||||
false,
|
||||
),
|
||||
cleanClientApi: parseEnvVarBoolean(process.env.CLEAN_CLIENT_API, false),
|
||||
};
|
||||
|
||||
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
||||
projectStatusApi: true,
|
||||
showProjectApiAccess: true,
|
||||
projectScopedSegments: true,
|
||||
projectScopedStickiness: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
Loading…
Reference in New Issue
Block a user