From 67d47045766d6096549a59076b4b4be625686dd4 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Tue, 25 Nov 2025 16:38:42 +0100 Subject: [PATCH 1/3] refactor(backend): change internal `OptionsFormData` from flat to nested - Remove `questionpy_sdk.webserver.controllers.question._form_data`. Ref: #249 --- generate-ts-types.py | 2 +- .../{question/controller.py => question.py} | 40 +++--- .../controllers/question/__init__.py | 7 - .../controllers/question/_form_data.py | 132 ------------------ questionpy_sdk/webserver/routes/question.py | 3 +- .../controllers/question/__init__.py | 3 - .../controllers/question/test_form_data.py | 73 ---------- .../test_controller.py => test_question.py} | 34 ++--- 8 files changed, 42 insertions(+), 252 deletions(-) rename questionpy_sdk/webserver/controllers/{question/controller.py => question.py} (75%) delete mode 100644 questionpy_sdk/webserver/controllers/question/__init__.py delete mode 100644 questionpy_sdk/webserver/controllers/question/_form_data.py delete mode 100644 tests/questionpy_sdk/webserver/controllers/question/__init__.py delete mode 100644 tests/questionpy_sdk/webserver/controllers/question/test_form_data.py rename tests/questionpy_sdk/webserver/controllers/{question/test_controller.py => test_question.py} (79%) diff --git a/generate-ts-types.py b/generate-ts-types.py index a3dd0ff4..0e6f0e62 100755 --- a/generate-ts-types.py +++ b/generate-ts-types.py @@ -19,7 +19,7 @@ from questionpy_sdk.webserver.controllers.attempt.data import AttemptRenderData from questionpy_sdk.webserver.controllers.attempt.errors import ErrorSectionKey from questionpy_sdk.webserver.controllers.attempt.question_ui import ClientQuestionDisplayOptions -from questionpy_sdk.webserver.controllers.question.controller import OptionsStateResponse +from questionpy_sdk.webserver.controllers.question import OptionsStateResponse from questionpy_sdk.webserver.errors import DetailedServerError logging.basicConfig(level=logging.INFO, format="") diff --git a/questionpy_sdk/webserver/controllers/question/controller.py b/questionpy_sdk/webserver/controllers/question.py similarity index 75% rename from questionpy_sdk/webserver/controllers/question/controller.py rename to questionpy_sdk/webserver/controllers/question.py index c8f0c7de..2c9b44bb 100644 --- a/questionpy_sdk/webserver/controllers/question/controller.py +++ b/questionpy_sdk/webserver/controllers/question.py @@ -2,15 +2,30 @@ # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +from collections.abc import Mapping +from typing import cast + from pydantic import ConfigDict from pydantic.dataclasses import dataclass import questionpy_sdk.webserver.errors as webserver_errors +from questionpy.form import OptionsFile, RichTextEditor from questionpy_common.api.qtype import InvalidQuestionStateError from questionpy_common.elements import OptionsFormDefinition from questionpy_sdk.webserver.constants import DEFAULT_REQUEST_INFO from questionpy_sdk.webserver.controllers.base import BaseController -from questionpy_sdk.webserver.controllers.question._form_data import OptionsFormData, flatten_form_data, parse_form_data + +type OptionsFormBaseValue = str | int | float | bool | list[str] | list[OptionsFile] | RichTextEditor | None +"""Union of supported form value types (w/o nested).""" + +type OptionsFormModelValue = Mapping[str, OptionsFormValue] +"""Form value type for elements that support nested `FormModel`, like `group`, `section`, etc.""" + +type OptionsFormValue = OptionsFormBaseValue | OptionsFormModelValue | list[OptionsFormModelValue] +"""Union of all form value types.""" + +type OptionsFormData = Mapping[str, OptionsFormValue] +"""Root form data type.""" @dataclass(config=ConfigDict(use_attribute_docstrings=True)) @@ -45,15 +60,13 @@ async def get_questions(self) -> dict[str, OptionsFormData | webserver_errors.De for question_id in states_str: state = states_str[question_id] try: - form_definition, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state) + _, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state) except InvalidQuestionStateError as err: states[question_id] = webserver_errors.DetailedServerError( type(err).__name__, webserver_errors.format_error(err) ) else: - section_names = self._section_names_from_definition(form_definition) - flat_form_data = flatten_form_data(form_data, section_names) - states[question_id] = flat_form_data + states[question_id] = cast("OptionsFormData", form_data) return states @@ -66,16 +79,11 @@ async def get_options_state(self, question_id: str) -> OptionsStateResponse: is_new = True async with self.get_worker() as worker: - form_definition, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state) - - return OptionsStateResponse( - data=flatten_form_data(form_data, self._section_names_from_definition(form_definition)), - is_new=is_new, - ) + _, form_data = await worker.get_options_form(DEFAULT_REQUEST_INFO, state) - async def save_options_state(self, question_id: str, data: OptionsFormData) -> None: - form_data = parse_form_data(data) + return OptionsStateResponse(data=cast("OptionsFormData", form_data), is_new=is_new) + async def save_options_state(self, question_id: str, data: dict[str, object]) -> None: try: old_state = await self._state_manager.read_question_state(question_id) except webserver_errors.MissingQuestionStateError: @@ -83,7 +91,7 @@ async def save_options_state(self, question_id: str, data: OptionsFormData) -> N async with self.get_worker() as worker: question = await worker.create_question_from_options( - DEFAULT_REQUEST_INFO, old_state, form_data=form_data, lms_permissions=None + DEFAULT_REQUEST_INFO, old_state, form_data=data, lms_permissions=None ) await self._state_manager.write_question_state(question_id, question.question_state) @@ -102,7 +110,3 @@ async def clone_question(self, question_id: str, new_question_id: str) -> None: state = await self._state_manager.read_question_state(question_id) await self._state_manager.write_question_state(new_question_id, state) - - @staticmethod - def _section_names_from_definition(form_definition: OptionsFormDefinition) -> list[str]: - return [section.name for section in form_definition.sections] diff --git a/questionpy_sdk/webserver/controllers/question/__init__.py b/questionpy_sdk/webserver/controllers/question/__init__.py deleted file mode 100644 index 3b9f11af..00000000 --- a/questionpy_sdk/webserver/controllers/question/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# This file is part of the QuestionPy SDK. (https://questionpy.org) -# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. -# (c) Technische Universität Berlin, innoCampus - -from .controller import QuestionController - -__all__ = ["QuestionController"] diff --git a/questionpy_sdk/webserver/controllers/question/_form_data.py b/questionpy_sdk/webserver/controllers/question/_form_data.py deleted file mode 100644 index 0eac692f..00000000 --- a/questionpy_sdk/webserver/controllers/question/_form_data.py +++ /dev/null @@ -1,132 +0,0 @@ -# This file is part of the QuestionPy SDK. (https://questionpy.org) -# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. -# (c) Technische Universität Berlin, innoCampus - -import operator -from collections.abc import Iterable, Mapping -from typing import Any - -type OptionsFormDataValue = str | int | float | bool | list[str] | None -type OptionsFormData = Mapping[str, OptionsFormDataValue] - - -def _unflatten(flat_form_data: OptionsFormData) -> dict[str, Any]: - """Splits the keys of a dictionary to form a new nested dictionary. - - Each key of the input dictionary is a reference string of a FormElements from the Options Form. - These strings are split to create a nested dictionary, where each key is one part of the reference. - Additionally: Dictionaries with only numerical keys (Repetition Elements) are replaced by lists. - - Examples: - >>> _unflatten({ - ... "general[my_hidden]": "foo", - ... "general[my_repetition][1][role]": "OPT_1", - ... "general[my_repetition][1][name][first_name]": "John", - ... }) - {'general': {'my_hidden': 'foo', 'my_repetition': [{'role': 'OPT_1', 'name': {'first_name': 'John'}}]}} - """ - unflattened_dict: dict[str, Any] = {} - for flat_key, value in flat_form_data.items(): - key_path = flat_key.replace("]", "").split("[") - current_dict = unflattened_dict - for key_part in key_path[:-1]: - current_dict = current_dict.setdefault(key_part, {}) - current_dict[key_path[-1]] = value - - result = _convert_repetition_dict_to_list(unflattened_dict) - if not isinstance(result, dict): - msg = "The result is not a dictionary." - raise TypeError(msg) - - return result - - -def _convert_repetition_dict_to_list(dictionary: dict[str, Any]) -> dict[str, Any] | list[Any]: - """Recursively transforms a dict with only numerical keys to a list.""" - if not isinstance(dictionary, dict): - return dictionary - - for key, value in dictionary.items(): - dictionary[key] = _convert_repetition_dict_to_list(value) - - if len(dictionary.keys()) > 0 and all(key.isnumeric() for key in dictionary): - # Sort by key (i.e. the index) and put the sorted values into a list. - return [value for key, value in sorted(dictionary.items(), key=operator.itemgetter(0))] - - return dictionary - - -def parse_form_data(form_data: OptionsFormData) -> dict[str, Any]: - """Parses form data from a flat into a nested dictionary to be consumed by Pydantic. - - This function parses a dictionary, where the keys are the references to the Form Elements from the Options Form. - The references are used to create a nested dictionary with the form data. Elements in the 'general' section are - moved to the root of the dictionary. - - Args: - form_data: The flat dictionary representing the form data. - - Returns: - The nested form data. - - Examples: - >>> parse_form_data({ - ... "general[my_hidden]": "foo", - ... "general[my_repetition][1][role]": "OPT_1", - ... "general[my_repetition][1][name][first_name]": "John", - ... }) - {'my_hidden': 'foo', 'my_repetition': [{'role': 'OPT_1', 'name': {'first_name': 'John'}}]} - """ - unflattened_form_data = _unflatten(form_data) - options = unflattened_form_data.get("general", {}) - for section_name, section in unflattened_form_data.items(): - if section_name != "general": - options[section_name] = section - return options - - -def _flatten_value(value: Any, prefix: str, result: dict[str, OptionsFormDataValue]) -> None: - # group - if isinstance(value, dict): - for k, v in value.items(): - _flatten_value(v, f"{prefix}[{k}]", result) - - # repetition - elif isinstance(value, list) and len(value) > 0 and all(isinstance(item, dict) for item in value): - for idx, v in enumerate(value, start=1): - item_prefix = f"{prefix}[{idx}]" - _flatten_value(v, item_prefix, result) - - else: - result[prefix] = value - - -def flatten_form_data(form_data: dict[str, Any], section_names: Iterable[str]) -> OptionsFormData: - """Flattens form data from a nested dictionary into a flat dictionary to be consumed by the frontend. - - This function flattens a nested dictionary into a flat dictionary, where the keys are references - to the Form Elements in the Options Form. Top-level elements are put under the 'general' section, - while elements under the given `section_names` are put under their respective sections. - - Args: - form_data: The nested dictionary representing the form data. - section_names: An iterable of section names that should be treated as sections and not put - under 'general'. - - Returns: - The flat form data. - - Examples: - >>> flatten_form_data( - ... { - ... "my_hidden": "foo", - ... "my_repetition": [{"input": "foo"}], - ... }, - ... section_names=[], - ... ) - {'general[my_hidden]': 'foo', 'general[my_repetition][1][input]': 'foo'} - """ - result: dict[str, OptionsFormDataValue] = {} - for key, value in form_data.items(): - _flatten_value(value, key if key in section_names else f"general[{key}]", result) - return result diff --git a/questionpy_sdk/webserver/routes/question.py b/questionpy_sdk/webserver/routes/question.py index 37d02002..7faa5713 100644 --- a/questionpy_sdk/webserver/routes/question.py +++ b/questionpy_sdk/webserver/routes/question.py @@ -56,7 +56,8 @@ class QuestionStateView(QuestionBaseView): async def get(self) -> web.Response: """Gets the form data for the Options Form from the state storage.""" question_id = self.request.match_info["question_id"] - return self.json_model_response(RootModel(await self.controller.get_options_state(question_id))) + state_response = await self.controller.get_options_state(question_id) + return self.json_model_response(RootModel(state_response)) async def post(self) -> web.Response: """Stores the form data from the Options Form in the state storage.""" diff --git a/tests/questionpy_sdk/webserver/controllers/question/__init__.py b/tests/questionpy_sdk/webserver/controllers/question/__init__.py deleted file mode 100644 index e750778c..00000000 --- a/tests/questionpy_sdk/webserver/controllers/question/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# This file is part of the QuestionPy SDK. (https://questionpy.org) -# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. -# (c) Technische Universität Berlin, innoCampus diff --git a/tests/questionpy_sdk/webserver/controllers/question/test_form_data.py b/tests/questionpy_sdk/webserver/controllers/question/test_form_data.py deleted file mode 100644 index 4ac14ec1..00000000 --- a/tests/questionpy_sdk/webserver/controllers/question/test_form_data.py +++ /dev/null @@ -1,73 +0,0 @@ -# This file is part of the QuestionPy SDK. (https://questionpy.org) -# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. -# (c) Technische Universität Berlin, innoCampus - -from questionpy_sdk.webserver.controllers.question._form_data import OptionsFormData, flatten_form_data, parse_form_data - -FORM_DATA: OptionsFormData = { - "general[input]": "Foo", - "general[chk]": False, - "general[radio]": "OPT_1", - "general[my_select]": "OPT_2", - "general[my_select_multi]": ["OPT_1", "OPT_2"], - "general[my_hidden]": "foo", - "general[my_repetition][1][id]": "49d9828f-9c9c-49fa-9d2e-b6fd83943eb3", - "general[my_repetition][1][role]": "OPT_1", - "general[my_repetition][1][name][first_name]": "Jane", - "general[my_repetition][1][name][last_name]": "Doe", - "general[my_repetition][2][id]": "0b803039-09db-4831-8866-9940ff3c10de", - "general[my_repetition][2][role]": "OPT_2", - "general[my_repetition][2][name][first_name]": "", - "general[my_repetition][2][name][last_name]": "", - "general[has_name]": False, - "general[name_group][first_name]": "John", - "general[name_group][last_name]": "Doe", - "another_section[some_input]": "Bar", -} - -PARSED_FORM_DATA = { - "input": "Foo", - "chk": False, - "radio": "OPT_1", - "my_select": "OPT_2", - "my_select_multi": ["OPT_1", "OPT_2"], - "my_hidden": "foo", - "my_repetition": [ - { - "id": "49d9828f-9c9c-49fa-9d2e-b6fd83943eb3", - "role": "OPT_1", - "name": { - "first_name": "Jane", - "last_name": "Doe", - }, - }, - { - "id": "0b803039-09db-4831-8866-9940ff3c10de", - "role": "OPT_2", - "name": { - "first_name": "", - "last_name": "", - }, - }, - ], - "has_name": False, - "name_group": { - "first_name": "John", - "last_name": "Doe", - }, - "another_section": { - "some_input": "Bar", - }, -} - - -def test_parse_form_data() -> None: - assert parse_form_data(FORM_DATA) == PARSED_FORM_DATA - - -def test_parse_form_data_empty_dict() -> None: - assert parse_form_data({}) == {} - - -def test_flatten_form_data() -> None: - assert flatten_form_data(PARSED_FORM_DATA, ["another_section"]) == FORM_DATA diff --git a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py b/tests/questionpy_sdk/webserver/controllers/test_question.py similarity index 79% rename from tests/questionpy_sdk/webserver/controllers/question/test_controller.py rename to tests/questionpy_sdk/webserver/controllers/test_question.py index 63feca9e..7bad0831 100644 --- a/tests/questionpy_sdk/webserver/controllers/question/test_controller.py +++ b/tests/questionpy_sdk/webserver/controllers/test_question.py @@ -8,8 +8,7 @@ from questionpy_common.api.question import ScoringMethod from questionpy_common.elements import OptionsFormDefinition, TextInputElement -from questionpy_sdk.webserver.controllers.question import QuestionController -from questionpy_sdk.webserver.controllers.question.controller import OptionsStateResponse +from questionpy_sdk.webserver.controllers.question import OptionsStateResponse, QuestionController from questionpy_sdk.webserver.errors import DuplicateQuestionError, MissingQuestionStateError from questionpy_server.models import QuestionCreated @@ -33,21 +32,22 @@ async def test_get_form_definition( async def test_get_questions(controller: QuestionController, mock_worker: AsyncMock) -> None: - mock_worker.get_options_form.return_value = ( - OptionsFormDefinition(general=[TextInputElement(label="Foo", name="foo")]), - {"foo": "Bar"}, + mock_worker.get_options_form.side_effect = ( + ( + OptionsFormDefinition(general=[TextInputElement(label="Foo", name="foo")]), + {"foo": "foo value"}, + ), + ( + OptionsFormDefinition(general=[TextInputElement(label="Bar", name="bar")]), + {"bar": "bar value"}, + ), ) questions = await controller.get_questions() - question_1 = questions["svyhZCg8"] - assert isinstance(question_1, dict) - assert question_1["general[foo]"] == "Bar" - - question_2 = questions["tKVJTdsv"] - assert isinstance(question_2, dict) - assert question_2["general[foo]"] == "Bar" - - assert len(questions) == 2 + assert questions == { + "svyhZCg8": {"foo": "foo value"}, + "tKVJTdsv": {"bar": "bar value"}, + } async def test_get_options_state( @@ -60,7 +60,7 @@ async def test_get_options_state( options_state = await controller.get_options_state("QaKxpanc") mock_state_manager.read_question_state.assert_called_once() - assert options_state == OptionsStateResponse(data={"general[foo]": "Bar"}, is_new=False) + assert options_state == OptionsStateResponse(data={"foo": "Bar"}, is_new=False) async def test_get_options_state_new( @@ -74,7 +74,7 @@ async def test_get_options_state_new( options_state = await controller.get_options_state("QaKxpanc") mock_state_manager.read_question_state.assert_called_once() - assert options_state == OptionsStateResponse(data={"general[foo]": "Bar"}, is_new=True) + assert options_state == OptionsStateResponse(data={"foo": "Bar"}, is_new=True) async def test_save_options_state( @@ -83,7 +83,7 @@ async def test_save_options_state( mock_worker.create_question_from_options.return_value = QuestionCreated( lang="en", scoring_method=ScoringMethod.AUTOMATICALLY_SCORABLE, question_state="question_state" ) - await controller.save_options_state("QaKxpanc", {"general[foo]": "Baz"}) + await controller.save_options_state("QaKxpanc", {"foo": "Baz"}) mock_state_manager.read_question_state.assert_called_once() mock_state_manager.write_question_state.assert_called_once_with("QaKxpanc", "question_state") From 22c53cc98c84e90732ab0f38dbf868e9b4c9c651 Mon Sep 17 00:00:00 2001 From: Mirko Dietrich Date: Tue, 25 Nov 2025 16:39:25 +0100 Subject: [PATCH 2/3] refactor(frontend): handle nested structure in form data - Consistently use `path` to reference form elements instead of string key (`name`). - Discard custom model for `file_upload`, `repeat`, and `rich_text_editor` elements. - Extract `` component. - `useCommon` -> `useId`, and `usePath`. Closes #249 --- .../src/components/question/FormElement.vue | 4 +- .../question/ValidationFeedback.vue | 15 ++ .../question/elements/CheckboxElement.vue | 11 +- .../question/elements/GeneratedIdElement.vue | 12 +- .../question/elements/GroupElement.vue | 10 +- .../question/elements/HiddenElement.vue | 11 +- .../question/elements/RadioGroupElement.vue | 12 +- .../question/elements/RepetitionElement.vue | 21 +- .../question/elements/SelectElement.vue | 11 +- .../question/elements/StaticTextElement.vue | 4 +- .../question/elements/TextAreaElement.vue | 19 +- .../question/elements/TextInputElement.vue | 19 +- .../question/__tests__/formDataUtils.test.ts | 237 +++++++++++------- .../composables/question/elements/index.ts | 3 +- .../question/elements/useCommon.ts | 37 --- .../question/elements/useConditions.ts | 45 ++-- .../composables/question/elements/useHelp.ts | 10 +- .../composables/question/elements/useId.ts | 23 ++ .../composables/question/elements/useModel.ts | 13 +- .../composables/question/elements/usePath.ts | 23 ++ .../question/elements/useValidation.ts | 45 ++-- .../src/composables/question/formDataUtils.ts | 166 ++++++------ .../composables/question/useFormDataState.ts | 78 ++++-- .../composables/question/useRepetitions.ts | 111 +++----- .../types/OptionsStateResponse.generated.ts | 35 ++- frontend/src/types/index.ts | 36 ++- frontend/src/types/typeUtils.ts | 33 ++- 27 files changed, 615 insertions(+), 429 deletions(-) create mode 100644 frontend/src/components/question/ValidationFeedback.vue delete mode 100644 frontend/src/composables/question/elements/useCommon.ts create mode 100644 frontend/src/composables/question/elements/useId.ts create mode 100644 frontend/src/composables/question/elements/usePath.ts diff --git a/frontend/src/components/question/FormElement.vue b/frontend/src/components/question/FormElement.vue index 4b6961d0..2a2a97a6 100644 --- a/frontend/src/components/question/FormElement.vue +++ b/frontend/src/components/question/FormElement.vue @@ -12,14 +12,14 @@ import { computed } from 'vue' import type { ComponentInstance } from 'vue' -import type { FormElement } from '@/types' +import type { ElementPath, FormElement } from '@/types' import { mapElementKindToComponent } from './elements' const props = defineProps<{ disabled: boolean element: FormElement - pathPrefix: string[] + pathPrefix: ElementPath }>() const elementComponent = computed(() => mapElementKindToComponent(props.element.kind)) diff --git a/frontend/src/components/question/ValidationFeedback.vue b/frontend/src/components/question/ValidationFeedback.vue new file mode 100644 index 00000000..10d75e91 --- /dev/null +++ b/frontend/src/components/question/ValidationFeedback.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/components/question/elements/CheckboxElement.vue b/frontend/src/components/question/elements/CheckboxElement.vue index a24ceba9..f1d975f6 100644 --- a/frontend/src/components/question/elements/CheckboxElement.vue +++ b/frontend/src/components/question/elements/CheckboxElement.vue @@ -10,7 +10,6 @@ :aria-describedby="ariaDescribedBy" :disabled="isDisabled" :id="id" - :name="name" :required="element.required" v-model="model" >{{ element.right_label }}() -const { id, name } = useCommon(pathPrefix, element) +const path = usePath(pathPrefix, element) +const id = useId(path) const model = useModel(pathPrefix, element) const { isDisabledByCond, isHiddenByCond } = useConditions(pathPrefix, element) const { helpId, helpText } = useHelp(pathPrefix, element) diff --git a/frontend/src/components/question/elements/GeneratedIdElement.vue b/frontend/src/components/question/elements/GeneratedIdElement.vue index 3b881a71..7e21826e 100644 --- a/frontend/src/components/question/elements/GeneratedIdElement.vue +++ b/frontend/src/components/question/elements/GeneratedIdElement.vue @@ -5,17 +5,19 @@ --> diff --git a/frontend/src/components/question/elements/GroupElement.vue b/frontend/src/components/question/elements/GroupElement.vue index 6d76bb67..9209ba41 100644 --- a/frontend/src/components/question/elements/GroupElement.vue +++ b/frontend/src/components/question/elements/GroupElement.vue @@ -20,18 +20,18 @@ diff --git a/frontend/src/components/question/elements/HiddenElement.vue b/frontend/src/components/question/elements/HiddenElement.vue index 5137d4e7..9759889e 100644 --- a/frontend/src/components/question/elements/HiddenElement.vue +++ b/frontend/src/components/question/elements/HiddenElement.vue @@ -5,22 +5,23 @@ --> diff --git a/frontend/src/components/question/elements/RadioGroupElement.vue b/frontend/src/components/question/elements/RadioGroupElement.vue index 5760993b..bd3ee4be 100644 --- a/frontend/src/components/question/elements/RadioGroupElement.vue +++ b/frontend/src/components/question/elements/RadioGroupElement.vue @@ -11,7 +11,6 @@ :aria-describedby="ariaDescribedBy" :disabled="isDisabled" :id="id" - :name="name" :options="options" :required="element.required" /> @@ -24,21 +23,24 @@ import { computed } from 'vue' import { useAriaDescribedBy, - useCommon, useConditions, useHelp, + useId, useIsDisabled, useModel, + usePath, + useValidation, } from '@/composables/question/elements' -import type { RadioGroupElement } from '@/types' +import type { ElementPath, RadioGroupElement } from '@/types' const { disabled, element, pathPrefix } = defineProps<{ disabled: boolean element: RadioGroupElement - pathPrefix: string[] + pathPrefix: ElementPath }>() -const { id, name } = useCommon(pathPrefix, element) +const path = usePath(pathPrefix, element) +const id = useId(path) const model = useModel(pathPrefix, element) const { isDisabledByCond, isHiddenByCond } = useConditions(pathPrefix, element) const { helpId, helpText } = useHelp(pathPrefix, element) diff --git a/frontend/src/components/question/elements/RepetitionElement.vue b/frontend/src/components/question/elements/RepetitionElement.vue index cdc688d0..050e6476 100644 --- a/frontend/src/components/question/elements/RepetitionElement.vue +++ b/frontend/src/components/question/elements/RepetitionElement.vue @@ -5,7 +5,7 @@ --> @@ -43,18 +44,18 @@ import IMdiDelete from '~icons/mdi/delete' import { computed } from 'vue' import { useRepetitions } from '@/composables/question' -import { useCommon, useIsDisabled } from '@/composables/question/elements' -import type { RepetitionElement } from '@/types' +import { useId, useIsDisabled, usePath, useValidation } from '@/composables/question/elements' +import type { ElementPath, RepetitionElement } from '@/types' const { disabled, element, pathPrefix } = defineProps<{ disabled: boolean element: RepetitionElement - pathPrefix: string[] + pathPrefix: ElementPath }>() -const { path } = useCommon(pathPrefix, element) -const { addRepetition, getRepetitionCount, removeRepetition } = useRepetitions(path.value) +const path = usePath(pathPrefix, element) +const id = useId(path) +const { add, count, remove } = useRepetitions(pathPrefix, element) const isDisabled = useIsDisabled(computed(() => disabled)) - -const count = computed(() => getRepetitionCount()) +const validation = useValidation(path) diff --git a/frontend/src/components/question/elements/SelectElement.vue b/frontend/src/components/question/elements/SelectElement.vue index 8707e0df..5d445a86 100644 --- a/frontend/src/components/question/elements/SelectElement.vue +++ b/frontend/src/components/question/elements/SelectElement.vue @@ -12,7 +12,6 @@ :disabled="isDisabled" :id="id" :multiple="element.multiple" - :name="name" :options="options" :required="element.required" :select-size="size" @@ -26,24 +25,26 @@ import { computed } from 'vue' import { useAriaDescribedBy, - useCommon, useConditions, useHelp, + useId, useIsDisabled, useModel, + usePath, } from '@/composables/question/elements' -import type { SelectElement } from '@/types' +import type { ElementPath, SelectElement } from '@/types' const { disabled, element, pathPrefix } = defineProps<{ disabled: boolean element: SelectElement - pathPrefix: string[] + pathPrefix: ElementPath }>() const MAX_SIZE = 8 const size = computed(() => (element.multiple ? Math.min(element.options.length, MAX_SIZE) : undefined)) -const { id, name } = useCommon(pathPrefix, element) +const path = usePath(pathPrefix, element) +const id = useId(path) const model = useModel(pathPrefix, element) const { isDisabledByCond, isHiddenByCond } = useConditions(pathPrefix, element) const { helpId, helpText } = useHelp(pathPrefix, element) diff --git a/frontend/src/components/question/elements/StaticTextElement.vue b/frontend/src/components/question/elements/StaticTextElement.vue index 838c0197..55b4caa4 100644 --- a/frontend/src/components/question/elements/StaticTextElement.vue +++ b/frontend/src/components/question/elements/StaticTextElement.vue @@ -13,11 +13,11 @@ diff --git a/frontend/src/components/question/elements/CheckboxElement.vue b/frontend/src/components/question/elements/CheckboxElement.vue index f1d975f6..0317facf 100644 --- a/frontend/src/components/question/elements/CheckboxElement.vue +++ b/frontend/src/components/question/elements/CheckboxElement.vue @@ -5,22 +5,23 @@ --> diff --git a/frontend/src/components/question/elements/GroupElement.vue b/frontend/src/components/question/elements/GroupElement.vue index 9209ba41..cad85f0d 100644 --- a/frontend/src/components/question/elements/GroupElement.vue +++ b/frontend/src/components/question/elements/GroupElement.vue @@ -5,16 +5,17 @@ --> diff --git a/frontend/src/components/question/elements/RadioGroupElement.vue b/frontend/src/components/question/elements/RadioGroupElement.vue index bd3ee4be..44b9d122 100644 --- a/frontend/src/components/question/elements/RadioGroupElement.vue +++ b/frontend/src/components/question/elements/RadioGroupElement.vue @@ -5,7 +5,7 @@ --> @@ -47,4 +49,5 @@ const { helpId, helpText } = useHelp(pathPrefix, element) const isDisabled = useIsDisabled(computed(() => disabled || isDisabledByCond.value)) const ariaDescribedBy = useAriaDescribedBy([helpId.value]) const options = computed(() => element.options.map(({ value, label }) => ({ value, text: label }))) +const validation = useValidation(path) diff --git a/frontend/src/components/question/elements/RepetitionElement.vue b/frontend/src/components/question/elements/RepetitionElement.vue index 050e6476..22250b4b 100644 --- a/frontend/src/components/question/elements/RepetitionElement.vue +++ b/frontend/src/components/question/elements/RepetitionElement.vue @@ -5,42 +5,32 @@ -->