From 5eb94baf0fa53754b84ae6375e3884fad894c439 Mon Sep 17 00:00:00 2001 From: Subina Date: Fri, 18 Aug 2023 16:10:15 +0545 Subject: [PATCH] Add table of contents - Add tab view in question listing - Group questions based on selected question groups --- backend | 2 +- src/components/SortableList/index.tsx | 22 +- src/components/TocList/index.module.css | 30 +++ src/components/TocList/index.tsx | 192 +++++++++++--- src/components/UserSelectInput/index.tsx | 2 +- .../QuestionPreview/index.module.css | 1 + .../QuestionPreview/index.tsx | 22 +- src/views/QuestionnaireEdit/index.module.css | 1 + src/views/QuestionnaireEdit/index.tsx | 244 ++++++++++-------- 9 files changed, 363 insertions(+), 153 deletions(-) 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/SortableList/index.tsx b/src/components/SortableList/index.tsx index e10fc3a..ac2157f 100644 --- a/src/components/SortableList/index.tsx +++ b/src/components/SortableList/index.tsx @@ -1,4 +1,9 @@ -import React, { useState, memo, useMemo, useCallback } from 'react'; +import React, { + useState, + memo, + useMemo, + useCallback, +} from 'react'; import { Portal, ListView, @@ -67,6 +72,7 @@ interface SortableItemProps< itemContainerParams?: ItemContainerParams; } +// eslint-disable-next-line react-refresh/only-export-components function SortableItem< D, P, @@ -103,17 +109,20 @@ function SortableItem<
); } +// eslint-disable-next-line react-refresh/only-export-components const MemoizedSortableItem = genericMemo(SortableItem); export type Props< @@ -142,6 +151,7 @@ export type Props< errored?: boolean; } +// eslint-disable-next-line react-refresh/only-export-components function SortableList< N extends string, D, @@ -183,7 +193,7 @@ function SortableList< const handleDragStart = useCallback((event: DragStartEvent) => { const { active } = event; - setActiveId(active.id); + setActiveId(active.id.toString()); }, []); const handleDragEnd = useCallback((event: DragEndEvent) => { @@ -191,8 +201,8 @@ function SortableList< setActiveId(undefined); if (active.id && over?.id && active.id !== over?.id && items && onChange) { - const oldIndex = items.indexOf(active.id); - const newIndex = items.indexOf(over.id); + const oldIndex = items.indexOf(active.id.toString()); + const newIndex = items.indexOf(over.id.toString()); const newItems = arrayMove(items, oldIndex, newIndex); const dataMap = listToMap( @@ -226,6 +236,7 @@ function SortableList< } return ( ); @@ -299,7 +310,7 @@ function SortableList< strategy={sortingStrategy} > ['projectScope']>['groups']>['items']>[number]; + +interface TocProps { id: string; - label: string; - name: string; - parentId: string; - questionnaireId: string; - relevant?: string; + item: QuestionGroup; + attributes?: Attributes; + listeners?: Listeners; + selectedGroups: string[]; + orderedOptions: QuestionGroup[] | undefined; + onOrderedOptionsChange: React.Dispatch>; + onSelectedGroupsChange: React.Dispatch>; + onActiveTabChange: React.Dispatch>; } -function reorder(data: T[]) { - return data.map((v, i) => ({ ...v, order: i + 1 })); +function TocRenderer(props: TocProps) { + const { + id, + item, + orderedOptions, + selectedGroups, + onOrderedOptionsChange, + onSelectedGroupsChange, + onActiveTabChange, + attributes, + listeners, + } = props; + + const handleGroupSelect = useCallback((val: boolean) => { + onSelectedGroupsChange((oldVal) => { + if (val) { + return ([...oldVal, id]); + } + const newVal = [...oldVal]; + newVal.splice(oldVal.indexOf(id), 1); + + return newVal; + }); + onActiveTabChange((oldActiveTab) => { + if (isNotDefined(oldActiveTab) && val) { + return id; + } + if (!val && oldActiveTab === id) { + return undefined; + } + return oldActiveTab; + }); + }, [ + onActiveTabChange, + onSelectedGroupsChange, + id, + ]); + + const filteredOptions = orderedOptions?.filter((group) => id === group.parentId); + + return ( + + + + + + + )} + contentClassName={styles.content} + heading={item.label} + headingClassName={styles.heading} + headingSize="extraSmall" + spacing="none" + > + {isDefined(filteredOptions) && filteredOptions.length > 0 && ( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + + )} + + ); } const keySelector = (g: QuestionGroup) => g.id; -interface Props

{ +interface Props { + className?: string; parentId: string | null; - options: QuestionGroup[]; - renderer: (props: P & { - listeners?: Listeners; - attributes?: Attributes; - }) => JSX.Element; - rendererParams: P; + orderedOptions: QuestionGroup[] | undefined; + onOrderedOptionsChange: React.Dispatch> + selectedGroups: string[]; + onSelectedGroupsChange: React.Dispatch>; + onActiveTabChange: React.Dispatch>; } -function TocList

(props: Props

) { +function TocList(props: Props) { const { + className, parentId, - options, - renderer, - rendererParams, + orderedOptions, + onOrderedOptionsChange, + selectedGroups, + onSelectedGroupsChange, + onActiveTabChange, } = props; - const filteredOptions = options?.filter( + const filteredOptions = orderedOptions?.filter( (group: QuestionGroup) => group.parentId === parentId, ); - const [ - orderedFilteredOptions, - setFilteredOrderedOptions, - ] = useState(filteredOptions); + const tocRendererParams = useCallback((key: string, datum: QuestionGroup): TocProps => ({ + orderedOptions, + onOrderedOptionsChange, + onSelectedGroupsChange, + onActiveTabChange, + selectedGroups, + id: key, + item: datum, + }), [ + orderedOptions, + selectedGroups, + onSelectedGroupsChange, + onOrderedOptionsChange, + onActiveTabChange, + ]); - const handleGroupOrderChange = useCallback((...args: QuestionGroup[]) => { - setFilteredOrderedOptions(args); - }, []); + const handleGroupOrderChange = useCallback((oldValue: QuestionGroup[]) => { + const nonParentOptions = orderedOptions?.filter((group) => !oldValue.includes(group)) ?? []; + onOrderedOptionsChange([ + ...nonParentOptions, + ...oldValue, + ]); + }, [ + onOrderedOptionsChange, + orderedOptions, + ]); return ( ); diff --git a/src/components/UserSelectInput/index.tsx b/src/components/UserSelectInput/index.tsx index dee4681..4b1b2a8 100644 --- a/src/components/UserSelectInput/index.tsx +++ b/src/components/UserSelectInput/index.tsx @@ -137,7 +137,7 @@ function UserSelectInput(props: UserSelectI return ( ['projectScope']>['questions']>['items']>[number]; +type Question = NonNullable['projectScope']>['questions']>['items']>[number]; interface QuestionProps { question: Question; @@ -18,8 +25,15 @@ function QuestionPreview(props: QuestionProps) { question, } = props; + if (isNotDefined(question.groupId)) { + return null; + } + return ( -

+ {(question.type === 'TEXT') && ( )} -
+ ); } diff --git a/src/views/QuestionnaireEdit/index.module.css b/src/views/QuestionnaireEdit/index.module.css index 96fb7a0..11c0784 100644 --- a/src/views/QuestionnaireEdit/index.module.css +++ b/src/views/QuestionnaireEdit/index.module.css @@ -57,6 +57,7 @@ flex-direction: column; flex-grow: 1; margin: 0 var(--dui-spacing-extra-large); + overflow-y: auto; gap: var(--dui-spacing-large); .header { diff --git a/src/views/QuestionnaireEdit/index.tsx b/src/views/QuestionnaireEdit/index.tsx index 11de57d..737aa9b 100644 --- a/src/views/QuestionnaireEdit/index.tsx +++ b/src/views/QuestionnaireEdit/index.tsx @@ -12,7 +12,6 @@ import { MdOutlineAbc, MdOutlineChecklist, } from 'react-icons/md'; -import { GrDrag } from 'react-icons/gr'; import { isNotDefined, isDefined, @@ -27,16 +26,19 @@ import { Header, ListView, QuickActionButton, + Tab, + Tabs, TextOutput, useModalState, } from '@the-deep/deep-ui'; import SubNavbar from '#components/SubNavbar'; -import SortableList, { Attributes, Listeners } from '#components/SortableList'; import TocList from '#components/TocList'; import { QuestionnaireQuery, QuestionnaireQueryVariables, + QuestionsByGroupQuery, + QuestionsByGroupQueryVariables, } from '#generated/types'; import TextQuestionForm from './TextQuestionForm'; @@ -50,8 +52,6 @@ const QUESTIONNAIRE = gql` query Questionnaire( $projectId: ID!, $questionnaireId: ID!, - $limit: Int, - $offset: Int, ) { private { projectScope(pk: $projectId) { @@ -64,19 +64,45 @@ const QUESTIONNAIRE = gql` id title } + groups(filters: { + questionnaire: { + pk: $questionnaireId + } + }) { + items { + id + parentId + relevant + questionnaireId + label + name + } + } + } + } + } +`; + +const QUESTIONS_BY_GROUP = gql` + query QuestionsByGroup( + $projectId: ID!, + $questionnaireId: ID!, + $groupId: DjangoModelFilterInput, + ) { + private { + projectScope(pk: $projectId) { + id questions( filters: { questionnaire: { pk: $questionnaireId, - } + }, + group: $groupId, + includeChildGroup: true, } order: { createdAt: ASC } - pagination: { - limit: $limit, - offset: $offset, - } ) { count limit @@ -85,87 +111,22 @@ const QUESTIONNAIRE = gql` createdAt hint id + groupId label name type questionnaireId } } - groups(filters: { - questionnaire: { - pk: $questionnaireId - } - }) { - items { - id - parentId - relevant - questionnaireId - label - name - } - } } } } `; -type Question = NonNullable['projectScope']>['questions']>['items']>[number]; +type Question = NonNullable['projectScope']>['questions']>['items']>[number]; type QuestionGroup = NonNullable['projectScope']>['groups']>['items']>[number]; const questionKeySelector = (q: Question) => q.id; -const questionGroupKeySelector = (g: QuestionGroup) => g.id; - -interface QuestionGroupProps { - id: string; - item: QuestionGroup; - attributes?: Attributes; - listeners?: Listeners; - options: QuestionGroup[]; -} - -function QuestionGroupItem(props: QuestionGroupProps) { - const { - id, - item, - attributes, - listeners, - options, - } = props; - - const rendererParams = useCallback((key: string, datum: QuestionGroup) => ({ - id: key, - item: datum, - options, - }), [options]); - - return ( - - - - )} - className={styles.groupItem} - heading={item.label} - headingSize="extraSmall" - > - - - ); -} +const groupTabKeySelector = (g: QuestionGroup) => g.id; const questionTypes: QuestionType[] = [ { @@ -198,18 +159,12 @@ const questionTypes: QuestionType[] = [ const questionTypeKeySelector = (q: QuestionType) => q.key; const PAGE_SIZE = 15; -// FIXME: The type is not right -interface QuestionnaireParams { - projectId: string | undefined; - questionnaireId: string | undefined; -} - // eslint-disable-next-line import/prefer-default-export export function Component() { const { projectId, questionnaireId, - } = useParams(); + } = useParams<{projectId: string, questionnaireId: string}>(); const [ addQuestionPaneShown, @@ -222,6 +177,11 @@ export function Component() { setSelectedQuestionType, ] = useState(); + const [ + selectedGroups, + setSelectedGroups, + ] = useState([]); + const handleRightPaneClose = useCallback(() => { hideAddQuestionPane(); setSelectedQuestionType(undefined); @@ -233,6 +193,7 @@ export function Component() { if (isNotDefined(projectId) || isNotDefined(questionnaireId)) { return undefined; } + return ({ projectId, questionnaireId, @@ -244,6 +205,11 @@ export function Component() { questionnaireId, ]); + const [ + orderedOptions, + setOrderedOptions, + ] = useState([]); + const { data: questionnaireResponse, } = useQuery( @@ -251,15 +217,60 @@ export function Component() { { skip: isNotDefined(questionnaireVariables), variables: questionnaireVariables, + onCompleted: (response) => { + const questionGroups = response?.private.projectScope?.groups?.items; + setOrderedOptions(questionGroups ?? []); + }, }, ); const questionnaireTitle = questionnaireResponse?.private.projectScope?.questionnaire?.title; const projectTitle = questionnaireResponse?.private.projectScope?.project.title; - const questionsData = questionnaireResponse?.private.projectScope?.questions?.items; - const questionGroups = questionnaireResponse?.private.projectScope?.groups.items; + const parentQuestionGroups = orderedOptions?.filter( + (item) => item.parentId === null, + ); + const selectedParentQuestionGroups = parentQuestionGroups?.filter( + (group) => selectedGroups.includes(group.id), + ); + + const [activeGroupTab, setActiveGroupTab] = useState( + selectedParentQuestionGroups?.[0]?.name, + ); + + // NOTE: If none of the tabs are selected, 1st group should be selected + const finalSelectedTab = activeGroupTab ?? selectedGroups[0]; + + const questionsVariables = useMemo(() => { + if (isNotDefined(projectId) + || isNotDefined(questionnaireId) + || isNotDefined(finalSelectedTab)) { + return undefined; + } + + return ({ + projectId, + questionnaireId, + activeGroupTab: finalSelectedTab, + }); + }, [ + projectId, + questionnaireId, + finalSelectedTab, + ]); + + const { + data: questionsResponse, + } = useQuery( + QUESTIONS_BY_GROUP, + { + skip: isNotDefined(questionsVariables), + variables: questionsVariables, + }, + ); + + const questionsData = questionsResponse?.private.projectScope?.questions?.items; const questionTypeRendererParams = useCallback((key: string, data: QuestionType) => ({ questionType: data, name: key, @@ -268,16 +279,15 @@ export function Component() { const questionRendererParams = useCallback((_: string, data: Question) => ({ question: data, - }), [ - ]); + }), []); - const tocRendererParams = useCallback((key: string, data: QuestionGroup) => ({ - id: key, - item: data, + const groupTabRenderParams = useCallback((_: string, datum: QuestionGroup) => ({ + children: datum.label, + name: datum.id, }), []); if (isNotDefined(projectId) || isNotDefined(questionnaireId)) { - return undefined; + return null; } return ( @@ -303,10 +313,12 @@ export function Component() { contentClassName={styles.leftContent} >
@@ -324,19 +336,35 @@ export function Component() { )} /> - + + + +
{addQuestionPaneShown && (