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
119 changes: 119 additions & 0 deletions src/components/documents/ChoiceAnswer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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';

interface ChoiceAnswerProps {
id: string;
questionIndex?: number;
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: '',
multiple: false,
readonly: false,
selectedChoices: [],
onChange: () => {}
} as {
id: string;
multiple?: boolean;
readonly?: boolean;
selectedChoices: number[];
onChange: (optionIndex: number, checked: boolean) => void;
});

const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
const [meta] = React.useState(new ModelMeta(props));
const doc = useFirstMainDocument(props.id, meta);
const questionIndex = props.questionIndex ?? 0;

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) => {
if (props.multiple) {
doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked);
} else {
doc?.updateSingleChoiceSelection(questionIndex, optionIndex);
}
};

return (
<div>
{beforeBlock}
<ChoiceAnswerContext.Provider
value={{
id: props.id,
multiple: props.multiple,
readonly: props.readonly,
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}-option-${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>
</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;
13 changes: 13 additions & 0 deletions src/components/documents/ChoiceAnswer/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.choiceAnswerOptionContainer {
display: flex;
flex-direction: row;
align-items: center;

p {
margin: 0;
}

label {
margin-left: 0.2em;
}
}
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;
};
91 changes: 91 additions & 0 deletions src/models/documents/ChoiceAnswer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { TypeDataMapping, Document as DocumentProps, Access } from '@tdev-api/document';
import { TypeMeta } from '@tdev-models/DocumentRoot';
import iDocument, { Source } from '@tdev-models/iDocument';
import DocumentStore from '@tdev-stores/DocumentStore';
import { action, computed, observable } from 'mobx';

export interface ChoiceAnswerChoices {
[type: number]: number[];
}

export interface MetaInit {
readonly?: boolean;
}

export class ModelMeta extends TypeMeta<'choice_answer'> {
readonly type = 'choice_answer';
readonly readonly?: boolean;

constructor(props: Partial<MetaInit>) {
super('choice_answer', props.readonly ? Access.RO_User : undefined);
this.readonly = props.readonly;
}

get defaultData(): TypeDataMapping['choice_answer'] {
return {
choices: {}
};
}
}

class ChoiceAnswer extends iDocument<'choice_answer'> {
@observable.ref accessor choices: ChoiceAnswerChoices;

constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) {
super(props, store);
this.choices = props.data?.choices || {};
}

@action
setData(data: TypeDataMapping['choice_answer'], from: Source, updatedAt?: Date): void {
this.choices = data.choices;
if (from === Source.LOCAL) {
this.save();
}
if (updatedAt) {
this.updatedAt = new Date(updatedAt);
}
}

@action
updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void {
this.updatedAt = new Date();
this.choices = {
...this.choices,
[questionIndex]: [optionIndex]
};
this.saveNow();
}

@action
updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void {
this.updatedAt = new Date();
const currentSelections = new Set<number>(this.choices[questionIndex] as number[] | []);
if (selected) {
currentSelections.add(optionIndex);
} else {
currentSelections.delete(optionIndex);
}
this.choices = {
...this.choices,
[questionIndex]: Array.from(currentSelections)
};
this.saveNow();
}

get data(): TypeDataMapping['choice_answer'] {
return {
choices: this.choices
};
}

@computed
get meta(): ModelMeta {
if (this.root?.type === 'choice_answer') {
return this.root.meta as ModelMeta;
}
return new ModelMeta({});
}
}

export default ChoiceAnswer;
Loading
Loading