From 0090f7dbf8128a605ef2580cbf08f4f4870c6c0d Mon Sep 17 00:00:00 2001 From: Gowtham Shanmugasundaram Date: Thu, 7 Sep 2023 18:45:52 +0530 Subject: [PATCH] Added required field check in wizard flow Signed-off-by: Gowtham Shanmugasundaram --- locales/en/plugin__odf-console.json | 18 +-- .../assign-policy-view.tsx | 66 ++++---- .../helper/assign-policy-view-footer.tsx | 137 +++++++++++++++++ .../helper/pvc-details-wizard-content.tsx | 144 +++++++++++------- .../helper/select-policy-wizard-content.tsx | 14 +- .../modals/app-manage-policies/style.scss | 9 +- packages/mco/constants/disaster-recovery.ts | 12 ++ packages/mco/utils/common.ts | 3 + .../src/dropdown/singleselectdropdown.tsx | 2 + packages/shared/src/utils/NameValueEditor.tsx | 10 +- 10 files changed, 304 insertions(+), 111 deletions(-) create mode 100644 packages/mco/components/modals/app-manage-policies/helper/assign-policy-view-footer.tsx diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 6ffb51beb..f38805cc1 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -204,16 +204,14 @@ "Secure your application by assigning a policy from the available policy templates.": "Secure your application by assigning a policy from the available policy templates.", "Manage data policy": "Manage data policy", "Assign a policy to protect the application and to ensure a quick recovery.": "Assign a policy to protect the application and to ensure a quick recovery.", - "Policy": "Policy", - "PersistentVolumeClaim": "PersistentVolumeClaim", - "Review and assign": "Review and assign", - "Assign": "Assign", "New policy assigned to application.": "New policy assigned to application.", "Unable to assign policy to application.": "Unable to assign policy to application.", - "Next": "Next", - "Back": "Back", "Assign policy nav": "Assign policy nav", "Assign policy content": "Assign policy content", + "1 or more mandatory fields are empty. To proceed, fill in the required information.": "1 or more mandatory fields are empty. To proceed, fill in the required information.", + "Assign": "Assign", + "Next": "Next", + "Back": "Back", "Manage list view alert": "Manage list view alert", "Confirm unassign": "Confirm unassign", "All placements": "All placements", @@ -233,12 +231,12 @@ "No activity": "No activity", "No assigned data policy found": "No assigned data policy found", "Delete": "Delete", + "Required": "Required", "Select a placement": "Select a placement", "{{count}} selected_one": "{{count}} selected", "{{count}} selected_other": "{{count}} selected", "Select labels": "Select labels", "Use PVC label selectors to effortlessly specify the application resources that need protection.": "Use PVC label selectors to effortlessly specify the application resources that need protection.", - "If no label is provided, all PVCs will be protected. Define your preferences to protect specific resources.": "If no label is provided, all PVCs will be protected. Define your preferences to protect specific resources.", "Application resource": "Application resource", "Add application resource": "Add application resource", "Select a policy": "Select a policy", @@ -288,6 +286,9 @@ "minutes": "minutes", "hours": "hours", "days": "days", + "Policy": "Policy", + "PersistentVolumeClaim": "PersistentVolumeClaim", + "Review and assign": "Review and assign", "{{async}}, interval: {{interval}}": "{{async}}, interval: {{interval}}", "In use: {{targetClusters}}": "In use: {{targetClusters}}", "Used: {{targetClusters}}": "Used: {{targetClusters}}", @@ -1155,6 +1156,5 @@ "Cannot change resource name (original: \"{{name}}\", updated: \"{{newName}}\").": "Cannot change resource name (original: \"{{name}}\", updated: \"{{newName}}\").", "Cannot change resource namespace (original: \"{{namespace}}\", updated: \"{{newNamespace}}\").": "Cannot change resource namespace (original: \"{{namespace}}\", updated: \"{{newNamespace}}\").", "Cannot change resource kind (original: \"{{original}}\", updated: \"{{updated}}\").": "Cannot change resource kind (original: \"{{original}}\", updated: \"{{updated}}\").", - "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").": "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").", - "Required": "Required" + "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\").": "Cannot change API group (original: \"{{apiGroup}}\", updated: \"{{newAPIGroup}}\")." } diff --git a/packages/mco/components/modals/app-manage-policies/assign-policy-view.tsx b/packages/mco/components/modals/app-manage-policies/assign-policy-view.tsx index 93b022fa5..48f258f04 100644 --- a/packages/mco/components/modals/app-manage-policies/assign-policy-view.tsx +++ b/packages/mco/components/modals/app-manage-policies/assign-policy-view.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; +import { AssignPolicySteps, AssignPolicyStepsNames } from '@odf/mco/constants'; import { createRefFromK8Resource } from '@odf/mco/utils'; import { ModalBody } from '@odf/shared/modals/Modal'; -import { getName } from '@odf/shared/selectors'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { getErrorMessage } from '@odf/shared/utils'; import { TFunction } from 'i18next'; import { Wizard, WizardStep, AlertVariant } from '@patternfly/react-core'; +import { AssignPolicyViewFooter } from './helper/assign-policy-view-footer'; import { PolicyConfigViewer } from './helper/policy-config-viewer'; import { PVCDetailsWizardContent } from './helper/pvc-details-wizard-content'; import { SelectPolicyWizardContent } from './helper/select-policy-wizard-content'; @@ -32,7 +33,7 @@ export const createSteps = ( matchingPolicies: DRPolicyType[], state: AssignPolicyViewState, stepIdReached: number, - isAssignDisabled: boolean, + isValidationEnabled: boolean, t: TFunction, setPolicy: (policy?: DataPolicyType) => void, setDRPlacementControls: ( @@ -41,40 +42,36 @@ export const createSteps = ( ): WizardStep[] => [ { id: 1, - name: t('Policy'), + name: AssignPolicyStepsNames(t)[AssignPolicySteps.Policy], component: ( ), - enableNext: !!getName(state.policy), }, { id: 2, - name: t('PersistentVolumeClaim'), + name: AssignPolicyStepsNames(t)[AssignPolicySteps.PersistentVolumeClaim], component: ( ), canJumpTo: stepIdReached >= 2, - enableNext: !!state.policy?.placementControlInfo?.length, }, { id: 3, - name: t('Review and assign'), + name: AssignPolicyStepsNames(t)[AssignPolicySteps.ReviewAndAssign], component: , - nextButtonText: t('Assign'), canJumpTo: stepIdReached >= 3, - enableNext: !isAssignDisabled, - hideBackButton: isAssignDisabled, - hideCancelButton: isAssignDisabled, }, ]; @@ -89,16 +86,7 @@ export const AssignPolicyView: React.FC = ({ }) => { const { t } = useCustomTranslation(); const [stepIdReached, setStepIdReached] = React.useState(1); - const [isAssignDisabled, setAssignDisabled] = React.useState(false); - - const onNext = ({ id }: WizardStep) => { - if (id) { - if (typeof id === 'string') { - id = parseInt(id, 10); - } - setStepIdReached(stepIdReached < id ? id : stepIdReached); - } - }; + const [isValidationEnabled, setIsValidationEnabled] = React.useState(false); const setPolicy = (policy: DataPolicyType = null) => dispatch({ @@ -116,8 +104,7 @@ export const AssignPolicyView: React.FC = ({ payload: drPlacementControls, }); - const assignPolicy = () => { - setAssignDisabled(true); + const onSubmit = async () => { const updateContext = ( title: string, description: string, @@ -141,7 +128,7 @@ export const AssignPolicyView: React.FC = ({ }; // assign DRPolicy const promises = assignPromises(state.policy); - Promise.all(promises) + await Promise.all(promises) .then(() => { updateContext( t('New policy assigned to application.'), @@ -160,32 +147,39 @@ export const AssignPolicyView: React.FC = ({ }); }; + const onClose = () => { + setModalContext(ModalViewContext.POLICY_LIST_VIEW); + // reset policy info + setPolicy(); + }; + return ( { - setModalContext(ModalViewContext.POLICY_LIST_VIEW); - // reset policy info - setPolicy(); - }} steps={createSteps( applicaitonInfo.workloadNamespace, applicaitonInfo.placements, matchingPolicies, state, stepIdReached, - isAssignDisabled, + isValidationEnabled, t, setPolicy, setDRPlacementControls )} - onNext={onNext} + footer={ + + } height={450} /> diff --git a/packages/mco/components/modals/app-manage-policies/helper/assign-policy-view-footer.tsx b/packages/mco/components/modals/app-manage-policies/helper/assign-policy-view-footer.tsx new file mode 100644 index 000000000..07d139b99 --- /dev/null +++ b/packages/mco/components/modals/app-manage-policies/helper/assign-policy-view-footer.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { AssignPolicySteps, AssignPolicyStepsNames } from '@odf/mco/constants'; +import { getName } from '@odf/shared/selectors'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { TFunction } from 'i18next'; +import { + Button, + WizardContextType, + WizardContext, + WizardFooter, + Alert, + AlertVariant, +} from '@patternfly/react-core'; +import { DRPolicyType, DataPolicyType } from '../utils/types'; +import '../../../../style.scss'; +import '../style.scss'; + +const isPVCSelectorFound = (dataPolicy: DRPolicyType) => + !!dataPolicy?.placementControlInfo?.length && + !!dataPolicy.placementControlInfo.every((drpc) => !!drpc.pvcSelector?.length); + +const isDRPolicySelected = (dataPolicy: DRPolicyType) => !!getName(dataPolicy); + +const canJumpToNextStep = ( + stepName: string, + dataPolicy: DataPolicyType, + t: TFunction +) => { + switch (stepName) { + case AssignPolicyStepsNames(t)[AssignPolicySteps.Policy]: + return isDRPolicySelected(dataPolicy); + case AssignPolicyStepsNames(t)[AssignPolicySteps.PersistentVolumeClaim]: + return isPVCSelectorFound(dataPolicy); + default: + return false; + } +}; + +export const AssignPolicyViewFooter: React.FC = ({ + dataPolicy, + stepIdReached, + isValidationEnabled, + setStepIdReached, + onSubmit, + onCancel, + setIsValidationEnabled, +}) => { + const { t } = useCustomTranslation(); + const [requestInProgress, setRequestInProgress] = React.useState(false); + const { activeStep, onNext, onBack } = + React.useContext(WizardContext); + + const stepId = activeStep.id as number; + const stepName = activeStep.name as string; + + const canJumpToNext = canJumpToNextStep(stepName, dataPolicy, t); + const validationError = isValidationEnabled && !canJumpToNext; + + const moveToNextStep = () => { + if (canJumpToNext) { + setStepIdReached(stepIdReached <= stepId ? stepId + 1 : stepIdReached); + onNext(); + setIsValidationEnabled(false); + } else { + setIsValidationEnabled(true); + } + }; + + const handleNext = async () => { + switch (stepName) { + case AssignPolicyStepsNames(t)[AssignPolicySteps.ReviewAndAssign]: + setRequestInProgress(true); + await onSubmit(); + setRequestInProgress(false); + break; + default: + moveToNextStep(); + } + }; + + return ( + <> + {validationError && ( + + )} + + + {/* Disabling the back button for the first step (Policy) in wizard */} + + + + + ); +}; + +type AssignPolicyViewFooterProps = { + dataPolicy: DataPolicyType; + stepIdReached: number; + isValidationEnabled: boolean; + setStepIdReached: React.Dispatch>; + onSubmit: () => Promise; + onCancel: () => void; + setIsValidationEnabled: React.Dispatch>; +}; diff --git a/packages/mco/components/modals/app-manage-policies/helper/pvc-details-wizard-content.tsx b/packages/mco/components/modals/app-manage-policies/helper/pvc-details-wizard-content.tsx index 41e510c8c..27cc41008 100644 --- a/packages/mco/components/modals/app-manage-policies/helper/pvc-details-wizard-content.tsx +++ b/packages/mco/components/modals/app-manage-policies/helper/pvc-details-wizard-content.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useACMSafeFetch } from '@odf/mco/hooks/acm-safe-fetch'; import { DRPlacementControlModel } from '@odf/mco/models'; import { SearchResult } from '@odf/mco/types'; +import { getValidatedProp } from '@odf/mco/utils'; import { MultiSelectDropdown } from '@odf/shared/dropdown/multiselectdropdown'; import { SingleSelectDropdown } from '@odf/shared/dropdown/singleselectdropdown'; import { getName, getNamespace } from '@odf/shared/selectors'; @@ -14,8 +15,6 @@ import { import { ObjectReference } from '@openshift-console/dynamic-plugin-sdk'; import * as _ from 'lodash-es'; import { - Alert, - AlertVariant, Button, Form, FormGroup, @@ -38,7 +37,9 @@ const findPlacement = (placements: PlacementType[], name: string) => const getPlacementTags = (drpcs: DRPlacementControlType[]) => !!drpcs.length - ? drpcs.map((drpc) => [getName(drpc.placementInfo), drpc?.pvcSelector]) + ? drpcs.map((drpc) => + !!drpc ? [getName(drpc.placementInfo), drpc?.pvcSelector] : [] + ) : [[]]; const getLabelsFromSearchResult = (searchResult: SearchResult): string[] => { @@ -124,8 +125,11 @@ const PairElement: React.FC = ({ placementNames, labels, tags, - createPlacementControlInfo, + isValidationEnabled, + placementControInfo, + setPlacementControlInfo, updatePlacementControlInfo, + unSetPlacementControlInfo, }: extraProps = extraProps; const selectedPlacement = pair[NameValueEditorPair.Name]; const selectedLabels = pair[NameValueEditorPair.Value]; @@ -136,6 +140,11 @@ const PairElement: React.FC = ({ ); + React.useEffect(() => { + // Initialize the placementControInfo for the index with empty object + !placementControInfo?.[index] && setPlacementControlInfo('', index); + }, [placementControInfo, index, setPlacementControlInfo]); + const onChangePlacement = React.useCallback( (placement: string) => { onChange( @@ -143,9 +152,9 @@ const PairElement: React.FC = ({ index, NameValueEditorPair.Name ); - createPlacementControlInfo(placement, index); + setPlacementControlInfo(placement, index); }, - [index, onChange, createPlacementControlInfo] + [index, onChange, setPlacementControlInfo] ); const onChangeValue = React.useCallback( @@ -158,12 +167,18 @@ const PairElement: React.FC = ({ const onRemove = React.useCallback(() => { onRemoveProp(index); - createPlacementControlInfo('', index); - }, [index, onRemoveProp, createPlacementControlInfo]); + unSetPlacementControlInfo(index); + }, [index, onRemoveProp, unSetPlacementControlInfo]); return ( -
-
+
+ = ({ )} placeholderText={t('Select a placement')} onChange={onChangePlacement} + required + validated={getValidatedProp( + isValidationEnabled && !selectedPlacement + )} /> -
-
+ + = ({ : t('Select labels') } variant={SelectVariant.checkbox} + required + validated={getValidatedProp( + isValidationEnabled && !selectedLabels?.length + )} /> -
-
+ + -
+
); }; @@ -216,6 +247,7 @@ export const PVCDetailsWizardContent: React.FC = unProtectedPlacements, policyRef, workloadNamespace, + isValidationEnabled, setDRPlacementControls, }) => { const { t } = useCustomTranslation(); @@ -251,20 +283,15 @@ export const PVCDetailsWizardContent: React.FC = [unProtectedPlacements] ); - const createPlacementControlInfo = React.useCallback( + const setPlacementControlInfo = React.useCallback( (placementName: string, index: number) => { - if (!placementName) { - // unselect - selectedPlacementControls.splice(index, 1); - } else { - // select - const placement = findPlacement(unProtectedPlacements, placementName); - const drPlacementControlObj = createDRPlacementControlObj( - placement, - policyRef - ); - selectedPlacementControls[index] = drPlacementControlObj; - } + // select + const placement = findPlacement(unProtectedPlacements, placementName); + const drPlacementControlObj = createDRPlacementControlObj( + placement, + policyRef + ); + selectedPlacementControls[index] = drPlacementControlObj; setDRPlacementControls(selectedPlacementControls); }, [ @@ -286,6 +313,14 @@ export const PVCDetailsWizardContent: React.FC = [selectedPlacementControls, setDRPlacementControls] ); + const unSetPlacementControlInfo = React.useCallback( + (index: number) => { + selectedPlacementControls.splice(index, 1); + setDRPlacementControls(selectedPlacementControls); + }, + [selectedPlacementControls, setDRPlacementControls] + ); + return (
@@ -295,34 +330,25 @@ export const PVCDetailsWizardContent: React.FC = )} - - - - - setTags(nameValuePairs)} - PairElementComponent={PairElement} - nameString={t('Application resource')} - valueString={t('PVC label selector')} - addString={t('Add application resource')} - isAddDisabled={tags.length !== selectedPlacementControls.length} - extraProps={{ - placementNames, - labels, - tags, - createPlacementControlInfo, - updatePlacementControlInfo, - }} - /> - + setTags(nameValuePairs)} + PairElementComponent={PairElement} + nameString={t('Application resource')} + valueString={t('PVC label selector')} + addString={t('Add application resource')} + extraProps={{ + placementNames, + labels, + tags, + isValidationEnabled, + placementControInfo, + setPlacementControlInfo, + updatePlacementControlInfo, + unSetPlacementControlInfo, + }} + className="co-required mco-manage-policies__nameValue--weight" + /> ); }; @@ -333,8 +359,11 @@ type extraProps = { placementNames: string[]; labels: string[]; tags: TagsType; - createPlacementControlInfo: (placementName: string, index: number) => void; + isValidationEnabled: boolean; + placementControInfo: DRPlacementControlType[]; + setPlacementControlInfo: (placementName: string, index: number) => void; updatePlacementControlInfo: (labels: string[], index: number) => void; + unSetPlacementControlInfo: (index: number) => void; }; type PVCDetailsWizardContentProps = { @@ -342,6 +371,7 @@ type PVCDetailsWizardContentProps = { unProtectedPlacements: PlacementType[]; policyRef: ObjectReference; workloadNamespace: string; + isValidationEnabled: boolean; setDRPlacementControls: ( drPlacementControls: DRPlacementControlType[] ) => void; diff --git a/packages/mco/components/modals/app-manage-policies/helper/select-policy-wizard-content.tsx b/packages/mco/components/modals/app-manage-policies/helper/select-policy-wizard-content.tsx index 16d3f7268..404d0de65 100644 --- a/packages/mco/components/modals/app-manage-policies/helper/select-policy-wizard-content.tsx +++ b/packages/mco/components/modals/app-manage-policies/helper/select-policy-wizard-content.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { getValidatedProp } from '@odf/mco/utils'; import { SingleSelectDropdown } from '@odf/shared/dropdown/singleselectdropdown'; import { getName } from '@odf/shared/selectors'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; @@ -14,23 +15,27 @@ const findPolicy = (name: string, dataPolicies: DRPolicyType[]) => dataPolicies.find((policy) => getName(policy) === name); export const SelectPolicyWizardContent: React.FC = - ({ policy, matchingPolicies, setPolicy }) => { + ({ policy, matchingPolicies, isValidationEnabled, setPolicy }) => { const { t } = useCustomTranslation(); + const name = getName(policy); return (
{ - if (getName(policy) !== value) { + if (name !== value) { setPolicy(findPolicy(value, matchingPolicies)); } }} @@ -43,5 +48,6 @@ export const SelectPolicyWizardContent: React.FC type SelectPolicyWizardContentProps = { policy: DataPolicyType; matchingPolicies: DRPolicyType[]; + isValidationEnabled: boolean; setPolicy: (policy: DataPolicyType) => void; }; diff --git a/packages/mco/components/modals/app-manage-policies/style.scss b/packages/mco/components/modals/app-manage-policies/style.scss index b1ca634c5..9d48501fd 100644 --- a/packages/mco/components/modals/app-manage-policies/style.scss +++ b/packages/mco/components/modals/app-manage-policies/style.scss @@ -17,10 +17,13 @@ &__form--width { width: 50%; } - &__alert--margin-bottom { - margin-bottom: var(--pf-global--spacer--xs) !important; - } &__emptyState---margin-bottom { margin-bottom: var(--pf-global--spacer--lg); } + &__nameValue--weight { + font-weight: var(--pf-global--FontWeight--bold) + } + &__alert--margin-left { + margin-left: var(--pf-global--spacer--lg); + } } diff --git a/packages/mco/constants/disaster-recovery.ts b/packages/mco/constants/disaster-recovery.ts index cfbca1c84..c03249fbd 100644 --- a/packages/mco/constants/disaster-recovery.ts +++ b/packages/mco/constants/disaster-recovery.ts @@ -67,3 +67,15 @@ export const SYNC_SCHEDULE_DISPLAY_TEXT = ( [TIME_UNITS.Hours]: t('hours'), [TIME_UNITS.Days]: t('days'), }); + +// Asisgn policy wizard steps +export enum AssignPolicySteps { + Policy = 'policy', + PersistentVolumeClaim = 'persistent-volume-claim', + ReviewAndAssign = 'review-and-assign', +} +export const AssignPolicyStepsNames = (t: TFunction) => ({ + [AssignPolicySteps.Policy]: t('Policy'), + [AssignPolicySteps.PersistentVolumeClaim]: t('PersistentVolumeClaim'), + [AssignPolicySteps.ReviewAndAssign]: t('Review and assign'), +}); diff --git a/packages/mco/utils/common.ts b/packages/mco/utils/common.ts index a55ae624e..ce3d8d177 100644 --- a/packages/mco/utils/common.ts +++ b/packages/mco/utils/common.ts @@ -35,3 +35,6 @@ export const getMajorVersion = (version: string): string => { ? version.split('.')[0] + '.' + version.split('.')[1] + '.0' : ''; }; + +export const getValidatedProp = (error: boolean) => + error ? 'error' : 'default'; diff --git a/packages/shared/src/dropdown/singleselectdropdown.tsx b/packages/shared/src/dropdown/singleselectdropdown.tsx index b22444c2d..99ae8dcdc 100644 --- a/packages/shared/src/dropdown/singleselectdropdown.tsx +++ b/packages/shared/src/dropdown/singleselectdropdown.tsx @@ -63,4 +63,6 @@ export type SingleSelectDropdownProps = { onFilter?: SelectProps['onFilter']; hasInlineFilter?: SelectProps['hasInlineFilter']; isDisabled?: boolean; + validated?: 'success' | 'warning' | 'error' | 'default'; + required?: boolean; }; diff --git a/packages/shared/src/utils/NameValueEditor.tsx b/packages/shared/src/utils/NameValueEditor.tsx index e0c6e656d..b19f578f8 100644 --- a/packages/shared/src/utils/NameValueEditor.tsx +++ b/packages/shared/src/utils/NameValueEditor.tsx @@ -52,6 +52,7 @@ type NameValueEditorProps = { onLastItemRemoved: () => void; extraProps?: any; isAddDisabled?: boolean; + className?: string; }; export const enum NameValueEditorPair { @@ -208,6 +209,7 @@ export const NameValueEditor: React.FC = valueString, extraProps, isAddDisabled, + className, PairElementComponent = PairElement, }) => { const { t } = useCustomTranslation(); @@ -307,8 +309,12 @@ export const NameValueEditor: React.FC = {!readOnly && allowSorting && (
)} -
{nameStringUpdated}
-
{valueStringUpdated}
+
+ {nameStringUpdated} +
+
+ {valueStringUpdated} +
{pairElems}