Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/api/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type DocumentStore from '@tdev-stores/DocumentStore';
import iDocumentContainer from '@tdev-models/iDocumentContainer';
import iViewStore from '@tdev-stores/ViewStores/iViewStore';
import Code from '@tdev-models/documents/Code';
import ChoiceAnswer, { ChoiceAnswerChoices } from '@tdev-models/documents/ChoiceAnswer';

export enum Access {
RO_DocumentRoot = 'RO_DocumentRoot',
Expand All @@ -40,6 +41,10 @@ export interface StringData {
text: string;
}

export interface ChoiceAnswerData {
choices: ChoiceAnswerChoices;
}

export interface QuillV2Data {
delta: Delta;
}
Expand Down Expand Up @@ -119,6 +124,7 @@ export interface TypeDataMapping extends ContainerTypeDataMapping {
// TODO: rename to `code_version`?
['script_version']: ScriptVersionData;
['string']: StringData;
['choice_answer']: ChoiceAnswerData;
['quill_v2']: QuillV2Data;
['solution']: SolutionData;
['dir']: DirData;
Expand Down Expand Up @@ -148,6 +154,7 @@ export interface TypeModelMapping extends ContainerTypeModelMapping {
// TODO: rename to `code_version`?
['script_version']: ScriptVersion;
['string']: String;
['choice_answer']: ChoiceAnswer;
['quill_v2']: QuillV2;
['solution']: Solution;
['dir']: Directory;
Expand Down
51 changes: 51 additions & 0 deletions src/components/documents/ChoiceAnswer/Quiz.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer';
import { observer } from 'mobx-react-lite';
import React from 'react';
import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer';

interface Props {
id: string;
readonly?: boolean;
hideQuestionNumbers?: boolean;
children?: React.ReactNode[];
}

export const QuizContext = React.createContext({
id: '',
readonly: false,
hideQuestionNumbers: false,
focussedQuestion: 0,
doc: null
} as {
id: string;
readonly?: boolean;
hideQuestionNumbers?: boolean;
focussedQuestion: number;
setFocussedQuestion?: (index: number) => void;
doc: ChoiceAnswerDocument | null;
});

const Quiz = observer((props: Props) => {
const [meta] = React.useState(new ModelMeta(props));
const doc = useFirstMainDocument(props.id, meta);

const [focussedQuestion, setFocussedQuestion] = React.useState(0);

return (
<QuizContext.Provider
value={{
id: props.id,
readonly: props.readonly,
hideQuestionNumbers: props.hideQuestionNumbers,
focussedQuestion: focussedQuestion,
setFocussedQuestion: setFocussedQuestion,
doc
}}
>
{props.children}
</QuizContext.Provider>
);
});

export default Quiz;
162 changes: 162 additions & 0 deletions src/components/documents/ChoiceAnswer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer';
import { observer } from 'mobx-react-lite';
import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.scss';
import SyncStatus from '@tdev-components/SyncStatus';
import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import Loader from '@tdev-components/Loader';
import useIsBrowser from '@docusaurus/useIsBrowser';
import { QuizContext } from './Quiz';
import Button from '@tdev-components/shared/Button';
import { mdiCloseCircleOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js';

interface ChoiceAnswerProps {
id: string;
questionIndex?: number;
inQuiz?: boolean;
multiple?: boolean;
readonly?: boolean;
children: React.ReactNode;
}

interface ThinWrapperProps {
children: React.ReactNode;
}

interface OptionProps {
children: React.ReactNode;
optionIndex: number;
}

type ChoiceAnswerSubComponents = {
Before: React.FC<ThinWrapperProps>;
Options: React.FC<ThinWrapperProps>;
Option: React.FC<OptionProps>;
After: React.FC<ThinWrapperProps>;
};

const ChoiceAnswerContext = React.createContext({
id: '',
questionIndex: 0,
multiple: false,
readonly: false,
selectedChoices: [],
onChange: () => {}
} as {
id: string;
questionIndex: number;
multiple?: boolean;
readonly?: boolean;
selectedChoices: number[];
onChange: (optionIndex: number, checked: boolean) => void;
});

const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
const parentProps = React.useContext(QuizContext);
const [meta] = React.useState(new ModelMeta(props));
const id = parentProps.id || props.id;
const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(id, meta);
const questionIndex = props.questionIndex ?? 0;
const isBrowser = useIsBrowser();

if (!doc) {
return <UnknownDocumentType type={meta.type} />;
}

if (!isBrowser) {
return <Loader />;
}

const childrenArray = React.Children.toArray(props.children);
const beforeBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Before
);
const optionsBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Options
);
const afterBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.After
);

const onOptionChange = (optionIndex: number, checked: boolean) => {
parentProps.setFocussedQuestion?.(questionIndex);
if (props.multiple) {
doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked);
} else {
checked
? doc?.updateSingleChoiceSelection(questionIndex, optionIndex)
: doc?.resetAnswer(questionIndex);
}
};

return (
<div className={styles.choiceAnswerContainer}>
{parentProps.focussedQuestion === questionIndex && (
<SyncStatus className={styles.syncStatus} model={doc} size={0.7} />
)}

{props.inQuiz && !parentProps.hideQuestionNumbers && <h3>Frage {questionIndex + 1}</h3>}
{beforeBlock}
<ChoiceAnswerContext.Provider
value={{
id: id,
questionIndex: questionIndex,
multiple: props.multiple,
readonly: props.readonly || parentProps.readonly || !doc || doc.isDummy,
selectedChoices: doc?.data.choices[questionIndex] || [],
onChange: onOptionChange
}}
>
{optionsBlock}
</ChoiceAnswerContext.Provider>
{afterBlock}
</div>
);
}) as React.FC<ChoiceAnswerProps> & ChoiceAnswerSubComponents;

ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => {
const parentProps = React.useContext(ChoiceAnswerContext);
const optionId = `${parentProps.id}-q${parentProps.questionIndex}-opt${optionIndex}`;

return (
<div key={optionId} className={clsx(styles.choiceAnswerOptionContainer)}>
<input
type={parentProps.multiple ? 'checkbox' : 'radio'}
id={optionId}
name={parentProps.id}
value={optionId}
onChange={(e) => parentProps.onChange(optionIndex, e.target.checked)}
checked={parentProps.selectedChoices.includes(optionIndex)}
disabled={parentProps.readonly}
/>
<label htmlFor={optionId}>{children}</label>
{!parentProps.multiple &&
!parentProps.readonly &&
parentProps.selectedChoices.includes(optionIndex) && (
<Button
text="Antwort löschen"
color="danger"
icon={mdiTrashCanOutline}
iconSide="left"
size={0.7}
onClick={() => parentProps.onChange(optionIndex, false)}
className={clsx(styles.btnDeleteAnswer)}
/>
)}
</div>
);
};

ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};

export default ChoiceAnswer;
28 changes: 28 additions & 0 deletions src/components/documents/ChoiceAnswer/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.choiceAnswerContainer {
position: relative;

.syncStatus {
position: absolute;
top: 0.2em;
right: 0.2em;
}
}

.choiceAnswerOptionContainer {
display: flex;
flex-direction: row;
align-items: center;

p {
margin: 0;
}

label {
margin-left: 0.2em;
}

.btnDeleteAnswer {
margin-left: 0.7em;
font-size: 0.8em;
}
}
51 changes: 2 additions & 49 deletions src/components/documents/ProgressState/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,66 +8,19 @@ import Item from './Item';
import { useStore } from '@tdev-hooks/useStore';

import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import { extractListItems } from '@tdev-components/util/domHelpers';
interface Props extends MetaInit {
id: string;
float?: 'left' | 'right';
children?: React.ReactNode;
labels?: React.ReactNode[];
}

const useExtractedChildren = (children: React.ReactElement): React.ReactNode[] | null => {
const liContent = React.useMemo(() => {
if (!children) {
return null;
}
/**
* Extracts the children of the first <ol> element.
* <ol>
* <li>Item 1</li>
* <li>Item 2</li>
* </ol>
* Is represented as:
* ```js
* {
* type: 'ol',
* props: {
* children: [
* {
* type: 'li',
* props: { children: 'Item 1' },
* },
* {
* type: 'li',
* props: { children: 'Item 2' },
* },
* ]
* }
* }
* ```
* Use the `children.props.children` to access the nested `<li>` elements, but don't enforce
* that the root element is an `<ol>`, as it might be a custom component that renders an `<ol>`
* internally. Like that, e.g. `<ul>` is supported as well (where Docusaurus uses an `MDXUl` Component...).
*/
const nestedChildren = (children.props as any)?.children;
if (Array.isArray(nestedChildren)) {
return nestedChildren
.filter((c: any) => typeof c === 'object' && c !== null && c.props?.children)
.map((c: any) => {
return c.props.children as React.ReactNode;
});
}
throw new Error(
`ProgressState must have an <ol> as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}`
);
}, [children]);
return liContent;
};

const ProgressState = observer((props: Props) => {
const [meta] = React.useState(new ModelMeta(props));
const pageStore = useStore('pageStore');
const doc = useFirstMainDocument(props.id, meta);
const children = useExtractedChildren(props.children as React.ReactElement);
const children = extractListItems(props.children as React.ReactElement);
React.useEffect(() => {
doc?.setTotalSteps(children?.length || 0);
}, [doc, children?.length]);
Expand Down
49 changes: 49 additions & 0 deletions src/components/util/domHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';

export const extractListItems = (children: React.ReactElement): React.ReactNode[] | null => {
const liContent = React.useMemo(() => {
if (!children) {
return null;
}
/**
* Extracts the children of the first <ol> element.
* <ol>
* <li>Item 1</li>
* <li>Item 2</li>
* </ol>
* Is represented as:
* ```js
* {
* type: 'ol',
* props: {
* children: [
* {
* type: 'li',
* props: { children: 'Item 1' },
* },
* {
* type: 'li',
* props: { children: 'Item 2' },
* },
* ]
* }
* }
* ```
* Use the `children.props.children` to access the nested `<li>` elements, but don't enforce
* that the root element is an `<ol>`, as it might be a custom component that renders an `<ol>`
* internally. Like that, e.g. `<ul>` is supported as well (where Docusaurus uses an `MDXUl` Component...).
*/
const nestedChildren = (children.props as any)?.children;
if (Array.isArray(nestedChildren)) {
return nestedChildren
.filter((c: any) => typeof c === 'object' && c !== null && c.props?.children)
.map((c: any) => {
return c.props.children as React.ReactNode;
});
}
throw new Error(
`ProgressState must have an <ol> as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}`
);
}, [children]);
return liContent;
};
Loading