1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

fix: add actions validation (#6481)

https://linear.app/unleash/issue/2-2022/improve-actions-validation

Improves our current actions form validation. 

Empty actions are now ignored on the payload and we get errors in
actions where any of the required fields are empty.

Also refactored our current actions into a constant map that can be
shared across frontend and backend.
This commit is contained in:
Nuno Góis 2024-03-11 08:18:36 +00:00 committed by GitHub
parent 62a7633ed5
commit 7d827442ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 130 additions and 44 deletions

View File

@ -57,6 +57,8 @@ interface IProjectActionsFormProps {
setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>; setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>;
errors: ProjectActionsFormErrors; errors: ProjectActionsFormErrors;
validateName: (name: string) => boolean; validateName: (name: string) => boolean;
validateSourceId: (sourceId: number) => boolean;
validateActorId: (actorId: number) => boolean;
validated: boolean; validated: boolean;
} }
@ -77,6 +79,8 @@ export const ProjectActionsForm = ({
setActions, setActions,
errors, errors,
validateName, validateName,
validateSourceId,
validateActorId,
validated, validated,
}: IProjectActionsFormProps) => { }: IProjectActionsFormProps) => {
const { serviceAccounts, loading: serviceAccountsLoading } = const { serviceAccounts, loading: serviceAccountsLoading } =
@ -142,6 +146,7 @@ export const ProjectActionsForm = ({
setSourceId={setSourceId} setSourceId={setSourceId}
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
validateSourceId={validateSourceId}
/> />
<ProjectActionsFormStepActions <ProjectActionsFormStepActions
@ -151,6 +156,8 @@ export const ProjectActionsForm = ({
setActions={setActions} setActions={setActions}
actorId={actorId} actorId={actorId}
setActorId={setActorId} setActorId={setActorId}
validateActorId={validateActorId}
validated={validated}
/> />
<ConditionallyRender <ConditionallyRender

View File

@ -8,7 +8,8 @@ import { ProjectActionsFormItem } from '../ProjectActionsFormItem';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix'; import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix';
import { useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { ACTIONS } from '@server/util/constants/actions';
const StyledItemBody = styled('div')(({ theme }) => ({ const StyledItemBody = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -28,25 +29,13 @@ const StyledFieldContainer = styled('div')({
flex: 1, flex: 1,
}); });
const options = [
{
label: 'Enable flag',
key: 'TOGGLE_FEATURE_ON',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
},
{
label: 'Disable flag',
key: 'TOGGLE_FEATURE_OFF',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
},
];
interface IProjectActionsItemProps { interface IProjectActionsItemProps {
action: ActionsActionState; action: ActionsActionState;
index: number; index: number;
stateChanged: (action: ActionsActionState) => void; stateChanged: (action: ActionsActionState) => void;
actorId: number; actorId: number;
onDelete: () => void; onDelete: () => void;
validated: boolean;
} }
export const ProjectActionsActionItem = ({ export const ProjectActionsActionItem = ({
@ -55,22 +44,23 @@ export const ProjectActionsActionItem = ({
stateChanged, stateChanged,
actorId, actorId,
onDelete, onDelete,
validated,
}: IProjectActionsItemProps) => { }: IProjectActionsItemProps) => {
const { action: actionName } = action; const { action: actionName, executionParams, error } = action;
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId); const { project } = useProjectOverview(projectId);
const { permissions } = useServiceAccountAccessMatrix( const { permissions } = useServiceAccountAccessMatrix(
actorId, actorId,
projectId, projectId,
action.executionParams.environment as string, executionParams.environment as string,
); );
const hasPermission = useMemo(() => { const actionDefinition = ACTIONS.get(actionName);
const requiredPermissions = options.find(
({ key }) => key === actionName,
)?.permissions;
const { environment: actionEnvironment } = action.executionParams; const hasPermission = useMemo(() => {
const requiredPermissions = actionDefinition?.permissions;
const { environment: actionEnvironment } = executionParams;
if ( if (
permissions.length === 0 || permissions.length === 0 ||
@ -87,7 +77,24 @@ export const ProjectActionsActionItem = ({
environment === actionEnvironment, environment === actionEnvironment,
), ),
); );
}, [actionName, permissions]); }, [actionDefinition, permissions]);
useEffect(() => {
stateChanged({
...action,
error: undefined,
});
if (
actionDefinition?.required.some(
(required) => !executionParams[required],
)
) {
stateChanged({
...action,
error: 'Please fill all required fields.',
});
}
}, [actionDefinition, executionParams]);
const environments = project.environments.map( const environments = project.environments.map(
({ environment }) => environment, ({ environment }) => environment,
@ -116,7 +123,10 @@ export const ProjectActionsActionItem = ({
<GeneralSelect <GeneralSelect
label='Action' label='Action'
name='action' name='action'
options={options} options={[...ACTIONS].map(([key, { label }]) => ({
key,
label,
}))}
value={actionName} value={actionName}
onChange={(selected) => onChange={(selected) =>
stateChanged({ stateChanged({
@ -135,12 +145,12 @@ export const ProjectActionsActionItem = ({
label: environment, label: environment,
key: environment, key: environment,
}))} }))}
value={action.executionParams.environment as string} value={executionParams.environment as string}
onChange={(selected) => onChange={(selected) =>
stateChanged({ stateChanged({
...action, ...action,
executionParams: { executionParams: {
...action.executionParams, ...executionParams,
environment: selected, environment: selected,
}, },
}) })
@ -156,12 +166,12 @@ export const ProjectActionsActionItem = ({
label: feature.name, label: feature.name,
key: feature.name, key: feature.name,
}))} }))}
value={action.executionParams.featureName as string} value={executionParams.featureName as string}
onChange={(selected) => onChange={(selected) =>
stateChanged({ stateChanged({
...action, ...action,
executionParams: { executionParams: {
...action.executionParams, ...executionParams,
featureName: selected, featureName: selected,
}, },
}) })
@ -170,6 +180,10 @@ export const ProjectActionsActionItem = ({
/> />
</StyledFieldContainer> </StyledFieldContainer>
</StyledItemRow> </StyledItemRow>
<ConditionallyRender
condition={validated && Boolean(error)}
show={<Alert severity='error'>{error}</Alert>}
/>
<ConditionallyRender <ConditionallyRender
condition={!hasPermission} condition={!hasPermission}
show={ show={

View File

@ -29,6 +29,8 @@ interface IProjectActionsFormStepActionsProps {
setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>; setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>;
actorId: number; actorId: number;
setActorId: React.Dispatch<React.SetStateAction<number>>; setActorId: React.Dispatch<React.SetStateAction<number>>;
validateActorId: (actorId: number) => boolean;
validated: boolean;
} }
export const ProjectActionsFormStepActions = ({ export const ProjectActionsFormStepActions = ({
@ -38,6 +40,8 @@ export const ProjectActionsFormStepActions = ({
setActions, setActions,
actorId, actorId,
setActorId, setActorId,
validateActorId,
validated,
}: IProjectActionsFormStepActionsProps) => { }: IProjectActionsFormStepActionsProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -91,6 +95,7 @@ export const ProjectActionsFormStepActions = ({
options={serviceAccountOptions} options={serviceAccountOptions}
value={`${actorId}`} value={`${actorId}`}
onChange={(v) => { onChange={(v) => {
validateActorId(Number(v));
setActorId(parseInt(v)); setActorId(parseInt(v));
}} }}
/> />
@ -107,6 +112,7 @@ export const ProjectActionsFormStepActions = ({
actions.filter((a) => a.id !== action.id), actions.filter((a) => a.id !== action.id),
) )
} }
validated={validated}
/> />
))} ))}
<StyledButtonContainer> <StyledButtonContainer>

View File

@ -37,6 +37,7 @@ interface IProjectActionsFormStepSourceProps {
setSourceId: React.Dispatch<React.SetStateAction<number>>; setSourceId: React.Dispatch<React.SetStateAction<number>>;
filters: ActionsFilterState[]; filters: ActionsFilterState[];
setFilters: React.Dispatch<React.SetStateAction<ActionsFilterState[]>>; setFilters: React.Dispatch<React.SetStateAction<ActionsFilterState[]>>;
validateSourceId: (sourceId: number) => boolean;
} }
export const ProjectActionsFormStepSource = ({ export const ProjectActionsFormStepSource = ({
@ -44,6 +45,7 @@ export const ProjectActionsFormStepSource = ({
setSourceId, setSourceId,
filters, filters,
setFilters, setFilters,
validateSourceId,
}: IProjectActionsFormStepSourceProps) => { }: IProjectActionsFormStepSourceProps) => {
const { signalEndpoints, loading: signalEndpointsLoading } = const { signalEndpoints, loading: signalEndpointsLoading } =
useSignalEndpoints(); useSignalEndpoints();
@ -102,6 +104,7 @@ export const ProjectActionsFormStepSource = ({
options={signalEndpointOptions} options={signalEndpointOptions}
value={`${sourceId}`} value={`${sourceId}`}
onChange={(v) => { onChange={(v) => {
validateSourceId(Number(v));
setSourceId(parseInt(v)); setSourceId(parseInt(v));
}} }}
/> />

View File

@ -23,6 +23,7 @@ export type ActionsActionState = Omit<
'id' | 'createdAt' | 'createdByUserId' 'id' | 'createdAt' | 'createdByUserId'
> & { > & {
id: string; id: string;
error?: string;
}; };
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = { const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
@ -99,12 +100,13 @@ export const useProjectActionsForm = (action?: IActionSet) => {
}; };
useEffect(() => { useEffect(() => {
clearError(ErrorField.FILTERS); validateFilters(filters);
if (filters.some(({ error }) => error)) {
setError(ErrorField.FILTERS, 'One or more filters have errors.');
}
}, [filters]); }, [filters]);
useEffect(() => {
validateActions(actions);
}, [actions]);
const isEmpty = (value: string) => !value.length; const isEmpty = (value: string) => !value.length;
const isNameNotUnique = (value: string) => const isNameNotUnique = (value: string) =>
@ -137,6 +139,16 @@ export const useProjectActionsForm = (action?: IActionSet) => {
return true; return true;
}; };
const validateFilters = (filters: ActionsFilterState[]) => {
if (filters.some(({ error }) => error)) {
setError(ErrorField.FILTERS, 'One or more filters have errors.');
return false;
}
clearError(ErrorField.FILTERS);
return true;
};
const validateActorId = (sourceId: number) => { const validateActorId = (sourceId: number) => {
if (isIdEmpty(sourceId)) { if (isIdEmpty(sourceId)) {
setError(ErrorField.ACTOR, 'Service account is required.'); setError(ErrorField.ACTOR, 'Service account is required.');
@ -148,11 +160,17 @@ export const useProjectActionsForm = (action?: IActionSet) => {
}; };
const validateActions = (actions: ActionsActionState[]) => { const validateActions = (actions: ActionsActionState[]) => {
if (actions.length === 0) { if (actions.filter(({ action }) => Boolean(action)).length === 0) {
setError(ErrorField.ACTIONS, 'At least one action is required.'); setError(ErrorField.ACTIONS, 'At least one action is required.');
return false; return false;
} }
clearError(ErrorField.ACTIONS);
if (actions.some(({ error }) => error)) {
setError(ErrorField.ACTIONS, 'One or more actions have errors.');
return false;
}
clearError(ErrorField.ACTIONS); clearError(ErrorField.ACTIONS);
return true; return true;
}; };
@ -160,12 +178,19 @@ export const useProjectActionsForm = (action?: IActionSet) => {
const validate = () => { const validate = () => {
const validName = validateName(name); const validName = validateName(name);
const validSourceId = validateSourceId(sourceId); const validSourceId = validateSourceId(sourceId);
const validFilters = validateFilters(filters);
const validActorId = validateActorId(actorId); const validActorId = validateActorId(actorId);
const validActions = validateActions(actions); const validActions = validateActions(actions);
setValidated(true); setValidated(true);
return validName && validSourceId && validActorId && validActions; return (
validName &&
validSourceId &&
validFilters &&
validActorId &&
validActions
);
}; };
return { return {
@ -187,6 +212,8 @@ export const useProjectActionsForm = (action?: IActionSet) => {
setErrors, setErrors,
validated, validated,
validateName, validateName,
validateSourceId,
validateActorId,
validate, validate,
reloadForm, reloadForm,
}; };

View File

@ -81,6 +81,8 @@ export const ProjectActionsModal = ({
setActions, setActions,
errors, errors,
validateName, validateName,
validateSourceId,
validateActorId,
validate, validate,
validated, validated,
reloadForm, reloadForm,
@ -127,11 +129,13 @@ export const ProjectActionsModal = ({
), ),
}, },
actorId, actorId,
actions: actions.map(({ action, sortOrder, executionParams }) => ({ actions: actions
action, .filter(({ action }) => Boolean(action))
sortOrder, .map(({ action, sortOrder, executionParams }) => ({
executionParams, action,
})), sortOrder,
executionParams,
})),
}; };
const formatApiCode = () => `curl --location --request ${ const formatApiCode = () => `curl --location --request ${
@ -206,6 +210,8 @@ export const ProjectActionsModal = ({
setActions={setActions} setActions={setActions}
errors={errors} errors={errors}
validateName={validateName} validateName={validateName}
validateSourceId={validateSourceId}
validateActorId={validateActorId}
validated={validated} validated={validated}
/> />
<StyledButtonContainer> <StyledButtonContainer>

View File

@ -1,11 +1,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import useSWR from 'swr';
import { IRole } from 'interfaces/role'; import { IRole } from 'interfaces/role';
import { IServiceAccount } from 'interfaces/service-account'; import { IServiceAccount } from 'interfaces/service-account';
import { IMatrixPermission } from 'interfaces/permissions'; import { IMatrixPermission } from 'interfaces/permissions';
import { IPermission } from 'interfaces/user'; import { IPermission } from 'interfaces/user';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
interface IServiceAccountAccessMatrix { interface IServiceAccountAccessMatrix {
root: IMatrixPermission[]; root: IMatrixPermission[];
@ -30,7 +30,7 @@ interface IServiceAccountAccessMatrixOutput
} }
export const useServiceAccountAccessMatrix = ( export const useServiceAccountAccessMatrix = (
id: number, id?: number,
project?: string, project?: string,
environment?: string, environment?: string,
): IServiceAccountAccessMatrixOutput => { ): IServiceAccountAccessMatrixOutput => {
@ -39,10 +39,9 @@ export const useServiceAccountAccessMatrix = (
}`; }`;
const url = `api/admin/service-account/${id}/permissions${queryParams}`; const url = `api/admin/service-account/${id}/permissions${queryParams}`;
const { data, error, mutate } = useSWR<IServiceAccountAccessMatrixResponse>( const { data, error, mutate } = useConditionalSWR<
formatApiPath(url), IServiceAccountAccessMatrixResponse | undefined
fetcher, >(Boolean(id), undefined, formatApiPath(url), fetcher);
);
return useMemo( return useMemo(
() => ({ () => ({

View File

@ -0,0 +1,24 @@
type ActionDefinition = {
label: string;
permissions: string[];
required: string[];
};
export const ACTIONS = new Map<string, ActionDefinition>([
[
'TOGGLE_FEATURE_ON',
{
label: 'Enable flag',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
required: ['project', 'environment', 'featureName'],
},
],
[
'TOGGLE_FEATURE_OFF',
{
label: 'Disable flag',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
required: ['project', 'environment', 'featureName'],
},
],
]);