Wizard: validate wizard with Redux Hook
Use redux hook to validate the form. This gives us single point of contact for "is the data valid?" while not requiring every redux action touching form data to perform validation. It's not perfect and might be improved when using external library solving the problems we're having.
This commit is contained in:
parent
fad7648d38
commit
4692eae454
10 changed files with 150 additions and 233 deletions
|
|
@ -1,18 +1,16 @@
|
|||
import React, { useEffect, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Wizard,
|
||||
WizardFooterWrapper,
|
||||
WizardStep,
|
||||
WizardStepType,
|
||||
useWizardContext,
|
||||
} from '@patternfly/react-core';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import DetailsStep from './steps/Details';
|
||||
import FileSystemStep from './steps/FileSystem';
|
||||
import { FileSystemStepFooter } from './steps/FileSystem/FileSystemConfiguration';
|
||||
import FileSystemStep, { FileSystemContext } from './steps/FileSystem';
|
||||
import FirstBootStep from './steps/FirstBoot';
|
||||
import ImageOutputStep from './steps/ImageOutput';
|
||||
import OscapStep from './steps/Oscap';
|
||||
|
|
@ -25,6 +23,10 @@ import SnapshotStep from './steps/Snapshot';
|
|||
import Aws from './steps/TargetEnvironment/Aws';
|
||||
import Azure from './steps/TargetEnvironment/Azure';
|
||||
import Gcp from './steps/TargetEnvironment/Gcp';
|
||||
import {
|
||||
useFilesystemValidation,
|
||||
useDetailsValidation,
|
||||
} from './utilities/useValidation';
|
||||
import {
|
||||
isAwsAccountIdValid,
|
||||
isAzureTenantGUIDValid,
|
||||
|
|
@ -54,7 +56,6 @@ import {
|
|||
selectGcpShareMethod,
|
||||
selectImageTypes,
|
||||
selectRegistrationType,
|
||||
selectStepValidation,
|
||||
addImageType,
|
||||
selectSnapshotDate,
|
||||
selectUseLatest,
|
||||
|
|
@ -67,11 +68,13 @@ import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
|
|||
type CustomWizardFooterPropType = {
|
||||
disableBack?: boolean;
|
||||
disableNext: boolean;
|
||||
beforeNext?: () => boolean;
|
||||
};
|
||||
|
||||
export const CustomWizardFooter = ({
|
||||
disableBack: disableBack,
|
||||
disableNext: disableNext,
|
||||
beforeNext,
|
||||
}: CustomWizardFooterPropType) => {
|
||||
const { goToNextStep, goToPrevStep, close } = useWizardContext();
|
||||
return (
|
||||
|
|
@ -79,7 +82,9 @@ export const CustomWizardFooter = ({
|
|||
<Button
|
||||
ouiaId="wizard-next-btn"
|
||||
variant="primary"
|
||||
onClick={goToNextStep}
|
||||
onClick={() => {
|
||||
if (!beforeNext || beforeNext()) goToNextStep();
|
||||
}}
|
||||
isDisabled={disableNext}
|
||||
>
|
||||
Next
|
||||
|
|
@ -172,15 +177,11 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
|
|||
|
||||
const snapshotStepRequiresChoice = !useLatest && !snapshotDate;
|
||||
|
||||
const [currentStep, setCurrentStep] = React.useState<WizardStepType>();
|
||||
const onStepChange = (
|
||||
_event: React.MouseEvent<HTMLButtonElement>,
|
||||
currentStep: WizardStepType
|
||||
) => setCurrentStep(currentStep);
|
||||
const detailsValidation = useDetailsValidation();
|
||||
const fileSystemValidation = useFilesystemValidation();
|
||||
const [filesystemPristine, setFilesystemPristine] = useState(true);
|
||||
|
||||
const detailsValidation = useAppSelector(selectStepValidation('details'));
|
||||
let startIndex = 1; // default index
|
||||
|
||||
if (isEdit) {
|
||||
if (snapshottingEnabled) {
|
||||
startIndex = isFirstBootEnabled ? 15 : 14;
|
||||
|
|
@ -196,7 +197,6 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
|
|||
<Wizard
|
||||
startIndex={startIndex}
|
||||
onClose={() => navigate(resolveRelPath(''))}
|
||||
onStepChange={onStepChange}
|
||||
isVisitRequired
|
||||
>
|
||||
<WizardStep
|
||||
|
|
@ -302,9 +302,24 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
|
|||
<WizardStep
|
||||
name="File system configuration"
|
||||
id="step-file-system"
|
||||
footer={<FileSystemStepFooter />}
|
||||
footer={
|
||||
<CustomWizardFooter
|
||||
beforeNext={() => {
|
||||
if (fileSystemValidation.disabledNext) {
|
||||
setFilesystemPristine(false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disableNext={
|
||||
!filesystemPristine && fileSystemValidation.disabledNext
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FileSystemStep />
|
||||
<FileSystemContext.Provider value={filesystemPristine}>
|
||||
<FileSystemStep />
|
||||
</FileSystemContext.Provider>
|
||||
</WizardStep>
|
||||
<WizardStep
|
||||
name="Content"
|
||||
|
|
@ -358,17 +373,11 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
|
|||
)}
|
||||
<WizardStep
|
||||
name="Details"
|
||||
id="step-details"
|
||||
status={
|
||||
currentStep?.id !== 'step-details' &&
|
||||
detailsValidation === 'error'
|
||||
? 'error'
|
||||
: 'default'
|
||||
}
|
||||
id={'step-details'}
|
||||
isDisabled={snapshotStepRequiresChoice}
|
||||
footer={
|
||||
<CustomWizardFooter
|
||||
disableNext={detailsValidation !== 'success'}
|
||||
disableNext={detailsValidation.disabledNext}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,7 @@ import {
|
|||
TextInputProps,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
setStepInputValidation,
|
||||
selectInputValidation,
|
||||
} from '../../store/wizardSlice';
|
||||
import { StepValidation } from './utilities/useValidation';
|
||||
|
||||
interface ValidatedTextInputPropTypes extends TextInputProps {
|
||||
dataTestId?: string | undefined;
|
||||
|
|
@ -23,63 +19,38 @@ interface ValidatedTextInputPropTypes extends TextInputProps {
|
|||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface StateValidatedTextInputPropTypes extends TextInputProps {
|
||||
interface HookValidatedTextInputPropTypes extends TextInputProps {
|
||||
dataTestId?: string | undefined;
|
||||
ouiaId?: string;
|
||||
stepId: string;
|
||||
inputId: string;
|
||||
ariaLabel: string | undefined;
|
||||
helperText: string | undefined;
|
||||
validator: (value: string | undefined) => boolean;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
stepValidation: StepValidation;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export const StateValidatedInput = ({
|
||||
export const HookValidatedInput = ({
|
||||
dataTestId,
|
||||
ouiaId,
|
||||
stepId,
|
||||
inputId,
|
||||
ariaLabel,
|
||||
helperText,
|
||||
validator,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: StateValidatedTextInputPropTypes) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const validatedState = useAppSelector(selectInputValidation(stepId, inputId));
|
||||
stepValidation,
|
||||
fieldName,
|
||||
}: HookValidatedTextInputPropTypes) => {
|
||||
const [isPristine, setIsPristine] = useState(!value ? true : false);
|
||||
// Do not surface validation on pristine state components
|
||||
const validated = isPristine ? 'default' : validatedState;
|
||||
const validated = isPristine
|
||||
? 'default'
|
||||
: stepValidation.errors[fieldName]
|
||||
? 'error'
|
||||
: 'success';
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsPristine(false);
|
||||
const isValid = validator(value);
|
||||
dispatch(
|
||||
setStepInputValidation({
|
||||
stepId,
|
||||
inputId,
|
||||
isValid,
|
||||
errorText: isValid ? helperText : undefined,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedOnChange = (
|
||||
evt: React.FormEvent<HTMLInputElement>,
|
||||
newVal: string
|
||||
) => {
|
||||
if (onChange) onChange(evt, newVal);
|
||||
const isValid = validator(newVal);
|
||||
dispatch(
|
||||
setStepInputValidation({
|
||||
stepId,
|
||||
inputId,
|
||||
isValid,
|
||||
errorText: isValid ? helperText : undefined,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -89,7 +60,7 @@ export const StateValidatedInput = ({
|
|||
data-testid={dataTestId}
|
||||
ouiaId={ouiaId}
|
||||
type="text"
|
||||
onChange={wrappedOnChange}
|
||||
onChange={onChange}
|
||||
validated={validated}
|
||||
aria-label={ariaLabel}
|
||||
onBlur={handleBlur}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,8 @@ import {
|
|||
selectBlueprintDescription,
|
||||
selectBlueprintName,
|
||||
} from '../../../../store/wizardSlice';
|
||||
import { StateValidatedInput } from '../../ValidatedTextInput';
|
||||
import {
|
||||
isBlueprintDescriptionValid,
|
||||
isBlueprintNameValid,
|
||||
} from '../../validators';
|
||||
import { useDetailsValidation } from '../../utilities/useValidation';
|
||||
import { HookValidatedInput } from '../../ValidatedTextInput';
|
||||
|
||||
const DetailsStep = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
|
@ -41,6 +38,8 @@ const DetailsStep = () => {
|
|||
dispatch(changeBlueprintDescription(description));
|
||||
};
|
||||
|
||||
const stepValidation = useDetailsValidation();
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Title headingLevel="h1" size="xl">
|
||||
|
|
@ -52,16 +51,15 @@ const DetailsStep = () => {
|
|||
blueprint.
|
||||
</Text>
|
||||
<FormGroup isRequired label="Blueprint name" fieldId="blueprint-name">
|
||||
<StateValidatedInput
|
||||
<HookValidatedInput
|
||||
ariaLabel="blueprint name"
|
||||
dataTestId="blueprint"
|
||||
stepId="details"
|
||||
inputId="blueprint-name"
|
||||
value={blueprintName}
|
||||
validator={isBlueprintNameValid}
|
||||
onChange={handleNameChange}
|
||||
helperText="Please enter a valid name"
|
||||
placeholder="Add blueprint name"
|
||||
stepValidation={stepValidation}
|
||||
fieldName="name"
|
||||
/>
|
||||
<FormHelperText>
|
||||
<HelperText>
|
||||
|
|
@ -76,16 +74,15 @@ const DetailsStep = () => {
|
|||
label="Blueprint description"
|
||||
fieldId="blueprint-description-name"
|
||||
>
|
||||
<StateValidatedInput
|
||||
<HookValidatedInput
|
||||
ariaLabel="blueprint description"
|
||||
dataTestId="blueprint description"
|
||||
stepId="details"
|
||||
inputId="blueprint-description"
|
||||
value={blueprintDescription || ''}
|
||||
validator={isBlueprintDescriptionValid}
|
||||
onChange={handleDescriptionChange}
|
||||
helperText="Please enter a valid description"
|
||||
placeholder="Add description"
|
||||
stepValidation={stepValidation}
|
||||
fieldName="description"
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -7,8 +7,6 @@ import {
|
|||
TextContent,
|
||||
TextInput,
|
||||
TextVariants,
|
||||
useWizardContext,
|
||||
WizardFooterWrapper,
|
||||
} from '@patternfly/react-core';
|
||||
import { Select, SelectOption } from '@patternfly/react-core/deprecated';
|
||||
import { MinusCircleIcon, PlusCircleIcon } from '@patternfly/react-icons';
|
||||
|
|
@ -33,17 +31,12 @@ import {
|
|||
removePartition,
|
||||
selectPartitions,
|
||||
changePartitionUnit,
|
||||
setIsNextButtonTouched,
|
||||
selectIsNextButtonTouched,
|
||||
selectFileSystemPartitionMode,
|
||||
} from '../../../../store/wizardSlice';
|
||||
import UsrSubDirectoriesDisabled from '../../UsrSubDirectoriesDisabled';
|
||||
import { ValidatedTextInput } from '../../ValidatedTextInput';
|
||||
import {
|
||||
getDuplicateMountPoints,
|
||||
isFileSystemConfigValid,
|
||||
isMountpointMinSizeValid,
|
||||
} from '../../validators';
|
||||
import { useFilesystemValidation } from '../../utilities/useValidation';
|
||||
import { HookValidatedInput } from '../../ValidatedTextInput';
|
||||
|
||||
import { FileSystemContext } from './index';
|
||||
|
||||
export type Partition = {
|
||||
id: string;
|
||||
|
|
@ -52,47 +45,6 @@ export type Partition = {
|
|||
unit: Units;
|
||||
};
|
||||
|
||||
export const FileSystemStepFooter = () => {
|
||||
const { goToNextStep, goToPrevStep, close } = useWizardContext();
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const [isNextDisabled, setNextDisabled] = useState(false);
|
||||
const fileSystemPartitionMode = useAppSelector(selectFileSystemPartitionMode);
|
||||
const partitions = useAppSelector(selectPartitions);
|
||||
|
||||
const onValidate = () => {
|
||||
dispatch(setIsNextButtonTouched(false));
|
||||
if (!isValid) {
|
||||
setNextDisabled(true);
|
||||
} else {
|
||||
goToNextStep();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (
|
||||
fileSystemPartitionMode === 'automatic' ||
|
||||
isFileSystemConfigValid(partitions)
|
||||
) {
|
||||
setIsValid(true);
|
||||
} else setIsValid(false);
|
||||
setNextDisabled(false);
|
||||
dispatch(setIsNextButtonTouched(true));
|
||||
}, [partitions, fileSystemPartitionMode, dispatch]);
|
||||
return (
|
||||
<WizardFooterWrapper>
|
||||
<Button onClick={onValidate} isDisabled={isNextDisabled}>
|
||||
Next
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={goToPrevStep}>
|
||||
Back
|
||||
</Button>
|
||||
<Button ouiaId="wizard-cancel-btn" variant="link" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WizardFooterWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const FileSystemConfiguration = () => {
|
||||
const partitions = useAppSelector(selectPartitions);
|
||||
const environments = useAppSelector(selectImageTypes);
|
||||
|
|
@ -193,12 +145,11 @@ export const Row = ({
|
|||
onDrop,
|
||||
}: RowPropTypes) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const partitions = useAppSelector(selectPartitions);
|
||||
const handleRemovePartition = (id: string) => {
|
||||
dispatch(removePartition(id));
|
||||
};
|
||||
const isNextButtonPristine = useAppSelector(selectIsNextButtonTouched);
|
||||
const duplicates = getDuplicateMountPoints(partitions);
|
||||
const stepValidation = useFilesystemValidation();
|
||||
const isPristine = React.useContext(FileSystemContext);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
|
|
@ -215,15 +166,14 @@ export const Row = ({
|
|||
/>
|
||||
<Td className="pf-m-width-20">
|
||||
<MountpointPrefix partition={partition} />
|
||||
{!isNextButtonPristine &&
|
||||
duplicates.indexOf(partition.mountpoint) !== -1 && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
isInline
|
||||
isPlain
|
||||
title="Duplicate mount point."
|
||||
/>
|
||||
)}
|
||||
{!isPristine && stepValidation.errors[`mountpoint-${partition.id}`] && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
isInline
|
||||
isPlain
|
||||
title={stepValidation.errors[`mountpoint-${partition.id}`]}
|
||||
/>
|
||||
)}
|
||||
</Td>
|
||||
{partition.mountpoint !== '/' &&
|
||||
!partition.mountpoint.startsWith('/boot') &&
|
||||
|
|
@ -352,15 +302,17 @@ export const getConversionFactor = (units: Units) => {
|
|||
|
||||
const MinimumSize = ({ partition }: MinimumSizePropTypes) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const stepValidation = useFilesystemValidation();
|
||||
|
||||
return (
|
||||
<ValidatedTextInput
|
||||
<HookValidatedInput
|
||||
ariaLabel="minimum partition size"
|
||||
helperText="Must be larger than 0"
|
||||
validator={isMountpointMinSizeValid}
|
||||
value={partition.min_size}
|
||||
type="text"
|
||||
ouiaId="size"
|
||||
stepValidation={stepValidation}
|
||||
fieldName={`min-size-${partition.id}`}
|
||||
onChange={(event, minSize) => {
|
||||
if (minSize === '' || /^\d+$/.test(minSize)) {
|
||||
dispatch(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { selectFileSystemPartitionMode } from '../../../../store/wizardSlice';
|
|||
import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly';
|
||||
export type FileSystemPartitionMode = 'automatic' | 'manual';
|
||||
|
||||
export const FileSystemContext = React.createContext<boolean>(true);
|
||||
|
||||
const FileSystemStep = () => {
|
||||
const fileSystemPartitionMode = useAppSelector(selectFileSystemPartitionMode);
|
||||
const hasIsoTargetOnly = useHasSpecificTargetOnly('image-installer');
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||
import { CreateSaveAndBuildBtn, CreateSaveButton } from './CreateDropdown';
|
||||
import { EditSaveAndBuildBtn, EditSaveButton } from './EditDropdown';
|
||||
|
||||
import { useServerStore, useAppSelector } from '../../../../../store/hooks';
|
||||
import { useServerStore } from '../../../../../store/hooks';
|
||||
import {
|
||||
useCreateBlueprintMutation,
|
||||
useUpdateBlueprintMutation,
|
||||
} from '../../../../../store/imageBuilderApi';
|
||||
import { selectIsValid } from '../../../../../store/wizardSlice';
|
||||
import { resolveRelPath } from '../../../../../Utilities/path';
|
||||
import { mapRequestFromState } from '../../../utilities/requestMapper';
|
||||
import { useIsBlueprintValid } from '../../../utilities/useValidation';
|
||||
|
||||
const ReviewWizardFooter = () => {
|
||||
const { goToPrevStep, close } = useWizardContext();
|
||||
|
|
@ -41,7 +41,7 @@ const ReviewWizardFooter = () => {
|
|||
setIsOpen(!isOpen);
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const isValid = useAppSelector(selectIsValid);
|
||||
const isValid = useIsBlueprintValid();
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuccess || isCreateSuccess) {
|
||||
|
|
|
|||
|
|
@ -170,12 +170,10 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
|
|||
partitions: request.customizations.filesystem.map((fs) =>
|
||||
convertFilesystemToPartition(fs)
|
||||
),
|
||||
isNextButtonTouched: true,
|
||||
}
|
||||
: {
|
||||
mode: 'automatic' as FileSystemPartitionMode,
|
||||
partitions: [],
|
||||
isNextButtonTouched: true,
|
||||
};
|
||||
|
||||
const arch = request.image_requests[0].architecture;
|
||||
|
|
@ -259,7 +257,6 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
|
|||
repository: '',
|
||||
package_list: [],
|
||||
})) || [],
|
||||
stepValidations: {},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
selectBlueprintName,
|
||||
selectBlueprintDescription,
|
||||
selectFileSystemPartitionMode,
|
||||
selectPartitions,
|
||||
} from '../../../store/wizardSlice';
|
||||
import {
|
||||
getDuplicateMountPoints,
|
||||
isBlueprintNameValid,
|
||||
isBlueprintDescriptionValid,
|
||||
isMountpointMinSizeValid,
|
||||
} from '../validators';
|
||||
|
||||
export type StepValidation = {
|
||||
errors: {
|
||||
[key: string]: string;
|
||||
};
|
||||
disabledNext: boolean;
|
||||
};
|
||||
|
||||
export function useIsBlueprintValid(): boolean {
|
||||
const filesystem = useFilesystemValidation();
|
||||
const details = useDetailsValidation();
|
||||
return !filesystem.disabledNext && !details.disabledNext;
|
||||
}
|
||||
|
||||
export function useFilesystemValidation(): StepValidation {
|
||||
const mode = useAppSelector(selectFileSystemPartitionMode);
|
||||
const partitions = useAppSelector(selectPartitions);
|
||||
let disabledNext = false;
|
||||
|
||||
const errors: { [key: string]: string } = {};
|
||||
if (mode === 'automatic') {
|
||||
return { errors, disabledNext: false };
|
||||
}
|
||||
|
||||
const duplicates = getDuplicateMountPoints(partitions);
|
||||
for (const partition of partitions) {
|
||||
if (!isMountpointMinSizeValid(partition.min_size)) {
|
||||
errors[`min-size-${partition.id}`] = 'Invalid size';
|
||||
disabledNext = true;
|
||||
}
|
||||
if (duplicates.includes(partition.mountpoint)) {
|
||||
errors[`mountpoint-${partition.id}`] = 'Duplicate mount points';
|
||||
disabledNext = true;
|
||||
}
|
||||
}
|
||||
return { errors, disabledNext };
|
||||
}
|
||||
|
||||
export function useDetailsValidation(): StepValidation {
|
||||
const name = useAppSelector(selectBlueprintName);
|
||||
const description = useAppSelector(selectBlueprintDescription);
|
||||
|
||||
const nameValid = isBlueprintNameValid(name);
|
||||
const descriptionValid = isBlueprintDescriptionValid(description);
|
||||
return {
|
||||
errors: {
|
||||
name: nameValid ? '' : 'Invalid name',
|
||||
description: descriptionValid ? '' : 'Invalid description',
|
||||
},
|
||||
disabledNext: !nameValid || !descriptionValid,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue