1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-24 17:51:14 +02:00
This commit is contained in:
Thomas Heartman 2025-09-24 14:23:38 +02:00 committed by GitHub
commit 60c65a5596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 226 additions and 53 deletions

View File

@ -9,6 +9,7 @@ import {
getSegmentChangesThatWouldBeOverwritten, getSegmentChangesThatWouldBeOverwritten,
getStrategyChangesThatWouldBeOverwritten, getStrategyChangesThatWouldBeOverwritten,
} from './strategy-change-diff-calculation.js'; } from './strategy-change-diff-calculation.js';
import { constraintId } from 'constants/constraintId.js';
describe('Strategy change conflict detection', () => { describe('Strategy change conflict detection', () => {
const existingStrategy: IFeatureStrategy = { const existingStrategy: IFeatureStrategy = {
@ -175,6 +176,7 @@ describe('Strategy change conflict detection', () => {
operator: 'IN' as const, operator: 'IN' as const,
contextName: 'appName', contextName: 'appName',
caseInsensitive: false, caseInsensitive: false,
[constraintId]: 'id1',
}, },
], ],
variants: [ variants: [
@ -230,6 +232,7 @@ describe('Strategy change conflict detection', () => {
operator: 'IN' as const, operator: 'IN' as const,
contextName: 'appName', contextName: 'appName',
caseInsensitive: false, caseInsensitive: false,
[constraintId]: 'id2',
}, },
], ],
}; };
@ -249,6 +252,7 @@ describe('Strategy change conflict detection', () => {
inverted: false, inverted: false,
operator: 'IN' as const, operator: 'IN' as const,
values: ['blah'], values: ['blah'],
[constraintId]: 'id2',
}, },
], ],
}, },
@ -478,6 +482,7 @@ describe('Segment change conflict detection', () => {
operator: 'IN' as const, operator: 'IN' as const,
contextName: 'appName', contextName: 'appName',
caseInsensitive: false, caseInsensitive: false,
[constraintId]: 'id3',
}, },
], ],
}; };
@ -494,6 +499,7 @@ describe('Segment change conflict detection', () => {
operator: 'IN' as const, operator: 'IN' as const,
contextName: 'appName', contextName: 'appName',
caseInsensitive: false, caseInsensitive: false,
[constraintId]: 'id4',
}, },
], ],
}, },

View File

@ -9,7 +9,6 @@ import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsLis
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint'; import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint';
import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts'; import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts';
import { constraintId } from 'constants/constraintId.ts'; import { constraintId } from 'constants/constraintId.ts';
import { v4 as uuidv4 } from 'uuid';
export interface IEditableConstraintsListRef { export interface IEditableConstraintsListRef {
addConstraint?: (contextName: string) => void; addConstraint?: (contextName: string) => void;
} }
@ -44,7 +43,6 @@ export const EditableConstraintsList = forwardRef<
if (!constraints.every((constraint) => constraintId in constraint)) { if (!constraints.every((constraint) => constraintId in constraint)) {
setConstraints( setConstraints(
constraints.map((constraint) => ({ constraints.map((constraint) => ({
[constraintId]: uuidv4(),
...constraint, ...constraint,
})), })),
); );

View File

@ -1,7 +1,6 @@
import { createEmptyConstraint } from 'utils/createEmptyConstraint'; import { createEmptyConstraint } from 'utils/createEmptyConstraint';
import { fromIConstraint, toIConstraint } from './editable-constraint-type.ts'; import { fromIConstraint, toIConstraint } from './editable-constraint-type.ts';
import { constraintId } from 'constants/constraintId'; import { constraintId } from 'constants/constraintId';
import type { IConstraint } from 'interfaces/strategy.ts';
test('mapping to and from retains the constraint id', () => { test('mapping to and from retains the constraint id', () => {
const constraint = createEmptyConstraint('context'); const constraint = createEmptyConstraint('context');
@ -11,18 +10,6 @@ test('mapping to and from retains the constraint id', () => {
); );
}); });
test('mapping to an editable constraint adds a constraint id if there is none', () => {
const constraint: IConstraint = createEmptyConstraint('context');
delete constraint[constraintId];
const editableConstraint = fromIConstraint(constraint);
expect(editableConstraint[constraintId]).toBeDefined();
const iConstraint = toIConstraint(editableConstraint);
expect(iConstraint[constraintId]).toEqual(editableConstraint[constraintId]);
});
test('mapping from an empty constraint removes redundant value / values', () => { test('mapping from an empty constraint removes redundant value / values', () => {
const constraint = { ...createEmptyConstraint('context'), value: '' }; const constraint = { ...createEmptyConstraint('context'), value: '' };
expect(constraint).toHaveProperty('value'); expect(constraint).toHaveProperty('value');

View File

@ -1,4 +1,3 @@
import { constraintId } from 'constants/constraintId';
import { import {
type DateOperator, type DateOperator,
isDateOperator, isDateOperator,
@ -11,7 +10,6 @@ import {
isSemVerOperator, isSemVerOperator,
} from 'constants/operators'; } from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { v4 as uuidv4 } from 'uuid';
type EditableConstraintBase = Omit< type EditableConstraintBase = Omit<
IConstraint, IConstraint,
@ -74,14 +72,12 @@ export const fromIConstraint = (
const { value, values, operator, ...rest } = constraint; const { value, values, operator, ...rest } = constraint;
if (isSingleValueOperator(operator)) { if (isSingleValueOperator(operator)) {
return { return {
[constraintId]: uuidv4(),
...rest, ...rest,
operator, operator,
value: value ?? '', value: value ?? '',
}; };
} else { } else {
return { return {
[constraintId]: uuidv4(),
...rest, ...rest,
operator, operator,
values: new Set(values), values: new Set(values),

View File

@ -13,6 +13,7 @@ import { vi } from 'vitest';
import { testServerRoute, testServerSetup } from 'utils/testServer'; import { testServerRoute, testServerSetup } from 'utils/testServer';
import type { ContextFieldSchema } from 'openapi'; import type { ContextFieldSchema } from 'openapi';
import { NUM_EQ } from '@server/util/constants'; import { NUM_EQ } from '@server/util/constants';
import { constraintId } from 'constants/constraintId.js';
const server = testServerSetup(); const server = testServerSetup();
@ -25,6 +26,7 @@ test('calls onUpdate with new state', async () => {
contextName: 'context-field', contextName: 'context-field',
operator: NOT_IN, operator: NOT_IN,
values: ['A', 'B'], values: ['A', 'B'],
[constraintId]: 'constraint id',
}; };
const onUpdate = vi.fn(); const onUpdate = vi.fn();
@ -71,6 +73,7 @@ describe('validators', () => {
contextName: 'context-field', contextName: 'context-field',
operator: operator, operator: operator,
value: '', value: '',
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -94,6 +97,7 @@ describe('validators', () => {
contextName: 'context-field', contextName: 'context-field',
operator: operator, operator: operator,
value: '', value: '',
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -117,6 +121,7 @@ describe('validators', () => {
contextName: 'context-field', contextName: 'context-field',
operator: operator, operator: operator,
value: '', value: '',
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -140,6 +145,7 @@ describe('validators', () => {
contextName: 'context-field', contextName: 'context-field',
operator: operator, operator: operator,
values: [], values: [],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -162,6 +168,7 @@ describe('validators', () => {
contextName: 'context-field', contextName: 'context-field',
operator: operator, operator: operator,
values: ['a', 'b'], values: ['a', 'b'],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -189,6 +196,7 @@ describe('legal values', () => {
contextName: definition.name, contextName: definition.name,
operator: IN, operator: IN,
values: [], values: [],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -206,6 +214,7 @@ describe('legal values', () => {
contextName: definition.name, contextName: definition.name,
operator: IN, operator: IN,
values: [], values: [],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -231,6 +240,7 @@ describe('legal values', () => {
contextName: 'field-with-no-legal-values', contextName: 'field-with-no-legal-values',
operator: IN, operator: IN,
values: [], values: [],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -244,6 +254,7 @@ describe('legal values', () => {
contextName: definition.name, contextName: definition.name,
operator: IN, operator: IN,
values: ['A', 'B'], values: ['A', 'B'],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>
@ -260,6 +271,7 @@ describe('legal values', () => {
contextName: definition.name, contextName: definition.name,
operator: NUM_EQ, operator: NUM_EQ,
values: [], values: [],
[constraintId]: 'constraint id',
}; };
const { result } = renderHook(() => const { result } = renderHook(() =>

View File

@ -6,6 +6,7 @@ import { renderHook, act } from '@testing-library/react';
import type { IConstraint } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { IN, STR_CONTAINS } from 'constants/operators'; import { IN, STR_CONTAINS } from 'constants/operators';
import type { Operator } from 'constants/operators'; import type { Operator } from 'constants/operators';
import { constraintId } from 'constants/constraintId.ts';
const createTestConstraint = ( const createTestConstraint = (
contextName: string, contextName: string,
@ -15,6 +16,7 @@ const createTestConstraint = (
contextName, contextName,
operator, operator,
values, values,
[constraintId]: 'constraint id',
}); });
describe('areConstraintsEqual', () => { describe('areConstraintsEqual', () => {
@ -24,6 +26,7 @@ describe('areConstraintsEqual', () => {
operator: IN, operator: IN,
values: ['user1', 'user2'], values: ['user1', 'user2'],
inverted: false, inverted: false,
[constraintId]: 'constraint id',
}; };
const constraint2: IConstraint = { const constraint2: IConstraint = {
@ -31,6 +34,7 @@ describe('areConstraintsEqual', () => {
operator: IN, operator: IN,
values: ['user1', 'user2'], values: ['user1', 'user2'],
inverted: false, inverted: false,
[constraintId]: 'constraint id',
}; };
expect(areConstraintsEqual(constraint1, constraint2)).toBe(true); expect(areConstraintsEqual(constraint1, constraint2)).toBe(true);
@ -41,12 +45,14 @@ describe('areConstraintsEqual', () => {
contextName: 'userId', contextName: 'userId',
operator: IN, operator: IN,
values: ['user1', 'user2', 'user3'], values: ['user1', 'user2', 'user3'],
[constraintId]: 'constraint id',
}; };
const constraint2: IConstraint = { const constraint2: IConstraint = {
contextName: 'userId', contextName: 'userId',
operator: IN, operator: IN,
values: ['user2', 'user3', 'user1'], values: ['user2', 'user3', 'user1'],
[constraintId]: 'constraint id',
}; };
expect(areConstraintsEqual(constraint1, constraint2)).toBe(true); expect(areConstraintsEqual(constraint1, constraint2)).toBe(true);
@ -57,12 +63,14 @@ describe('areConstraintsEqual', () => {
contextName: 'userId', contextName: 'userId',
operator: IN, operator: IN,
values: ['user1', 'user2'], values: ['user1', 'user2'],
[constraintId]: 'constraint id',
}; };
const constraint2: IConstraint = { const constraint2: IConstraint = {
contextName: 'userId', contextName: 'userId',
operator: IN, operator: IN,
values: ['user1', 'user3'], values: ['user1', 'user3'],
[constraintId]: 'constraint id',
}; };
expect(areConstraintsEqual(constraint1, constraint2)).toBe(false); expect(areConstraintsEqual(constraint1, constraint2)).toBe(false);
@ -73,12 +81,14 @@ describe('areConstraintsEqual', () => {
contextName: 'userId', contextName: 'userId',
operator: IN, operator: IN,
values: ['user1'], values: ['user1'],
[constraintId]: 'constraint id',
}; };
const constraint2: IConstraint = { const constraint2: IConstraint = {
contextName: 'userId', contextName: 'userId',
operator: STR_CONTAINS, operator: STR_CONTAINS,
values: ['user1'], values: ['user1'],
[constraintId]: 'constraint id',
}; };
expect(areConstraintsEqual(constraint1, constraint2)).toBe(false); expect(areConstraintsEqual(constraint1, constraint2)).toBe(false);

View File

@ -11,6 +11,8 @@ import {
} from '@server/types/permissions'; } from '@server/types/permissions';
import { StrategyItem } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem'; import { StrategyItem } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem';
import type { IFeatureStrategy } from 'interfaces/strategy'; import type { IFeatureStrategy } from 'interfaces/strategy';
import { constraintId } from 'constants/constraintId';
import { v4 as uuidv4 } from 'uuid';
interface ProjectEnvironmentDefaultStrategyProps { interface ProjectEnvironmentDefaultStrategyProps {
environment: ProjectEnvironmentType; environment: ProjectEnvironmentType;
@ -55,7 +57,11 @@ export const ProjectEnvironmentDefaultStrategy = ({
return { return {
...baseDefaultStrategy, ...baseDefaultStrategy,
disabled: false, disabled: false,
constraints: baseDefaultStrategy.constraints ?? [], constraints:
baseDefaultStrategy.constraints?.map((constraint) => ({
...constraint,
[constraintId]: uuidv4(),
})) ?? [],
title: baseDefaultStrategy.title ?? '', title: baseDefaultStrategy.title ?? '',
parameters: baseDefaultStrategy.parameters ?? {}, parameters: baseDefaultStrategy.parameters ?? {},
}; };

View File

@ -1,4 +1,4 @@
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { SegmentFormStepOne } from './SegmentFormStepOne.tsx'; import { SegmentFormStepOne } from './SegmentFormStepOne.tsx';
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx'; import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
import type React from 'react'; import type React from 'react';
@ -14,7 +14,7 @@ interface ISegmentProps {
name: string; name: string;
description: string; description: string;
project?: string; project?: string;
constraints: IConstraintWithId[]; constraints: IConstraint[];
setName: React.Dispatch<React.SetStateAction<string>>; setName: React.Dispatch<React.SetStateAction<string>>;
setDescription: React.Dispatch<React.SetStateAction<string>>; setDescription: React.Dispatch<React.SetStateAction<string>>;
setProject: React.Dispatch<React.SetStateAction<string | undefined>>; setProject: React.Dispatch<React.SetStateAction<string | undefined>>;

View File

@ -8,7 +8,7 @@ import {
CREATE_SEGMENT, CREATE_SEGMENT,
UPDATE_PROJECT_SEGMENT, UPDATE_PROJECT_SEGMENT,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import type { IConstraintWithId } from 'interfaces/strategy.ts'; import type { IConstraint } from 'interfaces/strategy.ts';
const server = testServerSetup(); const server = testServerSetup();
@ -26,7 +26,7 @@ const setupRoutes = () => {
const defaultProps = { const defaultProps = {
project: undefined, project: undefined,
constraints: [] as IConstraintWithId[], constraints: [] as IConstraint[],
setConstraints: vi.fn(), setConstraints: vi.fn(),
setCurrentStep: vi.fn(), setCurrentStep: vi.fn(),
mode: 'create' as const, mode: 'create' as const,

View File

@ -13,7 +13,7 @@ import {
UPDATE_SEGMENT, UPDATE_SEGMENT,
} from 'component/providers/AccessProvider/permissions'; } from 'component/providers/AccessProvider/permissions';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList'; import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
import type { IEditableConstraintsListRef } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList'; import type { IEditableConstraintsListRef } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
@ -33,7 +33,7 @@ import { GO_BACK } from 'constants/navigate';
interface ISegmentFormPartTwoProps { interface ISegmentFormPartTwoProps {
project?: string; project?: string;
constraints: IConstraintWithId[]; constraints: IConstraint[];
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>; setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>; setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
mode: SegmentFormMode; mode: SegmentFormMode;

View File

@ -1,8 +1,6 @@
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation'; import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
import { v4 as uuidv4 } from 'uuid';
import { constraintId } from 'constants/constraintId';
export const useSegmentForm = ( export const useSegmentForm = (
initialName = '', initialName = '',
@ -14,10 +12,9 @@ export const useSegmentForm = (
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
const [project, setProject] = useState<string | undefined>(initialProject); const [project, setProject] = useState<string | undefined>(initialProject);
const initialConstraintsWithId = initialConstraints.map((constraint) => ({ const initialConstraintsWithId = initialConstraints.map((constraint) => ({
[constraintId]: uuidv4(),
...constraint, ...constraint,
})); }));
const [constraints, setConstraints] = useState<IConstraintWithId[]>( const [constraints, setConstraints] = useState<IConstraint[]>(
initialConstraintsWithId, initialConstraintsWithId,
); );
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});

View File

@ -1,7 +1,13 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js'; import handleErrorResponses from '../httpErrorResponseHandler.js';
import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; import type {
ChangeRequestType,
IChangeRequestFeature,
IFeatureChange,
} from 'component/changeRequest/changeRequest.types';
import { useMemo } from 'react';
import { addConstraintIdsToFeatureChange } from 'utils/addConstraintIdsToFeatureChange.js';
export const useChangeRequest = (projectId: string, id: string) => { export const useChangeRequest = (projectId: string, id: string) => {
const { data, error, mutate } = useSWR<ChangeRequestType>( const { data, error, mutate } = useSWR<ChangeRequestType>(
@ -10,8 +16,32 @@ export const useChangeRequest = (projectId: string, id: string) => {
{ refreshInterval: 15000 }, { refreshInterval: 15000 },
); );
const { features, ...dataProps } = data || {};
const featuresWithConstraintIds: IChangeRequestFeature[] | undefined =
useMemo(() => {
return (
features?.map((feature) => {
const changes: IFeatureChange[] = feature.changes.map(
addConstraintIdsToFeatureChange,
);
return {
...feature,
changes,
};
}) ?? []
);
}, [JSON.stringify(features)]);
const mappedData = data
? {
...dataProps,
features: featuresWithConstraintIds,
}
: data;
return { return {
data, data: mappedData,
loading: !error && !data, loading: !error && !data,
refetchChangeRequest: () => mutate(), refetchChangeRequest: () => mutate(),
error, error,

View File

@ -3,6 +3,7 @@ import type { IConstraint } from 'interfaces/strategy'; // Assuming you have you
import type { FC } from 'react'; import type { FC } from 'react';
import { useConstraintsValidation } from './useConstraintsValidation.ts'; import { useConstraintsValidation } from './useConstraintsValidation.ts';
import { testServerRoute, testServerSetup } from 'utils/testServer'; import { testServerRoute, testServerSetup } from 'utils/testServer';
import { constraintId } from 'constants/constraintId.ts';
const server = testServerSetup(); const server = testServerSetup();
@ -26,12 +27,14 @@ test('should display Valid when constraints are valid', async () => {
values: ['test'], values: ['test'],
operator: 'IN', operator: 'IN',
contextName: 'irrelevant', contextName: 'irrelevant',
[constraintId]: 'constraint id',
}, },
{ {
value: 'test', value: 'test',
values: ['test'], values: ['test'],
operator: 'IN', operator: 'IN',
contextName: 'irrelevant', contextName: 'irrelevant',
[constraintId]: 'constraint id 2',
}, },
]; ];
@ -42,12 +45,19 @@ test('should display Valid when constraints are valid', async () => {
test('should display Invalid when constraints are invalid', async () => { test('should display Invalid when constraints are invalid', async () => {
const emptyValueAndValues: IConstraint[] = [ const emptyValueAndValues: IConstraint[] = [
{ value: '', values: [], operator: 'IN', contextName: 'irrelevant' },
{ {
value: '', value: '',
values: [], values: [],
operator: 'IN', operator: 'IN',
contextName: 'irrelevant', contextName: 'irrelevant',
[constraintId]: 'constraint id 3',
},
{
value: '',
values: [],
operator: 'IN',
contextName: 'irrelevant',
[constraintId]: 'constraint id 4',
}, },
]; ];

View File

@ -1,9 +1,12 @@
import useSWR, { type SWRConfiguration } from 'swr'; import useSWR, { type SWRConfiguration } from 'swr';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { emptyFeature } from './emptyFeature.ts'; import { emptyFeature } from './emptyFeature.ts';
import handleErrorResponses from '../httpErrorResponseHandler.ts'; import handleErrorResponses from '../httpErrorResponseHandler.ts';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import type { IFeatureToggle } from 'interfaces/featureToggle'; import type { IFeatureToggle } from 'interfaces/featureToggle';
import { constraintId } from 'constants/constraintId.ts';
import { v4 as uuidv4 } from 'uuid';
import type { IFeatureStrategy } from 'interfaces/strategy.ts';
export interface IUseFeatureOutput { export interface IUseFeatureOutput {
feature: IFeatureToggle; feature: IFeatureToggle;
@ -35,8 +38,12 @@ export const useFeature = (
mutate().catch(console.warn); mutate().catch(console.warn);
}, [mutate]); }, [mutate]);
const feature = useMemo(enrichConstraintsWithIds(data), [
JSON.stringify(data?.body),
]);
return { return {
feature: data?.body || emptyFeature, feature,
refetchFeature, refetchFeature,
loading: !error && !data, loading: !error && !data,
status: data?.status, status: data?.status,
@ -63,6 +70,42 @@ export const featureFetcher = async (
}; };
}; };
export const enrichConstraintsWithIds =
(data?: IFeatureResponse) => (): IFeatureToggle => {
if (!data?.body) {
return emptyFeature;
}
const { strategies, environments, ...rest } = data.body;
const addConstraintIds = (strategy: IFeatureStrategy) => {
const { constraints, ...strategyRest } = strategy;
return {
...strategyRest,
constraints: constraints?.map((constraint) => ({
...constraint,
[constraintId]: uuidv4(),
})),
};
};
const strategiesWithConstraintIds = strategies?.map(addConstraintIds);
const environmentsWithStrategyIds = environments?.map((environment) => {
const { strategies, ...environmentRest } = environment;
return {
...environmentRest,
strategies: strategies?.map(addConstraintIds),
};
});
return {
...rest,
strategies: strategiesWithConstraintIds,
environments: environmentsWithStrategyIds,
};
};
export const formatFeatureApiPath = ( export const formatFeatureApiPath = (
projectId: string, projectId: string,
featureId: string, featureId: string,

View File

@ -1,12 +1,12 @@
import useSWRImmutable from 'swr/immutable'; import useSWRImmutable from 'swr/immutable';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { emptyFeature } from './emptyFeature.js';
import { import {
type IUseFeatureOutput, type IUseFeatureOutput,
type IFeatureResponse, type IFeatureResponse,
featureFetcher, featureFetcher,
formatFeatureApiPath, formatFeatureApiPath,
useFeature, useFeature,
enrichConstraintsWithIds,
} from 'hooks/api/getters/useFeature/useFeature'; } from 'hooks/api/getters/useFeature/useFeature';
// useFeatureImmutable is like useFeature, except it won't refetch data on // useFeatureImmutable is like useFeature, except it won't refetch data on
@ -29,8 +29,12 @@ export const useFeatureImmutable = (
await refetchFeature(); await refetchFeature();
}, [mutate, refetchFeature]); }, [mutate, refetchFeature]);
const feature = useMemo(enrichConstraintsWithIds(data), [
JSON.stringify(data?.body),
]);
return { return {
feature: data?.body || emptyFeature, feature,
refetchFeature: refetch, refetchFeature: refetch,
loading: !error && !data, loading: !error && !data,
status: data?.status, status: data?.status,

View File

@ -1,7 +1,12 @@
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js'; import handleErrorResponses from '../httpErrorResponseHandler.js';
import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; import type {
ChangeRequestType,
IFeatureChange,
} from 'component/changeRequest/changeRequest.types';
import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR.js'; import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR.js';
import { useMemo } from 'react';
import { addConstraintIdsToFeatureChange } from 'utils/addConstraintIdsToFeatureChange.js';
const fetcher = (path: string) => { const fetcher = (path: string) => {
return fetch(path) return fetch(path)
@ -16,8 +21,29 @@ export const usePendingChangeRequests = (project: string) => {
fetcher, fetcher,
); );
const mappedData: typeof data = useMemo(
() =>
data?.map((changeRequest) => {
const { features, ...rest } = changeRequest || {};
const featuresWithConstraintIds =
features?.map((feature) => {
const changes: IFeatureChange[] = feature.changes.map(
addConstraintIdsToFeatureChange,
);
return {
...feature,
changes,
};
}) ?? [];
return { ...rest, features: featuresWithConstraintIds };
}),
[JSON.stringify(data)],
);
return { return {
data, mappedData,
loading: !error && !data, loading: !error && !data,
refetch: mutate, refetch: mutate,
error, error,

View File

@ -1,5 +1,5 @@
import useSWR, { type SWRConfiguration } from 'swr'; import useSWR, { type SWRConfiguration } from 'swr';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { getProjectOverviewFetcher } from './getProjectOverviewFetcher.js'; import { getProjectOverviewFetcher } from './getProjectOverviewFetcher.js';
import type { ProjectOverviewSchema } from 'openapi'; import type { ProjectOverviewSchema } from 'openapi';
@ -41,8 +41,26 @@ const useProjectOverview = (id: string, options: SWRConfiguration = {}) => {
mutate(); mutate();
}, [mutate]); }, [mutate]);
const overriddenData = useMemo(() => {
if (!data) return undefined;
return {
...data,
environments: data.environments?.map((env) => {
return env.defaultStrategy
? {
...env,
defaultStrategy: {
...env.defaultStrategy,
title: 'custom title override',
},
}
: env;
}),
};
}, [data]);
return { return {
project: data || fallbackProject, project: overriddenData || fallbackProject,
loading: !error && !data, loading: !error && !data,
error, error,
refetch, refetch,

View File

@ -66,10 +66,6 @@ export interface IConstraint {
caseInsensitive?: boolean; caseInsensitive?: boolean;
operator: Operator; operator: Operator;
contextName: string; contextName: string;
[constraintId]?: string;
}
export interface IConstraintWithId extends IConstraint {
[constraintId]: string; [constraintId]: string;
} }

View File

@ -0,0 +1,31 @@
import type {
IFeatureChange,
IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { constraintId } from 'constants/constraintId';
import { v4 as uuidv4 } from 'uuid';
const isAddStrategyChange = (
change: IFeatureChange,
): change is IChangeRequestAddStrategy => change.action === 'addStrategy';
const isUpdateStrategyChange = (
change: IFeatureChange,
): change is IChangeRequestUpdateStrategy => change.action === 'updateStrategy';
export const addConstraintIdsToFeatureChange = (change: IFeatureChange) => {
if (isAddStrategyChange(change) || isUpdateStrategyChange(change)) {
const { constraints, ...rest } = change.payload;
return {
...change,
payload: {
...rest,
constraints: constraints.map((constraint) => ({
...constraint,
[constraintId]: uuidv4(),
})),
},
} as IFeatureChange;
}
return change;
};

View File

@ -9,6 +9,7 @@ test('keys are ordered in the expected order', () => {
operator: 'STR_CONTAINS', operator: 'STR_CONTAINS',
contextName: 'context', contextName: 'context',
caseInsensitive: true, caseInsensitive: true,
[constraintId]: 'constraint-id',
}; };
const output = serializeConstraint(input); const output = serializeConstraint(input);
@ -30,6 +31,7 @@ test('only value OR values is present, not both', () => {
operator: 'IN', operator: 'IN',
contextName: 'context', contextName: 'context',
caseInsensitive: true, caseInsensitive: true,
[constraintId]: 'constraint-id',
}; };
const noValue = serializeConstraint(input); const noValue = serializeConstraint(input);

View File

@ -1,6 +1,9 @@
import type { constraintId } from 'constants/constraintId';
import { isSingleValueOperator } from 'constants/operators'; import { isSingleValueOperator } from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
type SerializedConstraint = Omit<IConstraint, typeof constraintId>;
export const serializeConstraint = ({ export const serializeConstraint = ({
value, value,
values, values,
@ -8,10 +11,10 @@ export const serializeConstraint = ({
operator, operator,
contextName, contextName,
caseInsensitive, caseInsensitive,
}: IConstraint): IConstraint => { }: IConstraint): SerializedConstraint => {
const makeConstraint = ( const makeConstraint = (
valueProp: { value: string } | { values: string[] }, valueProp: { value: string } | { values: string[] },
): IConstraint => { ): SerializedConstraint => {
return { return {
contextName, contextName,
operator, operator,

View File

@ -1,12 +1,10 @@
import { constraintId } from 'constants/constraintId'; import { constraintId } from 'constants/constraintId';
import { isDateOperator } from 'constants/operators'; import { isDateOperator } from 'constants/operators';
import type { IConstraintWithId } from 'interfaces/strategy'; import type { IConstraint } from 'interfaces/strategy';
import { operatorsForContext } from 'utils/operatorsForContext'; import { operatorsForContext } from 'utils/operatorsForContext';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export const createEmptyConstraint = ( export const createEmptyConstraint = (contextName: string): IConstraint => {
contextName: string,
): IConstraintWithId => {
const operator = operatorsForContext(contextName)[0]; const operator = operatorsForContext(contextName)[0];
const value = isDateOperator(operator) ? new Date().toISOString() : ''; const value = isDateOperator(operator) ? new Date().toISOString() : '';