mirror of
https://github.com/ansible/awx.git
synced 2026-03-02 09:18:48 -03:30
working on prompts validation
This commit is contained in:
@@ -12,15 +12,19 @@ import CredentialChip from '@components/CredentialChip';
|
|||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import { getQSConfig, parseQueryString } from '@util/qs';
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
import useRequest from '@util/useRequest';
|
import useRequest from '@util/useRequest';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('inventory', {
|
const QS_CONFIG = getQSConfig('credential', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
});
|
});
|
||||||
|
|
||||||
function CredentialsStep({ i18n }) {
|
function CredentialsStep({ i18n }) {
|
||||||
const [field, , helpers] = useField('credentials');
|
const [field, , helpers] = useField({
|
||||||
|
name: 'credentials',
|
||||||
|
validate: required(null, i18n),
|
||||||
|
});
|
||||||
const [selectedType, setSelectedType] = useState(null);
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import useRequest from '@util/useRequest';
|
|||||||
import OptionsList from '@components/OptionsList';
|
import OptionsList from '@components/OptionsList';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('inventory', {
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -17,7 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function InventoryStep({ i18n }) {
|
function InventoryStep({ i18n }) {
|
||||||
const [field, , helpers] = useField('inventory');
|
const [field, , helpers] = useField({
|
||||||
|
name: 'inventory',
|
||||||
|
validate: required(null, i18n),
|
||||||
|
});
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Wizard } from '@patternfly/react-core';
|
import { Wizard } from '@patternfly/react-core';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -6,14 +6,61 @@ import { Formik } from 'formik';
|
|||||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
|
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
|
||||||
import useRequest from '@util/useRequest';
|
import useRequest from '@util/useRequest';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import { required } from '@util/validators';
|
||||||
import InventoryStep from './InventoryStep';
|
import InventoryStep from './InventoryStep';
|
||||||
import CredentialsStep from './CredentialsStep';
|
import CredentialsStep from './CredentialsStep';
|
||||||
import OtherPromptsStep from './OtherPromptsStep';
|
import OtherPromptsStep from './OtherPromptsStep';
|
||||||
import SurveyStep from './SurveyStep';
|
import SurveyStep from './SurveyStep';
|
||||||
import PreviewStep from './PreviewStep';
|
import PreviewStep from './PreviewStep';
|
||||||
|
import PromptFooter from './PromptFooter';
|
||||||
import mergeExtraVars from './mergeExtraVars';
|
import mergeExtraVars from './mergeExtraVars';
|
||||||
|
|
||||||
|
const STEPS = {
|
||||||
|
INVENTORY: 'inventory',
|
||||||
|
CREDENTIALS: 'credentials',
|
||||||
|
PASSWORDS: 'passwords',
|
||||||
|
OTHER_PROMPTS: 'other',
|
||||||
|
SURVEY: 'survey',
|
||||||
|
PREVIEW: 'preview',
|
||||||
|
};
|
||||||
|
|
||||||
|
function showOtherPrompts(config) {
|
||||||
|
return (
|
||||||
|
config.ask_job_type_on_launch ||
|
||||||
|
config.ask_limit_on_launch ||
|
||||||
|
config.ask_verbosity_on_launch ||
|
||||||
|
config.ask_tags_on_launch ||
|
||||||
|
config.ask_skip_tags_on_launch ||
|
||||||
|
config.ask_variables_on_launch ||
|
||||||
|
config.ask_scm_branch_on_launch ||
|
||||||
|
config.ask_diff_mode_on_launch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialVisitedSteps(config) {
|
||||||
|
const visited = {};
|
||||||
|
if (config.ask_inventory_on_launch) {
|
||||||
|
visited[STEPS.INVENTORY] = false;
|
||||||
|
}
|
||||||
|
if (config.ask_credential_on_launch) {
|
||||||
|
visited[STEPS.CREDENTIALS] = false;
|
||||||
|
}
|
||||||
|
if (showOtherPrompts(config)) {
|
||||||
|
visited[STEPS.OTHER_PROMPTS] = false;
|
||||||
|
}
|
||||||
|
if (config.survey_enabled) {
|
||||||
|
visited[STEPS.SURVEY] = false;
|
||||||
|
}
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||||
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
const [visitedSteps, setVisitedSteps] = useState(
|
||||||
|
getInitialVisitedSteps(config)
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: survey,
|
result: survey,
|
||||||
request: fetchSurvey,
|
request: fetchSurvey,
|
||||||
@@ -37,12 +84,16 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
if (surveyError) {
|
if (surveyError) {
|
||||||
return <ContentError error={surveyError} />;
|
return <ContentError error={surveyError} />;
|
||||||
}
|
}
|
||||||
|
if (config.survey_enabled && !survey) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
const steps = [];
|
const steps = [];
|
||||||
const initialValues = {};
|
const initialValues = {};
|
||||||
if (config.ask_inventory_on_launch) {
|
if (config.ask_inventory_on_launch) {
|
||||||
initialValues.inventory = resource?.summary_fields?.inventory || null;
|
initialValues.inventory = resource?.summary_fields?.inventory || null;
|
||||||
steps.push({
|
steps.push({
|
||||||
|
id: STEPS.INVENTORY,
|
||||||
name: i18n._(t`Inventory`),
|
name: i18n._(t`Inventory`),
|
||||||
component: <InventoryStep />,
|
component: <InventoryStep />,
|
||||||
});
|
});
|
||||||
@@ -50,6 +101,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
if (config.ask_credential_on_launch) {
|
if (config.ask_credential_on_launch) {
|
||||||
initialValues.credentials = resource?.summary_fields?.credentials || [];
|
initialValues.credentials = resource?.summary_fields?.credentials || [];
|
||||||
steps.push({
|
steps.push({
|
||||||
|
id: STEPS.CREDENTIALS,
|
||||||
name: i18n._(t`Credentials`),
|
name: i18n._(t`Credentials`),
|
||||||
component: <CredentialsStep />,
|
component: <CredentialsStep />,
|
||||||
});
|
});
|
||||||
@@ -81,36 +133,44 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
if (config.ask_diff_mode_on_launch) {
|
if (config.ask_diff_mode_on_launch) {
|
||||||
initialValues.diff_mode = resource.diff_mode || false;
|
initialValues.diff_mode = resource.diff_mode || false;
|
||||||
}
|
}
|
||||||
if (
|
if (showOtherPrompts(config)) {
|
||||||
config.ask_job_type_on_launch ||
|
|
||||||
config.ask_limit_on_launch ||
|
|
||||||
config.ask_verbosity_on_launch ||
|
|
||||||
config.ask_tags_on_launch ||
|
|
||||||
config.ask_skip_tags_on_launch ||
|
|
||||||
config.ask_variables_on_launch ||
|
|
||||||
config.ask_scm_branch_on_launch ||
|
|
||||||
config.ask_diff_mode_on_launch
|
|
||||||
) {
|
|
||||||
steps.push({
|
steps.push({
|
||||||
|
id: STEPS.OTHER_PROMPTS,
|
||||||
name: i18n._(t`Other Prompts`),
|
name: i18n._(t`Other Prompts`),
|
||||||
component: <OtherPromptsStep config={config} />,
|
component: <OtherPromptsStep config={config} />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (config.survey_enabled) {
|
if (config.survey_enabled) {
|
||||||
initialValues.survey = {};
|
initialValues.survey = {};
|
||||||
|
// survey.spec.forEach(question => {
|
||||||
|
// initialValues[`survey_${question.variable}`] = question.default;
|
||||||
|
// })
|
||||||
steps.push({
|
steps.push({
|
||||||
|
id: STEPS.SURVEY,
|
||||||
name: i18n._(t`Survey`),
|
name: i18n._(t`Survey`),
|
||||||
component: <SurveyStep survey={survey} />,
|
component: <SurveyStep survey={survey} />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
steps.push({
|
steps.push({
|
||||||
|
id: STEPS.PREVIEW,
|
||||||
name: i18n._(t`Preview`),
|
name: i18n._(t`Preview`),
|
||||||
component: (
|
component: (
|
||||||
<PreviewStep resource={resource} config={config} survey={survey} />
|
<PreviewStep
|
||||||
|
resource={resource}
|
||||||
|
config={config}
|
||||||
|
survey={survey}
|
||||||
|
formErrors={formErrors}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
|
enableNext: Object.keys(formErrors).length === 0,
|
||||||
nextButtonText: i18n._(t`Launch`),
|
nextButtonText: i18n._(t`Launch`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validate = values => {
|
||||||
|
// return {};
|
||||||
|
return { limit: ['required field'] };
|
||||||
|
};
|
||||||
|
|
||||||
const submit = values => {
|
const submit = values => {
|
||||||
const postValues = {};
|
const postValues = {};
|
||||||
const setValue = (key, value) => {
|
const setValue = (key, value) => {
|
||||||
@@ -127,16 +187,32 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey));
|
setValue('extra_vars', mergeExtraVars(values.extra_vars, values.survey));
|
||||||
onLaunch(postValues);
|
onLaunch(postValues);
|
||||||
};
|
};
|
||||||
|
console.log('formErrors:', formErrors);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik initialValues={initialValues} onSubmit={submit}>
|
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
|
||||||
{({ handleSubmit }) => (
|
{({ errors, values, touched, validateForm, handleSubmit }) => (
|
||||||
<Wizard
|
<Wizard
|
||||||
isOpen
|
isOpen
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
onSave={handleSubmit}
|
onSave={handleSubmit}
|
||||||
|
onNext={async (nextStep, prevStep) => {
|
||||||
|
// console.log(`${prevStep.prevName} -> ${nextStep.name}`);
|
||||||
|
// console.log('errors', errors);
|
||||||
|
// console.log('values', values);
|
||||||
|
const newErrors = await validateForm();
|
||||||
|
setFormErrors(newErrors);
|
||||||
|
// console.log('new errors:', newErrors);
|
||||||
|
}}
|
||||||
|
onGoToStep={async (newStep, prevStep) => {
|
||||||
|
// console.log('errors', errors);
|
||||||
|
// console.log('values', values);
|
||||||
|
const newErrors = await validateForm();
|
||||||
|
setFormErrors(newErrors);
|
||||||
|
}}
|
||||||
title={i18n._(t`Prompts`)}
|
title={i18n._(t`Prompts`)}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
|
// footer={<PromptFooter firstStep={steps[0].id} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TagMultiSelect } from '@components/MultiSelect';
|
|||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import { VariablesField } from '@components/CodeMirrorInput';
|
import { VariablesField } from '@components/CodeMirrorInput';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
|
||||||
const FieldHeader = styled.div`
|
const FieldHeader = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -32,6 +33,9 @@ function OtherPromptsStep({ config, i18n }) {
|
|||||||
of hosts that will be managed or affected by the playbook. Multiple
|
of hosts that will be managed or affected by the playbook. Multiple
|
||||||
patterns are allowed. Refer to Ansible documentation for more
|
patterns are allowed. Refer to Ansible documentation for more
|
||||||
information and examples on patterns.`)}
|
information and examples on patterns.`)}
|
||||||
|
// TODO: remove this validator (for testing only)
|
||||||
|
isRequired
|
||||||
|
validate={required(null, i18n)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}
|
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}
|
||||||
|
|||||||
@@ -4,21 +4,30 @@ import yaml from 'js-yaml';
|
|||||||
import PromptDetail from '@components/PromptDetail';
|
import PromptDetail from '@components/PromptDetail';
|
||||||
import mergeExtraVars, { maskPasswords } from './mergeExtraVars';
|
import mergeExtraVars, { maskPasswords } from './mergeExtraVars';
|
||||||
|
|
||||||
function PreviewStep({ resource, config, survey }) {
|
function PreviewStep({ resource, config, survey, formErrors }) {
|
||||||
const { values } = useFormikContext();
|
const { values } = useFormikContext();
|
||||||
const passwordFields = survey.spec
|
const passwordFields = survey.spec
|
||||||
.filter(q => q.type === 'password')
|
.filter(q => q.type === 'password')
|
||||||
.map(q => q.variable);
|
.map(q => q.variable);
|
||||||
const masked = maskPasswords(values.survey, passwordFields);
|
const masked = maskPasswords(values.survey, passwordFields);
|
||||||
return (
|
return (
|
||||||
<PromptDetail
|
<>
|
||||||
resource={resource}
|
<PromptDetail
|
||||||
launchConfig={config}
|
resource={resource}
|
||||||
overrides={{
|
launchConfig={config}
|
||||||
...values,
|
overrides={{
|
||||||
extra_vars: yaml.safeDump(mergeExtraVars(values.extra_vars, masked)),
|
...values,
|
||||||
}}
|
extra_vars: yaml.safeDump(mergeExtraVars(values.extra_vars, masked)),
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{formErrors && (
|
||||||
|
<ul css="color: red">
|
||||||
|
{Object.keys(formErrors).map(
|
||||||
|
field => `${field}: ${formErrors[field]}`
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx
Normal file
67
awx/ui_next/src/components/LaunchPrompt/PromptFooter.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
WizardFooter,
|
||||||
|
WizardContextConsumer,
|
||||||
|
Button,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
const STEPS = {
|
||||||
|
INVENTORY: 'inventory',
|
||||||
|
CREDENTIALS: 'credentials',
|
||||||
|
PASSWORDS: 'passwords',
|
||||||
|
OTHER_PROMPTS: 'other',
|
||||||
|
SURVEY: 'survey',
|
||||||
|
PREVIEW: 'preview',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PromptFooter({ firstStep, i18n }) {
|
||||||
|
return (
|
||||||
|
<WizardFooter>
|
||||||
|
<WizardContextConsumer>
|
||||||
|
{({
|
||||||
|
activeStep,
|
||||||
|
goToStepByName,
|
||||||
|
goToStepById,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (activeStep.name !== STEPS.PREVIEW) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" type="submit" onClick={onNext}>
|
||||||
|
{activeStep.nextButtonText || i18n._(t`Next`)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onBack}
|
||||||
|
className={activeStep.id === firstStep ? 'pf-m-disabled' : ''}
|
||||||
|
>
|
||||||
|
{i18n._(t`Back`)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="link" onClick={onClose}>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => this.validateLastStep(onNext)}>
|
||||||
|
{activeStep.nextButtonText || i18n._(t`Launch`)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => goToStepByName('Step 1')}>
|
||||||
|
Go to Beginning
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</WizardContextConsumer>
|
||||||
|
</WizardFooter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PromptFooter as _PromptFooter };
|
||||||
|
export default withI18n()(PromptFooter);
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import FormField, { FieldTooltip } from '@components/FormField';
|
import FormField, { FieldTooltip } from '@components/FormField';
|
||||||
import AnsibleSelect from '@components/AnsibleSelect';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
|
||||||
import {
|
import {
|
||||||
required,
|
required,
|
||||||
minMaxValue,
|
minMaxValue,
|
||||||
@@ -19,12 +18,9 @@ import {
|
|||||||
integer,
|
integer,
|
||||||
combine,
|
combine,
|
||||||
} from '@util/validators';
|
} from '@util/validators';
|
||||||
|
import { Survey } from '@types';
|
||||||
|
|
||||||
function SurveyStep({ survey, i18n }) {
|
function SurveyStep({ survey, i18n }) {
|
||||||
if (!survey) {
|
|
||||||
return <ContentLoading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialValues = {};
|
const initialValues = {};
|
||||||
survey.spec.forEach(question => {
|
survey.spec.forEach(question => {
|
||||||
if (question.type === 'multiselect') {
|
if (question.type === 'multiselect') {
|
||||||
@@ -38,6 +34,9 @@ function SurveyStep({ survey, i18n }) {
|
|||||||
<SurveySubForm survey={survey} initialValues={initialValues} i18n={i18n} />
|
<SurveySubForm survey={survey} initialValues={initialValues} i18n={i18n} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
SurveyStep.propTypes = {
|
||||||
|
survey: Survey.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
// This is a nested Formik form to perform validation on individual
|
// This is a nested Formik form to perform validation on individual
|
||||||
// survey questions. When changes to the inner form occur (onBlur), the
|
// survey questions. When changes to the inner form occur (onBlur), the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function mergeExtraVars(extraVars, survey = {}) {
|
|||||||
export function maskPasswords(vars, passwordKeys) {
|
export function maskPasswords(vars, passwordKeys) {
|
||||||
const updated = { ...vars };
|
const updated = { ...vars };
|
||||||
passwordKeys.forEach(key => {
|
passwordKeys.forEach(key => {
|
||||||
if (updated[key]) {
|
if (typeof updated[key] !== 'undefined') {
|
||||||
updated[key] = '········';
|
updated[key] = '········';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,5 +46,17 @@ describe('mergeExtraVars', () => {
|
|||||||
three: '········',
|
three: '········',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should mask empty strings', () => {
|
||||||
|
const vars = {
|
||||||
|
one: '',
|
||||||
|
two: 'bravo',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(maskPasswords(vars, ['one', 'three'])).toEqual({
|
||||||
|
one: '········',
|
||||||
|
two: 'bravo',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -306,3 +306,21 @@ export const Schedule = shape({
|
|||||||
timezone: string,
|
timezone: string,
|
||||||
until: string,
|
until: string,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SurveyQuestion = shape({
|
||||||
|
question_name: string,
|
||||||
|
question_description: string,
|
||||||
|
required: bool,
|
||||||
|
type: string,
|
||||||
|
variable: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
default: string,
|
||||||
|
choices: string,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Survey = shape({
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
spec: arrayOf(SurveyQuestion),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user