diff --git a/backend b/backend index ee7cabb..b74c7f5 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit ee7cabb5c2461ad1eae79bdf87d7d009b024ce9e +Subproject commit b74c7f5c063596f8897e7e6ed1729617cab655a0 diff --git a/src/components/ChoiceCollectionSelectInput/index.tsx b/src/components/ChoiceCollectionSelectInput/index.tsx new file mode 100644 index 0000000..5b70c38 --- /dev/null +++ b/src/components/ChoiceCollectionSelectInput/index.tsx @@ -0,0 +1,120 @@ +import { useMemo, useState } from 'react'; +import { isNotDefined } from '@togglecorp/fujs'; +import { gql, useQuery } from '@apollo/client'; +import { SearchSelectInput } from '@the-deep/deep-ui'; + +import { + ChoiceCollectionsQuery, + ChoiceCollectionsQueryVariables, +} from '#generated/types'; + +const CHOICE_COLLECTIONS = gql` + query ChoiceCollections( + $projectId: ID!, + $questionnaireId: ID!, + $search:String + ) { + private { + projectScope(pk: $projectId) { + id + choiceCollections( + filters: { + questionnaire: {pk: $questionnaireId}, + label: {iContains: $search } + } + ) { + count + items { + id + label + name + questionnaireId + } + } + } + } +} +`; + +type ChoiceCollection = NonNullable['choiceCollections']['items'][number]; + +const choiceCollectionKeySelector = (d: ChoiceCollection) => d.id; +const choiceCollectionLabelSelector = (d: ChoiceCollection) => d.label; + +interface Props { + projectId: string; + questionnaireId: string | null; + name: T; + label: string; + onChange: (value: string | undefined, name: T) => void; + value: string | null | undefined; + error: string | undefined; +} + +function ChoiceCollectionSelectInput(props: Props) { + const { + projectId, + questionnaireId, + name, + value, + label, + onChange, + error, + } = props; + + const [ + choiceCollectionOptions, + setChoiceCollectionOptions, + ] = useState(); + + const [search, setSearch] = useState(); + const [opened, setOpened] = useState(false); + + const optionsVariables = useMemo(() => { + if (isNotDefined(projectId) || isNotDefined(questionnaireId) + ) { + return undefined; + } + return ({ + projectId, + questionnaireId, + search, + }); + }, [ + projectId, + questionnaireId, + search, + ]); + + const { + data: choiceCollectionsResponse, + loading: choiceCollectionLoading, + } = useQuery< + ChoiceCollectionsQuery, + ChoiceCollectionsQueryVariables + >(CHOICE_COLLECTIONS, { + skip: isNotDefined(optionsVariables) || !opened, + variables: optionsVariables, + }); + const searchOption = choiceCollectionsResponse?.private.projectScope?.choiceCollections.items; + + return ( + + ); +} + +export default ChoiceCollectionSelectInput; diff --git a/src/components/PillarSelectInput/index.tsx b/src/components/PillarSelectInput/index.tsx new file mode 100644 index 0000000..8b5d1c5 --- /dev/null +++ b/src/components/PillarSelectInput/index.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { gql, useQuery } from '@apollo/client'; +import { SelectInput } from '@the-deep/deep-ui'; +import { isNotDefined } from '@togglecorp/fujs'; + +import { PillarsQuery, PillarsQueryVariables } from '#generated/types'; + +const PILLARS = gql` + query Pillars ( + $projectId: ID!, + $questionnaireId: ID!, + ) { + private { + projectScope(pk: $projectId) { + groups (filters: {questionnaire: {pk: $questionnaireId}}){ + items { + id + name + label + parentId + questionnaireId + } + } + } + } + } +`; + +type Pillar = NonNullable['groups']['items'][number]; + +const pillarKeySelector = (data: Pillar) => data.id; +const pillarLabelSelector = (data: Pillar) => data.label; + +interface PillarProps{ + projectId: string; + name: T; + questionnaireId: string | null; + value: string | null | undefined; + error: string | undefined; + onChange: (value: string | undefined, name: T) => void; +} + +function PillarSelectInput(props: PillarProps) { + const { + projectId, + questionnaireId, + value, + error, + onChange, + name, + } = props; + + const pillarsVariables = useMemo(() => { + if (isNotDefined(projectId) || isNotDefined(questionnaireId)) { + return undefined; + } + return ({ + projectId, + questionnaireId, + }); + }, [ + projectId, + questionnaireId, + ]); + + const { + data: pillarsResponse, + loading: pillarsLoading, + } = useQuery( + PILLARS, + { + skip: isNotDefined(pillarsVariables), + variables: pillarsVariables, + }, + ); + + const pillarsOptions = pillarsResponse?.private?.projectScope?.groups.items ?? []; + + return ( + + ); +} + +export default PillarSelectInput; diff --git a/src/components/questionPreviews/SelectMultipleQuestionPreview/index.module.css b/src/components/questionPreviews/SelectMultipleQuestionPreview/index.module.css new file mode 100644 index 0000000..2f92235 --- /dev/null +++ b/src/components/questionPreviews/SelectMultipleQuestionPreview/index.module.css @@ -0,0 +1,13 @@ +.preview { + display: flex; + flex-direction: column; + padding: var(--dui-spacing-extra-large) var(--dui-spacing-large); + gap: var(--dui-spacing-medium); + + .checkbox-list { + display: flex; + flex-direction: column; + gap: var(--dui-spacing-small); + width: 10rem; + } +} diff --git a/src/components/questionPreviews/SelectMultipleQuestionPreview/index.tsx b/src/components/questionPreviews/SelectMultipleQuestionPreview/index.tsx new file mode 100644 index 0000000..ff61f15 --- /dev/null +++ b/src/components/questionPreviews/SelectMultipleQuestionPreview/index.tsx @@ -0,0 +1,114 @@ +import { useCallback, useMemo } from 'react'; +import { gql, useQuery } from '@apollo/client'; +import { + _cs, + isNotDefined, + noOp, +} from '@togglecorp/fujs'; +import { + Checkbox, + ListView, + TextOutput, +} from '@the-deep/deep-ui'; + +import { MultipleOptionListQuery, MultipleOptionListQueryVariables } from '#generated/types'; + +import styles from './index.module.css'; + +const MULTIPLE_OPTION_LIST = gql` + query MultipleOptionList( + $projectId: ID!, + $choiceCollectionId: ID!, + ) { + private { + projectScope(pk: $projectId) { + choiceCollection(pk: $choiceCollectionId) { + id + label + name + choices { + id + label + name + } + } + } + } + } +`; + +type CheckboxType = NonNullable['choiceCollection']>['choices'][number]; +const checkboxKeySelector = (d: CheckboxType) => d.id; + +interface Props { + className?: string; + label?: string; + hint?: string | null; + choiceCollectionId: string | undefined | null; + projectId: string; +} + +function SelectMultipleQuestionPreview(props: Props) { + const { + className, + label, + hint, + choiceCollectionId, + projectId, + } = props; + + const optionListVariables = useMemo(() => { + if (isNotDefined(projectId) || isNotDefined(choiceCollectionId)) { + return undefined; + } + return ({ + projectId, + choiceCollectionId, + }); + }, [ + projectId, + choiceCollectionId, + ]); + + const { + data: optionsListResponse, + loading: OptionsListLoading, + } = useQuery( + MULTIPLE_OPTION_LIST, + { + skip: isNotDefined(optionListVariables), + variables: optionListVariables, + }, + ); + + const checkboxListRendererParams = useCallback((_: string, datum: CheckboxType) => ({ + label: datum?.label, + name: 'choiceCollection', + value: false, + readOnly: true, + onChange: noOp, + }), []); + + return ( +
+ + +
+ ); +} + +export default SelectMultipleQuestionPreview; diff --git a/src/components/questionPreviews/SelectOneQuestionPreview/index.module.css b/src/components/questionPreviews/SelectOneQuestionPreview/index.module.css new file mode 100644 index 0000000..8a243df --- /dev/null +++ b/src/components/questionPreviews/SelectOneQuestionPreview/index.module.css @@ -0,0 +1,14 @@ +.preview { + display: flex; + flex-direction: column; + padding: var(--dui-spacing-extra-large) var(--dui-spacing-large); + gap: var(--dui-spacing-medium); + + .question-list { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: var(--dui-spacing-small); + width: 10rem; + } +} diff --git a/src/components/questionPreviews/SelectOneQuestionPreview/index.tsx b/src/components/questionPreviews/SelectOneQuestionPreview/index.tsx new file mode 100644 index 0000000..9580762 --- /dev/null +++ b/src/components/questionPreviews/SelectOneQuestionPreview/index.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; +import { + _cs, + isNotDefined, + noOp, +} from '@togglecorp/fujs'; +import { gql, useQuery } from '@apollo/client'; +import { + RadioInput, + TextOutput, +} from '@the-deep/deep-ui'; + +import { + SingleOptionListQuery, + SingleOptionListQueryVariables, +} from '#generated/types'; + +import styles from './index.module.css'; + +const SINGLE_OPTION_LIST = gql` + query SingleOptionList( + $projectId: ID!, + $choiceCollectionId: ID!, + ) { + private { + projectScope(pk: $projectId) { + choiceCollection(pk: $choiceCollectionId) { + label + id + name + choices { + id + label + name + } + } + } + } + } +`; + +type ChoiceType = NonNullable['projectScope']>['choiceCollection']>['choices']>[number]; + +const choiceCollectionKeySelector = (d: ChoiceType) => d.id; +const choiceCollectionLabelSelector = (d: ChoiceType) => d.label; + +interface Props { + className?: string; + label?: string; + hint?: string | null; + choiceCollectionId: string | undefined | null; + projectId: string; +} + +function SelectOneQuestionPreview(props: Props) { + const { + className, + label, + hint, + choiceCollectionId, + projectId, + } = props; + + const optionListVariables = useMemo(() => { + if (isNotDefined(projectId) || isNotDefined(choiceCollectionId)) { + return undefined; + } + return ({ + projectId, + choiceCollectionId, + }); + }, [ + projectId, + choiceCollectionId, + ]); + + const { + data: optionsListResponse, + loading: optionListLoading, + } = useQuery( + SINGLE_OPTION_LIST, + { + skip: isNotDefined(optionListVariables), + variables: optionListVariables, + }, + ); + + const optionsList = optionsListResponse?.private?.projectScope?.choiceCollection?.choices ?? []; + + return ( +
+ + +
+ ); +} + +export default SelectOneQuestionPreview; diff --git a/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.module.css b/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.module.css new file mode 100644 index 0000000..88a597a --- /dev/null +++ b/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.module.css @@ -0,0 +1,16 @@ +.question { + display: flex; + flex-direction: column; + gap: var(--dui-spacing-large); + + .preview { + border: var(--dui-width-separator-thin) solid var(--dui-color-separator); + border-radius: var(--dui-border-radius-card); + } + + .edit-section { + display: flex; + flex-direction: column; + gap: var(--dui-spacing-medium); + } +} diff --git a/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.tsx b/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.tsx new file mode 100644 index 0000000..66b68e4 --- /dev/null +++ b/src/views/QuestionnaireEdit/SelectMultipleQuestionForm/index.tsx @@ -0,0 +1,225 @@ +import { useCallback } from 'react'; +import { + randomString, +} from '@togglecorp/fujs'; +import { + gql, + useMutation, +} from '@apollo/client'; +import { + Button, + TextInput, + useAlert, +} from '@the-deep/deep-ui'; +import { + ObjectSchema, + createSubmitHandler, + requiredStringCondition, + getErrorObject, + useForm, + PartialForm, +} from '@togglecorp/toggle-form'; +import { + QuestionCreateInput, + QuestionTypeEnum, + CreateMultipleSelectionQuestionMutation, + CreateMultipleSelectionQuestionMutationVariables, +} from '#generated/types'; +import SelectMultipleQuestionPreview from '#components/questionPreviews/SelectMultipleQuestionPreview'; +import PillarSelectInput from '#components/PillarSelectInput'; +import ChoiceCollectionSelectInput from '#components/ChoiceCollectionSelectInput'; + +import styles from './index.module.css'; + +const CREATE_MULTIPLE_SELECTION_QUESTION = gql` + mutation CreateMultipleSelectionQuestion( + $projectId: ID!, + $input: QuestionCreateInput!, + ){ + private { + projectScope(pk: $projectId) { + createQuestion( + data: $input, + ) { + ok + errors + } + } + } + } +`; + +type FormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const schema: FormSchema = { + fields: (): FormSchemaFields => ({ + name: { + required: true, + requiredValidation: requiredStringCondition, + }, + type: { + required: true, + requiredValidation: requiredStringCondition, + }, + questionnaire: { + required: true, + requiredValidation: requiredStringCondition, + }, + label: { + required: true, + requiredValidation: requiredStringCondition, + }, + choiceCollection: { + required: true, + requiredValidation: requiredStringCondition, + }, + group: { + required: true, + requiredValidation: requiredStringCondition, + }, + hint: {}, + }), +}; + +interface Props { + projectId: string; + questionnaireId: string; +} + +function SelectMultipleQuestionForm(props: Props) { + const { + projectId, + questionnaireId, + } = props; + + const alert = useAlert(); + + const [ + triggerQuestionCreate, + { loading: createQuestionPending }, + ] = useMutation< + CreateMultipleSelectionQuestionMutation, + CreateMultipleSelectionQuestionMutationVariables + >( + CREATE_MULTIPLE_SELECTION_QUESTION, + { + onCompleted: (questionResponse) => { + const response = questionResponse?.private?.projectScope?.createQuestion; + if (!response) { + return; + } + if (response.ok) { + alert.show( + 'Question created successfully.', + { variant: 'success' }, + ); + } else { + alert.show( + 'Failed to create question.', + { variant: 'error' }, + ); + } + }, + onError: () => { + alert.show( + 'Failed to create question.', + { variant: 'error' }, + ); + }, + }, + ); + const initialFormValue: FormType = { + type: 'SELECT_MULTIPLE' as QuestionTypeEnum, + questionnaire: questionnaireId, + name: randomString(), + }; + + const { + pristine, + validate, + value: formValue, + error: formError, + setFieldValue, + setError, + } = useForm(schema, { value: initialFormValue }); + + const fieldError = getErrorObject(formError); + + const handleQuestionSubmit = useCallback(() => { + const handler = createSubmitHandler( + validate, + setError, + (valueFromForm) => { + triggerQuestionCreate({ + variables: { + projectId, + input: valueFromForm as QuestionCreateInput, + }, + }); + }, + ); + handler(); + }, [ + triggerQuestionCreate, + projectId, + setError, + validate, + ]); + + return ( +
+ +
+ + + + +
+ + + ); +} + +export default SelectMultipleQuestionForm; diff --git a/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.module.css b/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.module.css new file mode 100644 index 0000000..88a597a --- /dev/null +++ b/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.module.css @@ -0,0 +1,16 @@ +.question { + display: flex; + flex-direction: column; + gap: var(--dui-spacing-large); + + .preview { + border: var(--dui-width-separator-thin) solid var(--dui-color-separator); + border-radius: var(--dui-border-radius-card); + } + + .edit-section { + display: flex; + flex-direction: column; + gap: var(--dui-spacing-medium); + } +} diff --git a/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.tsx b/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.tsx new file mode 100644 index 0000000..a062699 --- /dev/null +++ b/src/views/QuestionnaireEdit/SelectOneQuestionForm/index.tsx @@ -0,0 +1,225 @@ +import { useCallback } from 'react'; +import { + randomString, +} from '@togglecorp/fujs'; +import { + gql, + useMutation, +} from '@apollo/client'; +import { + Button, + TextInput, + useAlert, +} from '@the-deep/deep-ui'; +import { + ObjectSchema, + createSubmitHandler, + requiredStringCondition, + getErrorObject, + useForm, + PartialForm, +} from '@togglecorp/toggle-form'; + +import { + QuestionCreateInput, + QuestionTypeEnum, + CreateSingleSelectionQuestionMutation, + CreateSingleSelectionQuestionMutationVariables, +} from '#generated/types'; +import SelectOneQuestionPreview from '#components/questionPreviews/SelectOneQuestionPreview'; +import PillarSelectInput from '#components/PillarSelectInput'; +import ChoiceCollectionSelectInput from '#components/ChoiceCollectionSelectInput'; + +import styles from './index.module.css'; + +const CREATE_SINGLE_SELECTION_QUESTION = gql` + mutation CreateSingleSelectionQuestion( + $projectId: ID!, + $input: QuestionCreateInput!, + ){ + private { + projectScope(pk: $projectId) { + createQuestion( + data: $input, + ) { + ok + errors + } + } + } + } +`; + +type FormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const schema: FormSchema = { + fields: (): FormSchemaFields => ({ + name: { + required: true, + requiredValidation: requiredStringCondition, + }, + type: { + required: true, + requiredValidation: requiredStringCondition, + }, + questionnaire: { + required: true, + requiredValidation: requiredStringCondition, + }, + label: { + required: true, + requiredValidation: requiredStringCondition, + }, + choiceCollection: { + required: true, + requiredValidation: requiredStringCondition, + }, + group: { + required: true, + requiredValidation: requiredStringCondition, + }, + hint: {}, + }), +}; + +interface Props { + projectId: string; + questionnaireId: string; +} + +function SelectOneQuestionForm(props: Props) { + const { + projectId, + questionnaireId, + } = props; + + const alert = useAlert(); + const [ + triggerQuestionCreate, + { loading: createQuestionPending }, + ] = useMutation< + CreateSingleSelectionQuestionMutation, + CreateSingleSelectionQuestionMutationVariables + >( + CREATE_SINGLE_SELECTION_QUESTION, + { + onCompleted: (questionResponse) => { + const response = questionResponse?.private?.projectScope?.createQuestion; + if (!response) { + return; + } + if (response.ok) { + alert.show( + 'Question created successfully.', + { variant: 'success' }, + ); + } else { + alert.show( + 'Failed to create question.', + { variant: 'error' }, + ); + } + }, + onError: () => { + alert.show( + 'Failed to create question.', + { variant: 'error' }, + ); + }, + }, + ); + const initialFormValue: FormType = { + type: 'SELECT_ONE' as QuestionTypeEnum, + questionnaire: questionnaireId, + name: randomString(), + }; + + const { + pristine, + validate, + value: formValue, + error: formError, + setFieldValue, + setError, + } = useForm(schema, { value: initialFormValue }); + + const fieldError = getErrorObject(formError); + + const handleQuestionSubmit = useCallback(() => { + const handler = createSubmitHandler( + validate, + setError, + (valueFromForm) => { + triggerQuestionCreate({ + variables: { + projectId, + input: valueFromForm as QuestionCreateInput, + }, + }); + }, + ); + handler(); + }, [ + triggerQuestionCreate, + projectId, + setError, + validate, + ]); + + return ( +
+ +
+ + + + +
+ + + ); +} + +export default SelectOneQuestionForm; diff --git a/src/views/QuestionnaireEdit/index.tsx b/src/views/QuestionnaireEdit/index.tsx index 7c13777..198ba3e 100644 --- a/src/views/QuestionnaireEdit/index.tsx +++ b/src/views/QuestionnaireEdit/index.tsx @@ -39,8 +39,11 @@ import { import TextQuestionForm from './TextQuestionForm'; import IntegerQuestionForm from './IntegerQuestionForm'; import RankQuestionForm from './RankQuestionForm'; +import SelectOneQuestionForm from './SelectOneQuestionForm'; import QuestionTypeItem, { QuestionType } from './QuestionTypeItem'; import QuestionPreview from './QuestionPreview'; +import SelectMultipleQuestionForm from './SelectMultipleQuestionForm'; + import styles from './index.module.css'; const QUESTIONNAIRE = gql` @@ -300,6 +303,18 @@ export function Component() { questionnaireId={questionnaireId} /> )} + {(selectedQuestionType === 'SELECT_ONE') && ( + + )} + {(selectedQuestionType === 'SELECT_MULTIPLE') && ( + + )} )}