1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-23 00:16:25 +01:00

feat: adds CR to variants per env UI (#2989)

https://linear.app/unleash/issue/2-585/add-cr-to-variants-per-environment-ui

Adds CR to the variants per environment UI. This is basically the point
where we have CRs integrated but can e.g. only update the weight once
per CR. Adapting the UI to better fit CR logic will come next.


![image](https://user-images.githubusercontent.com/14320932/214563512-664a432f-f2eb-49f7-9721-cbd6785a9320.png)
This commit is contained in:
Nuno Góis 2023-01-25 14:10:35 +00:00 committed by GitHub
parent 247f751fea
commit 4d1a004b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 220 additions and 56 deletions

View File

@ -54,7 +54,7 @@ export const ChangeRequestDialogue: FC<IChangeRequestDialogueProps> = ({
show={ show={
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
Change requests feature is enabled for {environment}. Change requests feature is enabled for {environment}.
Your changes needs to be approved before they will be Your changes need to be approved before they will be
live. All the changes you do now will be added into a live. All the changes you do now will be added into a
draft that you can submit for review. draft that you can submit for review.
</Alert> </Alert>

View File

@ -28,6 +28,9 @@ import { CloudCircle } from '@mui/icons-material';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { WeightType } from 'constants/variantTypes'; import { WeightType } from 'constants/variantTypes';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
const StyledFormSubtitle = styled('div')(({ theme }) => ({ const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -99,6 +102,10 @@ const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
marginRight: theme.spacing(10), marginRight: theme.spacing(10),
})); }));
const StyledCRAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({ const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(4), marginTop: theme.spacing(4),
})); }));
@ -147,6 +154,11 @@ interface IEnvironmentVariantModalProps {
variants: IFeatureVariant[], variants: IFeatureVariant[],
newVariants: IFeatureVariant[] newVariants: IFeatureVariant[]
) => { patch: Operation[]; error?: string }; ) => { patch: Operation[]; error?: string };
getCrPayload: (variants: IFeatureVariant[]) => {
feature: string;
action: 'patchVariant';
payload: { variants: IFeatureVariant[] };
};
onConfirm: (updatedVariants: IFeatureVariant[]) => void; onConfirm: (updatedVariants: IFeatureVariant[]) => void;
} }
@ -156,6 +168,7 @@ export const EnvironmentVariantModal = ({
open, open,
setOpen, setOpen,
getApiPayload, getApiPayload,
getCrPayload,
onConfirm, onConfirm,
}: IEnvironmentVariantModalProps) => { }: IEnvironmentVariantModalProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -163,6 +176,11 @@ export const EnvironmentVariantModal = ({
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { data } = usePendingChangeRequests(projectId);
const { changeRequestInReviewOrApproved, alert } =
useChangeRequestInReviewWarning(data);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [customPercentage, setCustomPercentage] = useState(false); const [customPercentage, setCustomPercentage] = useState(false);
const [percentage, setPercentage] = useState(''); const [percentage, setPercentage] = useState('');
@ -237,6 +255,7 @@ export const EnvironmentVariantModal = ({
}; };
const apiPayload = getApiPayload(variants, getUpdatedVariants()); const apiPayload = getApiPayload(variants, getUpdatedVariants());
const crPayload = getCrPayload(getUpdatedVariants());
useEffect(() => { useEffect(() => {
clearError(ErrorField.PERCENTAGE); clearError(ErrorField.PERCENTAGE);
@ -255,7 +274,17 @@ export const EnvironmentVariantModal = ({
onConfirm(getUpdatedVariants()); onConfirm(getUpdatedVariants());
}; };
const formatApiCode = () => `curl --location --request PATCH '${ const formatApiCode = () =>
isChangeRequest
? `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/environments/${
environment?.name
}/change-requests' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(crPayload, undefined, 2)}'`
: `curl --location --request PATCH '${
uiConfig.unleashUrl uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/environments/${ }/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name environment?.name
@ -324,6 +353,15 @@ export const EnvironmentVariantModal = ({
} }
}; };
const hasChangeRequestInReviewForEnvironment =
changeRequestInReviewOrApproved(environment?.name || '');
const changeRequestButtonText = hasChangeRequestInReviewForEnvironment
? 'Add to existing change request'
: 'Add change to draft';
const isChangeRequest = isChangeRequestConfigured(environment?.name || '');
return ( return (
<SidebarModal <SidebarModal
open={open} open={open}
@ -349,6 +387,29 @@ export const EnvironmentVariantModal = ({
</StyledFormSubtitle> </StyledFormSubtitle>
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
<div> <div>
<ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment}
show={alert}
elseShow={
<ConditionallyRender
condition={Boolean(isChangeRequest)}
show={
<StyledCRAlert severity="info">
<strong>Change requests</strong> are
enabled
{environment
? ` for ${environment.name}`
: ''}
. Your changes need to be approved
before they will be live. All the
changes you do now will be added
into a draft that you can submit for
review.
</StyledCRAlert>
}
/>
}
/>
<StyledInputDescription> <StyledInputDescription>
Variant name Variant name
</StyledInputDescription> </StyledInputDescription>
@ -486,7 +547,11 @@ export const EnvironmentVariantModal = ({
color="primary" color="primary"
disabled={!isValid} disabled={!isValid}
> >
{editing ? 'Save' : 'Add'} variant {isChangeRequest
? changeRequestButtonText
: editing
? 'Save variant'
: 'Add variant'}
</Button> </Button>
<StyledCancelButton <StyledCancelButton
onClick={() => { onClick={() => {

View File

@ -10,8 +10,11 @@ import { updateWeight } from 'component/common/util';
import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; import {
import { useState } from 'react'; IFeatureEnvironmentWithCrEnabled,
IFeatureVariant,
} from 'interfaces/featureToggle';
import { useMemo, useState } from 'react';
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal'; import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard'; import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog'; import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
@ -20,6 +23,10 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom'; import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom';
import { PushVariantsButton } from './PushVariantsButton/PushVariantsButton'; import { PushVariantsButton } from './PushVariantsButton/PushVariantsButton';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
const StyledAlert = styled(Alert)(({ theme }) => ({ const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4), marginBottom: theme.spacing(4),
@ -34,6 +41,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({
})); }));
export const FeatureEnvironmentVariants = () => { export const FeatureEnvironmentVariants = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const theme = useTheme(); const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
@ -46,14 +54,29 @@ export const FeatureEnvironmentVariants = () => {
); );
const { patchFeatureEnvironmentVariants, overrideVariantsInEnvironments } = const { patchFeatureEnvironmentVariants, overrideVariantsInEnvironments } =
useFeatureApi(); useFeatureApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [selectedEnvironment, setSelectedEnvironment] = const [selectedEnvironment, setSelectedEnvironment] =
useState<IFeatureEnvironment>(); useState<IFeatureEnvironmentWithCrEnabled>();
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>(); const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo(
() =>
feature?.environments?.map(environment => ({
...environment,
crEnabled:
uiConfig.flags.crOnVariants &&
isChangeRequestConfigured(environment.name),
})) || [],
[feature.environments, uiConfig.flags.crOnVariants]
);
const createPatch = ( const createPatch = (
variants: IFeatureVariant[], variants: IFeatureVariant[],
newVariants: IFeatureVariant[] newVariants: IFeatureVariant[]
@ -72,10 +95,24 @@ export const FeatureEnvironmentVariants = () => {
return { patch: createPatch(variants, updatedNewVariants) }; return { patch: createPatch(variants, updatedNewVariants) };
}; };
const getCrPayload = (variants: IFeatureVariant[]) => ({
feature: featureId,
action: 'patchVariant' as const,
payload: { variants },
});
const updateVariants = async ( const updateVariants = async (
environment: IFeatureEnvironment, environment: IFeatureEnvironmentWithCrEnabled,
variants: IFeatureVariant[] variants: IFeatureVariant[]
) => { ) => {
if (environment.crEnabled) {
await addChange(
projectId,
environment.name,
getCrPayload(variants)
);
refetchChangeRequests();
} else {
const environmentVariants = environment.variants ?? []; const environmentVariants = environment.variants ?? [];
const { patch } = getApiPayload(environmentVariants, variants); const { patch } = getApiPayload(environmentVariants, variants);
@ -87,23 +124,62 @@ export const FeatureEnvironmentVariants = () => {
environment.name, environment.name,
patch patch
); );
}
refetchFeature(); refetchFeature();
}; };
const pushToEnvironments = async ( const pushToEnvironments = async (
variants: IFeatureVariant[], variants: IFeatureVariant[],
selected: string[] selected: IFeatureEnvironmentWithCrEnabled[]
) => { ) => {
try { try {
const selectedWithCrEnabled = selected.filter(
({ crEnabled }) => crEnabled
);
const selectedWithCrDisabled = selected.filter(
({ crEnabled }) => !crEnabled
);
if (selectedWithCrEnabled.length) {
await Promise.all(
selectedWithCrEnabled.map(environment =>
addChange(
projectId,
environment.name,
getCrPayload(variants)
)
)
);
}
if (selectedWithCrDisabled.length) {
await overrideVariantsInEnvironments( await overrideVariantsInEnvironments(
projectId, projectId,
featureId, featureId,
variants, variants,
selected selectedWithCrDisabled.map(({ name }) => name)
); );
}
refetchChangeRequests();
refetchFeature(); refetchFeature();
const pushTitle = selectedWithCrDisabled.length
? `Variants pushed to ${
selectedWithCrDisabled.length === 1
? selectedWithCrDisabled[0].name
: `${selectedWithCrDisabled.length} environments`
}`
: '';
const draftTitle = selectedWithCrEnabled.length
? `Variants push added to ${
selectedWithCrEnabled.length === 1
? `${selectedWithCrEnabled[0].name} draft`
: `${selectedWithCrEnabled.length} drafts`
}`
: '';
const title = `${pushTitle}${
pushTitle && draftTitle ? '. ' : ''
}${draftTitle}`;
setToastData({ setToastData({
title: `Variants pushed successfully`, title,
type: 'success', type: 'success',
}); });
} catch (error: unknown) { } catch (error: unknown) {
@ -111,14 +187,14 @@ export const FeatureEnvironmentVariants = () => {
} }
}; };
const addVariant = (environment: IFeatureEnvironment) => { const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setSelectedVariant(undefined); setSelectedVariant(undefined);
setModalOpen(true); setModalOpen(true);
}; };
const editVariant = ( const editVariant = (
environment: IFeatureEnvironment, environment: IFeatureEnvironmentWithCrEnabled,
variant: IFeatureVariant variant: IFeatureVariant
) => { ) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
@ -127,7 +203,7 @@ export const FeatureEnvironmentVariants = () => {
}; };
const deleteVariant = ( const deleteVariant = (
environment: IFeatureEnvironment, environment: IFeatureEnvironmentWithCrEnabled,
variant: IFeatureVariant variant: IFeatureVariant
) => { ) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
@ -147,7 +223,9 @@ export const FeatureEnvironmentVariants = () => {
await updateVariants(selectedEnvironment, updatedVariants); await updateVariants(selectedEnvironment, updatedVariants);
setDeleteOpen(false); setDeleteOpen(false);
setToastData({ setToastData({
title: `Variant deleted successfully`, title: selectedEnvironment.crEnabled
? 'Variant deletion added to draft'
: 'Variant deleted successfully',
type: 'success', type: 'success',
}); });
} catch (error: unknown) { } catch (error: unknown) {
@ -162,7 +240,11 @@ export const FeatureEnvironmentVariants = () => {
await updateVariants(selectedEnvironment, updatedVariants); await updateVariants(selectedEnvironment, updatedVariants);
setModalOpen(false); setModalOpen(false);
setToastData({ setToastData({
title: `Variant ${ title: selectedEnvironment.crEnabled
? `Variant ${
selectedVariant ? 'changes' : ''
} added to draft`
: `Variant ${
selectedVariant ? 'updated' : 'added' selectedVariant ? 'updated' : 'added'
} successfully`, } successfully`,
type: 'success', type: 'success',
@ -174,14 +256,16 @@ export const FeatureEnvironmentVariants = () => {
}; };
const onCopyVariantsFrom = async ( const onCopyVariantsFrom = async (
fromEnvironment: IFeatureEnvironment, fromEnvironment: IFeatureEnvironmentWithCrEnabled,
toEnvironment: IFeatureEnvironment toEnvironment: IFeatureEnvironmentWithCrEnabled
) => { ) => {
try { try {
const variants = fromEnvironment.variants ?? []; const variants = fromEnvironment.variants ?? [];
await updateVariants(toEnvironment, variants); await updateVariants(toEnvironment, variants);
setToastData({ setToastData({
title: 'Variants copied successfully', title: toEnvironment.crEnabled
? 'Variants copy added to draft'
: 'Variants copied successfully',
type: 'success', type: 'success',
}); });
} catch (error: unknown) { } catch (error: unknown) {
@ -190,13 +274,15 @@ export const FeatureEnvironmentVariants = () => {
}; };
const onUpdateStickiness = async ( const onUpdateStickiness = async (
environment: IFeatureEnvironment, environment: IFeatureEnvironmentWithCrEnabled,
updatedVariants: IFeatureVariant[] updatedVariants: IFeatureVariant[]
) => { ) => {
try { try {
await updateVariants(environment, updatedVariants); await updateVariants(environment, updatedVariants);
setToastData({ setToastData({
title: 'Variant stickiness updated successfully', title: environment.crEnabled
? 'Variant stickiness update added to draft'
: 'Variant stickiness updated successfully',
type: 'success', type: 'success',
}); });
} catch (error: unknown) { } catch (error: unknown) {
@ -242,8 +328,8 @@ export const FeatureEnvironmentVariants = () => {
variants you should use the <code>getVariant()</code> method in variants you should use the <code>getVariant()</code> method in
the Client SDK. the Client SDK.
</StyledAlert> </StyledAlert>
{feature.environments.map(environment => { {environments.map(environment => {
const otherEnvsWithVariants = feature.environments.filter( const otherEnvsWithVariants = environments.filter(
({ name, variants }) => ({ name, variants }) =>
name !== environment.name && variants?.length name !== environment.name && variants?.length
); );
@ -266,7 +352,7 @@ export const FeatureEnvironmentVariants = () => {
<StyledButtonContainer> <StyledButtonContainer>
<PushVariantsButton <PushVariantsButton
current={environment.name} current={environment.name}
environments={feature.environments} environments={environments}
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
projectId={projectId} projectId={projectId}
onSubmit={selected => onSubmit={selected =>
@ -303,6 +389,7 @@ export const FeatureEnvironmentVariants = () => {
open={modalOpen} open={modalOpen}
setOpen={setModalOpen} setOpen={setModalOpen}
getApiPayload={getApiPayload} getApiPayload={getApiPayload}
getCrPayload={getCrPayload}
onConfirm={onVariantConfirm} onConfirm={onVariantConfirm}
/> />
<VariantDeleteDialog <VariantDeleteDialog

View File

@ -8,7 +8,7 @@ import {
styled, styled,
} from '@mui/material'; } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IFeatureEnvironment } from 'interfaces/featureToggle'; import { IFeatureEnvironmentWithCrEnabled } from 'interfaces/featureToggle';
import { useState } from 'react'; import { useState } from 'react';
import { useCheckProjectAccess } from 'hooks/useHasAccess'; import { useCheckProjectAccess } from 'hooks/useHasAccess';
@ -30,10 +30,10 @@ const StyledButton = styled(Button)(({ theme }) => ({
interface IPushVariantsButtonProps { interface IPushVariantsButtonProps {
current: string; current: string;
environments: IFeatureEnvironment[]; environments: IFeatureEnvironmentWithCrEnabled[];
permission: string; permission: string;
projectId: string; projectId: string;
onSubmit: (selected: string[]) => void; onSubmit: (selected: IFeatureEnvironmentWithCrEnabled[]) => void;
} }
export const PushVariantsButton = ({ export const PushVariantsButton = ({
@ -48,9 +48,9 @@ export const PushVariantsButton = ({
); );
const pushToOpen = Boolean(pushToAnchorEl); const pushToOpen = Boolean(pushToAnchorEl);
const [selectedEnvironments, setSelectedEnvironments] = useState<string[]>( const [selectedEnvironments, setSelectedEnvironments] = useState<
[] IFeatureEnvironmentWithCrEnabled[]
); >([]);
const hasAccess = useCheckProjectAccess(projectId); const hasAccess = useCheckProjectAccess(projectId);
const hasAccessTo = environments.reduce((acc, env) => { const hasAccessTo = environments.reduce((acc, env) => {
@ -58,16 +58,22 @@ export const PushVariantsButton = ({
return acc; return acc;
}, {} as Record<string, boolean>); }, {} as Record<string, boolean>);
const addSelectedEnvironment = (name: string) => { const addSelectedEnvironment = (
environment: IFeatureEnvironmentWithCrEnabled
) => {
setSelectedEnvironments(prevSelectedEnvironments => [ setSelectedEnvironments(prevSelectedEnvironments => [
...prevSelectedEnvironments, ...prevSelectedEnvironments,
name, environment,
]); ]);
}; };
const removeSelectedEnvironment = (name: string) => { const removeSelectedEnvironment = (
environment: IFeatureEnvironmentWithCrEnabled
) => {
setSelectedEnvironments(prevSelectedEnvironments => setSelectedEnvironments(prevSelectedEnvironments =>
prevSelectedEnvironments.filter(env => env !== name) prevSelectedEnvironments.filter(
({ name }) => name !== environment.name
)
); );
}; };
@ -121,16 +127,16 @@ export const PushVariantsButton = ({
onChange={event => { onChange={event => {
if (event.target.checked) { if (event.target.checked) {
addSelectedEnvironment( addSelectedEnvironment(
otherEnvironment.name otherEnvironment
); );
} else { } else {
removeSelectedEnvironment( removeSelectedEnvironment(
otherEnvironment.name otherEnvironment
); );
} }
}} }}
checked={selectedEnvironments.includes( checked={selectedEnvironments.includes(
otherEnvironment.name otherEnvironment
)} )}
value={otherEnvironment.name} value={otherEnvironment.name}
/> />

View File

@ -7,7 +7,8 @@ export interface IChangeSchema {
| 'updateEnabled' | 'updateEnabled'
| 'addStrategy' | 'addStrategy'
| 'updateStrategy' | 'updateStrategy'
| 'deleteStrategy'; | 'deleteStrategy'
| 'patchVariant';
payload: string | boolean | object | number; payload: string | boolean | object | number;
} }

View File

@ -44,6 +44,10 @@ export interface IFeatureEnvironment {
variants?: IFeatureVariant[]; variants?: IFeatureVariant[];
} }
export interface IFeatureEnvironmentWithCrEnabled extends IFeatureEnvironment {
crEnabled?: boolean;
}
export interface IFeatureVariant { export interface IFeatureVariant {
name: string; name: string;
stickiness: string; stickiness: string;

View File

@ -47,6 +47,7 @@ export interface IFlags {
featuresExportImport?: boolean; featuresExportImport?: boolean;
newProjectOverview?: boolean; newProjectOverview?: boolean;
caseInsensitiveInOperators?: boolean; caseInsensitiveInOperators?: boolean;
crOnVariants?: boolean;
} }
export interface IVersionInfo { export interface IVersionInfo {