1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

fix: resolve issues around changing a toggle's project (#978)

* refactor: show save button before using the dropdown

* refactor: simplify FeatureSettingsProject toast message

* refactor: fix FeatureProjectSelect filter prop type

* refactor: hide change project page for non-enterprise

* refactor: derive move targets from projects list instead of from permissions

* refactor: align frontend project compat check with backend

* refactor: fix useProject object stability

* refactor: disable the save button for the current project

* refactor: require equal environments when moving toggles

* refactor: improve arraysHaveSameItems name
This commit is contained in:
olav 2022-05-18 11:07:19 +02:00 committed by GitHub
parent 4aee33e189
commit 159c54ed37
10 changed files with 135 additions and 228 deletions

View File

@ -6,6 +6,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import FeatureSettingsProject from './FeatureSettingsProject/FeatureSettingsProject'; import FeatureSettingsProject from './FeatureSettingsProject/FeatureSettingsProject';
import { FeatureSettingsInformation } from './FeatureSettingsInformation/FeatureSettingsInformation'; import { FeatureSettingsInformation } from './FeatureSettingsInformation/FeatureSettingsInformation';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const METADATA = 'metadata'; const METADATA = 'metadata';
const PROJECT = 'project'; const PROJECT = 'project';
@ -15,6 +16,7 @@ export const FeatureSettings = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const [settings, setSettings] = useState(METADATA); const [settings, setSettings] = useState(METADATA);
const { uiConfig } = useUiConfig();
return ( return (
<PageContent header="Settings" bodyClass={styles.bodyContainer}> <PageContent header="Settings" bodyClass={styles.bodyContainer}>
@ -36,6 +38,7 @@ export const FeatureSettings = () => {
button button
onClick={() => setSettings(PROJECT)} onClick={() => setSettings(PROJECT)}
selected={settings === PROJECT} selected={settings === PROJECT}
hidden={!uiConfig.flags.P}
> >
Project Project
</ListItem> </ListItem>
@ -52,7 +55,7 @@ export const FeatureSettings = () => {
} }
/> />
<ConditionallyRender <ConditionallyRender
condition={settings === PROJECT} condition={settings === PROJECT && uiConfig.flags.P}
show={<FeatureSettingsProject />} show={<FeatureSettingsProject />}
/> />
</div> </div>

View File

@ -10,7 +10,7 @@ interface IFeatureProjectSelectProps
extends Omit<IGeneralSelectProps, 'options'> { extends Omit<IGeneralSelectProps, 'options'> {
enabled: boolean; enabled: boolean;
value: string; value: string;
filter: (project: string) => void; filter: (projectId: string) => boolean;
} }
const FeatureProjectSelect = ({ const FeatureProjectSelect = ({

View File

@ -1,121 +1,79 @@
import { useContext, useEffect, useState } from 'react'; import { useContext, useState, useMemo } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { MOVE_FEATURE_TOGGLE } from 'component/providers/AccessProvider/permissions'; import { MOVE_FEATURE_TOGGLE } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect'; import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect';
import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm'; import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm';
import { IPermission } from 'interfaces/user';
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
const FeatureSettingsProject = () => { const FeatureSettingsProject = () => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature } = useFeature(projectId, featureId); const { feature, refetchFeature } = useFeature(projectId, featureId);
const [project, setProject] = useState(feature.project);
const [dirty, setDirty] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const editable = hasAccess(MOVE_FEATURE_TOGGLE, projectId);
const { permissions = [] } = useAuthPermissions();
const { changeFeatureProject } = useFeatureApi(); const { changeFeatureProject } = useFeatureApi();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const [project, setProject] = useState(projectId);
const { projects } = useProjects();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { const onConfirm = async () => {
if (project !== feature.project) {
setDirty(true);
return;
}
setDirty(false);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [project]);
useEffect(() => {
const movableTargets = createMoveTargets();
if (!movableTargets[project]) {
setDirty(false);
setProject(projectId);
}
/* eslint-disable-next-line */
}, [permissions.length]);
const updateProject = async () => {
const newProject = project;
try { try {
await changeFeatureProject(projectId, featureId, newProject); if (project) {
refetchFeature(); await changeFeatureProject(projectId, featureId, project);
setToastData({ refetchFeature();
title: 'Updated project', setToastData({ title: 'Project changed', type: 'success' });
confetti: true, setShowConfirmDialog(false);
type: 'success', navigate(
text: 'Successfully updated toggle project.', `/projects/${project}/features/${featureId}/settings`,
}); { replace: true }
setDirty(false); );
setShowConfirmDialog(false); }
navigate(`/projects/${newProject}/features/${featureId}/settings`, {
replace: true,
});
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
}; };
const createMoveTargets = () => { const targetProjectIds = useMemo(() => {
return permissions.reduce( return projects
(acc: { [key: string]: boolean }, p: IPermission) => { .map(project => project.id)
if (p.project && p.permission === MOVE_FEATURE_TOGGLE) { .filter(projectId => hasAccess(MOVE_FEATURE_TOGGLE, projectId));
acc[p.project] = true; }, [projects, hasAccess]);
}
return acc;
},
{}
);
};
const filterProjects = () => { if (targetProjectIds.length === 0) {
const validTargets = createMoveTargets(); return null;
}
return (project: string) => {
if (validTargets[project]) {
return project;
}
};
};
return ( return (
<> <>
<FeatureProjectSelect <FeatureProjectSelect
value={project} value={project}
onChange={setProject} onChange={setProject}
label="Project" label="Project"
enabled={editable} filter={projectId => targetProjectIds.includes(projectId)}
filter={filterProjects()} enabled
/>
<ConditionallyRender
condition={dirty}
show={
<PermissionButton
permission={MOVE_FEATURE_TOGGLE}
onClick={() => setShowConfirmDialog(true)}
projectId={projectId}
>
Save changes
</PermissionButton>
}
/> />
<PermissionButton
permission={MOVE_FEATURE_TOGGLE}
onClick={() => setShowConfirmDialog(true)}
disabled={project === projectId}
projectId={projectId}
>
Save
</PermissionButton>
<FeatureSettingsProjectConfirm <FeatureSettingsProjectConfirm
projectId={project} projectId={project}
open={showConfirmDialog} open={showConfirmDialog}
feature={feature} feature={feature}
onClose={() => setShowConfirmDialog(false)} onClose={() => setShowConfirmDialog(false)}
onClick={updateProject} onClick={onConfirm}
/> />
</> </>
); );

View File

@ -1,46 +1,9 @@
import { makeStyles } from 'tss-react/mui'; import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({ export const useStyles = makeStyles()(theme => ({
compatability: { container: {
padding: '1rem', display: 'grid',
border: `1px solid ${theme.palette.grey[300]}`, gap: theme.spacing(2),
marginTop: '1rem', paddingTop: theme.spacing(2),
display: 'flex',
alignItems: 'center',
},
iconContainer: {
width: '50px',
height: '50px',
backgroundColor: theme.palette.success.main,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
errorIconContainer: {
width: '50px',
height: '50px',
backgroundColor: theme.palette.error.main,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
topContent: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
check: {
fill: '#fff',
width: '30px',
height: '30px',
},
paragraph: {
marginTop: '1rem',
},
cloud: {
fill: theme.palette.grey[500],
marginRight: '0.5rem',
}, },
})); }));

View File

@ -1,11 +1,11 @@
import { List, ListItem } from '@mui/material'; import { useMemo } from 'react';
import { Check, Error, Cloud } from '@mui/icons-material';
import { useState, useEffect } from 'react';
import useProject from 'hooks/api/getters/useProject/useProject'; import useProject from 'hooks/api/getters/useProject/useProject';
import { IFeatureEnvironment, IFeatureToggle } from 'interfaces/featureToggle'; import { IFeatureToggle } from 'interfaces/featureToggle';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue'; import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { useStyles } from './FeatureSettingsProjectConfirm.styles'; import { useStyles } from './FeatureSettingsProjectConfirm.styles';
import { arraysHaveSameItems } from 'utils/arraysHaveSameItems';
import { Alert } from '@mui/material';
interface IFeatureSettingsProjectConfirm { interface IFeatureSettingsProjectConfirm {
projectId: string; projectId: string;
@ -23,37 +23,18 @@ const FeatureSettingsProjectConfirm = ({
feature, feature,
}: IFeatureSettingsProjectConfirm) => { }: IFeatureSettingsProjectConfirm) => {
const { project } = useProject(projectId); const { project } = useProject(projectId);
const [incompatibleEnvs, setIncompatibleEnvs] = useState([]);
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
useEffect(() => { const hasSameEnvironments: boolean = useMemo(() => {
calculateCompatability(); return arraysHaveSameItems(
/* eslint-disable-next-line react-hooks/exhaustive-deps */ feature.environments.map(env => env.name),
}, [projectId, project.name]); project.environments
);
const calculateCompatability = () => { }, [feature, project]);
const featureEnvWithStrategies = feature.environments
.filter((env: IFeatureEnvironment) => {
return env.strategies.length > 0;
})
.map((env: IFeatureEnvironment) => env.name);
const destinationProjectActiveEnvironments = project.environments;
let incompatible: string[] = [];
featureEnvWithStrategies.forEach((env: string) => {
if (destinationProjectActiveEnvironments.indexOf(env) === -1) {
incompatible = [...incompatible, env];
}
});
// @ts-expect-error
setIncompatibleEnvs(incompatible);
};
return ( return (
<ConditionallyRender <ConditionallyRender
condition={incompatibleEnvs?.length === 0} condition={hasSameEnvironments}
show={ show={
<Dialogue <Dialogue
open={open} open={open}
@ -63,14 +44,15 @@ const FeatureSettingsProjectConfirm = ({
primaryButtonText="Change project" primaryButtonText="Change project"
secondaryButtonText="Cancel" secondaryButtonText="Cancel"
> >
Are you sure you want to change the project for this feature <div className={styles.container}>
toggle? <Alert severity="success">
<div className={styles.compatability}> This feature toggle is compatible with the new
This feature toggle is 100% compatible with the new project.
project. </Alert>
<div className={styles.iconContainer}> <p>
<Check className={styles.check} /> Are you sure you want to change the project for this
</div> toggle?
</p>
</div> </div>
</Dialogue> </Dialogue>
} }
@ -81,44 +63,16 @@ const FeatureSettingsProjectConfirm = ({
title="Confirm change project" title="Confirm change project"
secondaryButtonText="OK" secondaryButtonText="OK"
> >
<div className={styles.topContent}> <div className={styles.container}>
<Alert severity="warning">
Incompatible project environments
</Alert>
<p> <p>
{' '} In order to move a feature toggle between two
This feature toggle is not compatible with the new projects, both projects must have the exact same
project destination. environments enabled.
</p> </p>
<div className={styles.iconContainer}>
<div className={styles.errorIconContainer}>
<Error
className={styles.check}
titleAccess="Error"
/>
</div>
</div>
</div> </div>
<div className={styles.compatability}>
<div>
<p className={styles.paragraph}>
This feature toggle has strategy configuration
in an environment that is not activated in the
target project:
</p>
<List>
{incompatibleEnvs.map(env => {
return (
<ListItem key={env}>
<Cloud className={styles.cloud} />
{env}
</ListItem>
);
})}
</List>
</div>
</div>
<p className={styles.paragraph}>
In order to move this feature toggle, make sure you
enable the required environments in the target project.
</p>
</Dialogue> </Dialogue>
} }
/> />

View File

@ -1,5 +1,5 @@
import useSWR, { mutate, SWRConfiguration } from 'swr'; import useSWR, { SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react'; import { useCallback } from 'react';
import { getProjectFetcher } from './getProjectFetcher'; import { getProjectFetcher } from './getProjectFetcher';
import { IProject } from 'interfaces/project'; import { IProject } from 'interfaces/project';
@ -15,22 +15,16 @@ const fallbackProject: IProject = {
const useProject = (id: string, options: SWRConfiguration = {}) => { const useProject = (id: string, options: SWRConfiguration = {}) => {
const { KEY, fetcher } = getProjectFetcher(id); const { KEY, fetcher } = getProjectFetcher(id);
const { data, error, mutate } = useSWR<IProject>(KEY, fetcher, options);
const { data, error } = useSWR<IProject>(KEY, fetcher, options); const refetch = useCallback(() => {
const [loading, setLoading] = useState(!error && !data); mutate();
}, [mutate]);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return { return {
project: data || fallbackProject, project: data || fallbackProject,
loading: !error && !data,
error, error,
loading,
refetch, refetch,
}; };
}; };

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { import {
sortFeaturesByNameAscending, sortFeaturesByNameAscending,
sortFeaturesByNameDescending, sortFeaturesByNameDescending,
@ -63,25 +63,29 @@ const useSort = () => {
return sortFeaturesByStatusDescending(features); return sortFeaturesByStatusDescending(features);
}; };
const sort = (features: IFeatureToggleListItem[]) => { const sort = useCallback(
switch (sortData.sortKey) { (features: IFeatureToggleListItem[]): IFeatureToggleListItem[] => {
case 'name': switch (sortData.sortKey) {
return handleSortName(features); case 'name':
case 'last-seen': return handleSortName(features);
return handleSortLastSeen(features); case 'last-seen':
case 'created': return handleSortLastSeen(features);
return handleSortCreatedAt(features); case 'created':
case 'expired': return handleSortCreatedAt(features);
case 'report': case 'expired':
return handleSortExpiredAt(features); case 'report':
case 'status': return handleSortExpiredAt(features);
return handleSortStatus(features); case 'status':
default: return handleSortStatus(features);
return features; default:
} return features;
}; }
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sortData]
);
return [sort, setSortData]; return [sort, setSortData] as const;
}; };
export default useSort; export default useSort;

View File

@ -0,0 +1,17 @@
import { arraysHaveSameItems } from 'utils/arraysHaveSameItems';
test('arraysHaveSameItems', () => {
expect(arraysHaveSameItems([], [])).toEqual(true);
expect(arraysHaveSameItems([1], [1])).toEqual(true);
expect(arraysHaveSameItems([1], [1, 1])).toEqual(true);
expect(arraysHaveSameItems([1, 1], [1])).toEqual(true);
expect(arraysHaveSameItems([1, 2], [1, 2])).toEqual(true);
expect(arraysHaveSameItems([1, 2], [2, 1])).toEqual(true);
expect(arraysHaveSameItems([1, 2], [2, 2, 1])).toEqual(true);
expect(arraysHaveSameItems([1], [])).toEqual(false);
expect(arraysHaveSameItems([1], [2])).toEqual(false);
expect(arraysHaveSameItems([1, 2], [1, 3])).toEqual(false);
expect(arraysHaveSameItems([1, 2], [1, 2, 3])).toEqual(false);
expect(arraysHaveSameItems([1, 2, 3], [1, 2])).toEqual(false);
expect(arraysHaveSameItems([1, 2, 3], [1, 2, 4])).toEqual(false);
});

View File

@ -0,0 +1,12 @@
export const arraysHaveSameItems = <T>(a: T[], b: T[]): boolean => {
const setA = new Set(a);
const setB = new Set(b);
if (setA.size !== setB.size) {
return false;
}
return [...setA].every(itemA => {
return setB.has(itemA);
});
};

View File

@ -1,5 +1,5 @@
import { ADMIN } from '../component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { IPermission } from '../interfaces/user'; import { IPermission } from 'interfaces/user';
type objectIdx = { type objectIdx = {
[key: string]: string; [key: string]: string;
@ -8,8 +8,9 @@ type objectIdx = {
export const projectFilterGenerator = ( export const projectFilterGenerator = (
permissions: IPermission[] = [], permissions: IPermission[] = [],
matcherPermission: string matcherPermission: string
) => { ): ((projectId: string) => boolean) => {
let admin = false; let admin = false;
const permissionMap: objectIdx = permissions.reduce( const permissionMap: objectIdx = permissions.reduce(
(acc: objectIdx, p: IPermission) => { (acc: objectIdx, p: IPermission) => {
if (p.permission === ADMIN) { if (p.permission === ADMIN) {
@ -24,7 +25,8 @@ export const projectFilterGenerator = (
}, },
{} {}
); );
return (projectId: string) => { return (projectId: string) => {
return admin || permissionMap[projectId]; return admin || Boolean(permissionMap[projectId]);
}; };
}; };