mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
Feat segments project safeguards (#3359)
https://linear.app/unleash/issue/2-811/ability-to-move-project-specific-segments ![image](https://user-images.githubusercontent.com/14320932/226572047-19ed225c-13d5-4f2e-b10f-e17a7f7e6ae7.png) Provides some safeguards to project-specific segments when managing them on a global level (segments page): - You can always promote a segment to global; - You can't specify a project if the segment is currently in use in multiple projects; - You can't specify a different project if the segment is currently already in use in a project;
This commit is contained in:
parent
2147206b49
commit
27d48b3437
@ -13,6 +13,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||||
import { GO_BACK } from 'constants/navigate';
|
import { GO_BACK } from 'constants/navigate';
|
||||||
|
import { useStrategiesBySegment } from 'hooks/api/getters/useStrategiesBySegment/useStrategiesBySegment';
|
||||||
|
import { SegmentProjectAlert } from './SegmentProjectAlert';
|
||||||
|
|
||||||
interface ISegmentFormPartOneProps {
|
interface ISegmentFormPartOneProps {
|
||||||
name: string;
|
name: string;
|
||||||
@ -67,10 +69,24 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
clearErrors,
|
clearErrors,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
}) => {
|
}) => {
|
||||||
|
const segmentId = useOptionalPathParam('segmentId');
|
||||||
const projectId = useOptionalPathParam('projectId');
|
const projectId = useOptionalPathParam('projectId');
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects } = useProjects();
|
const { projects, loading: loadingProjects } = useProjects();
|
||||||
|
|
||||||
|
const { strategies, loading: loadingStrategies } =
|
||||||
|
useStrategiesBySegment(segmentId);
|
||||||
|
|
||||||
|
const projectsUsed = new Set<string>(
|
||||||
|
strategies.map(({ projectId }) => projectId!).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableProjects = projects.filter(
|
||||||
|
({ id }) =>
|
||||||
|
!projectsUsed.size ||
|
||||||
|
(projectsUsed.size === 1 && projectsUsed.has(id))
|
||||||
|
);
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = React.useState(
|
const [selectedProject, setSelectedProject] = React.useState(
|
||||||
projects.find(({ id }) => id === project) ?? null
|
projects.find(({ id }) => id === project) ?? null
|
||||||
@ -80,6 +96,8 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
setSelectedProject(projects.find(({ id }) => id === project) ?? null);
|
setSelectedProject(projects.find(({ id }) => id === project) ?? null);
|
||||||
}, [project, projects]);
|
}, [project, projects]);
|
||||||
|
|
||||||
|
const loading = loadingProjects && loadingStrategies;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledForm>
|
<StyledForm>
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
@ -110,7 +128,8 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={
|
condition={
|
||||||
Boolean(uiConfig.flags.projectScopedSegments) &&
|
Boolean(uiConfig.flags.projectScopedSegments) &&
|
||||||
!projectId
|
!projectId &&
|
||||||
|
!loading
|
||||||
}
|
}
|
||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
@ -123,11 +142,18 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
|
|||||||
onChange={(_, newValue) => {
|
onChange={(_, newValue) => {
|
||||||
setProject(newValue?.id);
|
setProject(newValue?.id);
|
||||||
}}
|
}}
|
||||||
options={projects}
|
options={availableProjects}
|
||||||
getOptionLabel={option => option.name}
|
getOptionLabel={option => option.name}
|
||||||
renderInput={params => (
|
renderInput={params => (
|
||||||
<TextField {...params} label="Project" />
|
<TextField {...params} label="Project" />
|
||||||
)}
|
)}
|
||||||
|
disabled={projectsUsed.size > 1}
|
||||||
|
/>
|
||||||
|
<SegmentProjectAlert
|
||||||
|
projects={projects}
|
||||||
|
strategies={strategies}
|
||||||
|
projectsUsed={Array.from(projectsUsed)}
|
||||||
|
availableProjects={availableProjects}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
95
frontend/src/component/segments/SegmentProjectAlert.tsx
Normal file
95
frontend/src/component/segments/SegmentProjectAlert.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Alert, styled } from '@mui/material';
|
||||||
|
import { formatEditStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
|
import { IProjectCard } from 'interfaces/project';
|
||||||
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { formatStrategyName } from 'utils/strategyNames';
|
||||||
|
|
||||||
|
const StyledUl = styled('ul')(({ theme }) => ({
|
||||||
|
listStyle: 'none',
|
||||||
|
paddingLeft: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface ISegmentProjectAlertProps {
|
||||||
|
projects: IProjectCard[];
|
||||||
|
strategies: IFeatureStrategy[];
|
||||||
|
projectsUsed: string[];
|
||||||
|
availableProjects: IProjectCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SegmentProjectAlert = ({
|
||||||
|
projects,
|
||||||
|
strategies,
|
||||||
|
projectsUsed,
|
||||||
|
availableProjects,
|
||||||
|
}: ISegmentProjectAlertProps) => {
|
||||||
|
const projectList = (
|
||||||
|
<StyledUl>
|
||||||
|
{Array.from(projectsUsed).map(projectId => (
|
||||||
|
<li key={projectId}>
|
||||||
|
<Link to={`/projects/${projectId}`} target="_blank">
|
||||||
|
{projects.find(({ id }) => id === projectId)?.name ??
|
||||||
|
projectId}
|
||||||
|
</Link>
|
||||||
|
<ul>
|
||||||
|
{strategies
|
||||||
|
?.filter(
|
||||||
|
strategy => strategy.projectId === projectId
|
||||||
|
)
|
||||||
|
.map(strategy => (
|
||||||
|
<li key={strategy.id}>
|
||||||
|
<Link
|
||||||
|
to={formatEditStrategyPath(
|
||||||
|
strategy.projectId!,
|
||||||
|
strategy.featureName!,
|
||||||
|
strategy.environment!,
|
||||||
|
strategy.id
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{strategy.featureName!}{' '}
|
||||||
|
{formatStrategyNameParens(strategy)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</StyledUl>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projectsUsed.length > 1) {
|
||||||
|
return (
|
||||||
|
<StyledAlert severity="info">
|
||||||
|
You can't specify a project for this segment because it is used
|
||||||
|
in multiple projects:
|
||||||
|
{projectList}
|
||||||
|
</StyledAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableProjects.length === 1) {
|
||||||
|
return (
|
||||||
|
<StyledAlert severity="info">
|
||||||
|
You can't specify a project other than{' '}
|
||||||
|
<strong>{availableProjects[0].name}</strong> for this segment
|
||||||
|
because it is used here:
|
||||||
|
{projectList}
|
||||||
|
</StyledAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStrategyNameParens = (strategy: IFeatureStrategy): string => {
|
||||||
|
if (!strategy.strategyName) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `(${formatStrategyName(strategy.strategyName)})`;
|
||||||
|
};
|
@ -1,28 +1,31 @@
|
|||||||
import useSWR, { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
|
||||||
|
|
||||||
export interface IUseStrategiesBySegmentOutput {
|
export interface IUseStrategiesBySegmentOutput {
|
||||||
strategies?: IFeatureStrategy[];
|
strategies: IFeatureStrategy[];
|
||||||
refetchUsedSegments: () => void;
|
refetchUsedSegments: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStrategiesBySegment = (
|
export const useStrategiesBySegment = (
|
||||||
id: number
|
id?: string | number
|
||||||
): IUseStrategiesBySegmentOutput => {
|
): IUseStrategiesBySegmentOutput => {
|
||||||
const path = formatApiPath(`api/admin/segments/${id}/strategies`);
|
const path = formatApiPath(`api/admin/segments/${id}/strategies`);
|
||||||
const { data, error } = useSWR(path, () => fetchUsedSegment(path));
|
const { data, error } = useConditionalSWR(id, [], path, () =>
|
||||||
|
fetchUsedSegment(path)
|
||||||
|
);
|
||||||
|
|
||||||
const refetchUsedSegments = useCallback(() => {
|
const refetchUsedSegments = useCallback(() => {
|
||||||
mutate(path).catch(console.warn);
|
mutate(path).catch(console.warn);
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
strategies: data?.strategies,
|
strategies: data?.strategies || [],
|
||||||
refetchUsedSegments,
|
refetchUsedSegments,
|
||||||
loading: !error && !data,
|
loading: !error && !data,
|
||||||
error,
|
error,
|
||||||
|
@ -22,6 +22,12 @@ export interface ISegmentService {
|
|||||||
user: Partial<Pick<IUser, 'username' | 'email'>>,
|
user: Partial<Pick<IUser, 'username' | 'email'>>,
|
||||||
): Promise<ISegment>;
|
): Promise<ISegment>;
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number,
|
||||||
|
data: UpsertSegmentSchema,
|
||||||
|
user: Partial<Pick<IUser, 'username' | 'email'>>,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
delete(id: number, user: IUser): Promise<void>;
|
delete(id: number, user: IUser): Promise<void>;
|
||||||
|
|
||||||
cloneStrategySegments(
|
cloneStrategySegments(
|
||||||
|
@ -98,6 +98,8 @@ export class SegmentService implements ISegmentService {
|
|||||||
await this.validateName(input.name);
|
await this.validateName(input.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.validateSegmentProject(id, input);
|
||||||
|
|
||||||
const segment = await this.segmentStore.update(id, input);
|
const segment = await this.segmentStore.update(id, input);
|
||||||
|
|
||||||
await this.eventStore.store({
|
await this.eventStore.store({
|
||||||
@ -218,4 +220,28 @@ export class SegmentService implements ISegmentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateSegmentProject(
|
||||||
|
id: number,
|
||||||
|
segment: Omit<ISegment, 'id'>,
|
||||||
|
): Promise<void> {
|
||||||
|
const strategies =
|
||||||
|
await this.featureStrategiesStore.getStrategiesBySegment(id);
|
||||||
|
|
||||||
|
const projectsUsed = new Set(
|
||||||
|
strategies.map((strategy) => strategy.projectId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
segment.project &&
|
||||||
|
(projectsUsed.size > 1 ||
|
||||||
|
(projectsUsed.size === 1 && !projectsUsed.has(segment.project)))
|
||||||
|
) {
|
||||||
|
throw new BadDataError(
|
||||||
|
`Invalid project. Segment is being used by strategies in other projects: ${Array.from(
|
||||||
|
projectsUsed,
|
||||||
|
).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,15 @@ const createSegment = (postData: UpsertSegmentSchema): Promise<ISegment> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSegment = (
|
||||||
|
id: number,
|
||||||
|
postData: UpsertSegmentSchema,
|
||||||
|
): Promise<void> => {
|
||||||
|
return app.services.segmentService.update(id, postData, {
|
||||||
|
email: 'test@example.com',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const mockStrategy = (segments: number[] = []) => {
|
const mockStrategy = (segments: number[] = []) => {
|
||||||
return {
|
return {
|
||||||
name: randomId(),
|
name: randomId(),
|
||||||
@ -436,4 +445,118 @@ describe('project-specific segments', () => {
|
|||||||
[{ status: 400 }],
|
[{ status: 400 }],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(`can't set a different segment project when being used by another project`, async () => {
|
||||||
|
const segmentName = 'my-segment';
|
||||||
|
const project1 = randomId();
|
||||||
|
const project2 = randomId();
|
||||||
|
await createProjects([project1, project2]);
|
||||||
|
const segment = await createSegment({
|
||||||
|
name: segmentName,
|
||||||
|
project: project1,
|
||||||
|
constraints: [],
|
||||||
|
});
|
||||||
|
const strategy = {
|
||||||
|
name: 'default',
|
||||||
|
parameters: {},
|
||||||
|
constraints: [],
|
||||||
|
segments: [segment.id],
|
||||||
|
};
|
||||||
|
await createFeatureToggle(
|
||||||
|
{
|
||||||
|
name: 'first_feature',
|
||||||
|
description: 'the #1 feature',
|
||||||
|
},
|
||||||
|
[strategy],
|
||||||
|
project1,
|
||||||
|
);
|
||||||
|
await expect(() =>
|
||||||
|
updateSegment(segment.id, {
|
||||||
|
...segment,
|
||||||
|
project: project2,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Invalid project. Segment is being used by strategies in other projects: ${project1}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can promote a segment project to global even when being used by a specific project', async () => {
|
||||||
|
const segmentName = 'my-segment';
|
||||||
|
const project1 = randomId();
|
||||||
|
const project2 = randomId();
|
||||||
|
await createProjects([project1, project2]);
|
||||||
|
const segment = await createSegment({
|
||||||
|
name: segmentName,
|
||||||
|
project: project1,
|
||||||
|
constraints: [],
|
||||||
|
});
|
||||||
|
const strategy = {
|
||||||
|
name: 'default',
|
||||||
|
parameters: {},
|
||||||
|
constraints: [],
|
||||||
|
segments: [segment.id],
|
||||||
|
};
|
||||||
|
await createFeatureToggle(
|
||||||
|
{
|
||||||
|
name: 'first_feature',
|
||||||
|
description: 'the #1 feature',
|
||||||
|
},
|
||||||
|
[strategy],
|
||||||
|
project1,
|
||||||
|
);
|
||||||
|
await expect(() =>
|
||||||
|
updateSegment(segment.id, {
|
||||||
|
...segment,
|
||||||
|
project: '',
|
||||||
|
}),
|
||||||
|
).resolves;
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`can't set a specific segment project when being used by multiple projects (global)`, async () => {
|
||||||
|
const segmentName = 'my-segment';
|
||||||
|
const project1 = randomId();
|
||||||
|
const project2 = randomId();
|
||||||
|
await createProjects([project1, project2]);
|
||||||
|
const segment = await createSegment({
|
||||||
|
name: segmentName,
|
||||||
|
project: '',
|
||||||
|
constraints: [],
|
||||||
|
});
|
||||||
|
const strategy = {
|
||||||
|
name: 'default',
|
||||||
|
parameters: {},
|
||||||
|
constraints: [],
|
||||||
|
segments: [segment.id],
|
||||||
|
};
|
||||||
|
const strategy2 = {
|
||||||
|
name: 'default',
|
||||||
|
parameters: {},
|
||||||
|
constraints: [],
|
||||||
|
segments: [segment.id],
|
||||||
|
};
|
||||||
|
await createFeatureToggle(
|
||||||
|
{
|
||||||
|
name: 'first_feature',
|
||||||
|
description: 'the #1 feature',
|
||||||
|
},
|
||||||
|
[strategy],
|
||||||
|
project1,
|
||||||
|
);
|
||||||
|
await createFeatureToggle(
|
||||||
|
{
|
||||||
|
name: 'second_feature',
|
||||||
|
description: 'the #2 feature',
|
||||||
|
},
|
||||||
|
[strategy2],
|
||||||
|
project2,
|
||||||
|
);
|
||||||
|
await expect(() =>
|
||||||
|
updateSegment(segment.id, {
|
||||||
|
...segment,
|
||||||
|
project: project1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`Invalid project. Segment is being used by strategies in other projects: ${project1}, ${project2}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user