Skip to content

Commit 2a33a73

Browse files
authored
Merge pull request #1114 from con2/feat/dimension-field-value-subset
feat/dimension field value subset
2 parents 71c97c6 + aea72b3 commit 2a33a73

17 files changed

Lines changed: 466 additions & 44 deletions

File tree

‎kompassi-v2-frontend/src/components/forms/EditFieldModal.tsx‎

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"use client";
22

3-
import { useCallback, useMemo } from "react";
3+
import { useCallback, useMemo, useState } from "react";
44
import Modal from "react-bootstrap/Modal";
55
import {
6+
encodeDimensionChoice,
67
fieldToValues,
8+
findEditorDimensionByChoice,
79
formDataToField,
810
getFieldEditorFields,
911
type AnnotationOption,
@@ -40,26 +42,67 @@ const EditFieldModal = ({
4042
formContext,
4143
}: EditFieldModalProps) => {
4244
const t = messages.FormEditor.editFieldModal;
43-
const fields = getFieldEditorFields(
44-
fieldToEdit.type,
45-
messages.FormEditor.editFieldForm,
46-
dimensions,
47-
annotations,
48-
formContext,
45+
const values = useMemo(
46+
() => fieldToValues(fieldToEdit, defaultDimensionUniverse),
47+
[fieldToEdit, defaultDimensionUniverse],
48+
);
49+
50+
// The value checkboxes follow the dimension dropdown, so its value must be known while editing.
51+
// A new field has no dimension yet, and the required dropdown then shows its first choice.
52+
const [dimensionChoice, setDimensionChoice] = useState<string>(
53+
() =>
54+
values.dimension ??
55+
(dimensions.length ? encodeDimensionChoice(dimensions[0]) : ""),
56+
);
57+
const handleChange = useCallback((event: React.FormEvent) => {
58+
const target = event.target as HTMLInputElement;
59+
if (target.name === "dimension") {
60+
setDimensionChoice(target.value);
61+
}
62+
}, []);
63+
64+
// The subset saved for the field's own dimension means nothing for another dimension.
65+
const formValues = useMemo(
66+
() =>
67+
dimensionChoice === values.dimension
68+
? values
69+
: { ...values, subsetValues: undefined },
70+
[values, dimensionChoice],
71+
);
72+
73+
const fields = useMemo(
74+
() =>
75+
getFieldEditorFields(
76+
fieldToEdit.type,
77+
messages.FormEditor.editFieldForm,
78+
dimensions,
79+
annotations,
80+
formContext,
81+
findEditorDimensionByChoice(dimensions, dimensionChoice),
82+
),
83+
[
84+
fieldToEdit.type,
85+
messages.FormEditor.editFieldForm,
86+
dimensions,
87+
annotations,
88+
formContext,
89+
dimensionChoice,
90+
],
4991
);
5092

5193
const handleSubmit = useCallback(
5294
(event: React.SyntheticEvent) => {
5395
event.preventDefault();
5496
const formData = new FormData(event.target as HTMLFormElement);
55-
const field = formDataToField(fields, fieldToEdit, formData);
97+
const field = formDataToField(
98+
fields,
99+
fieldToEdit,
100+
formData,
101+
defaultDimensionUniverse,
102+
);
56103
onSubmit(field);
57104
},
58-
[fieldToEdit, fields, onSubmit],
59-
);
60-
const values = useMemo(
61-
() => fieldToValues(fieldToEdit, defaultDimensionUniverse),
62-
[fieldToEdit, defaultDimensionUniverse],
105+
[fieldToEdit, fields, onSubmit, defaultDimensionUniverse],
63106
);
64107

65108
return (
@@ -69,11 +112,11 @@ const EditFieldModal = ({
69112
{t.title}: {messages.FormEditor.fieldTypes[fieldToEdit.type]}
70113
</Modal.Title>
71114
</Modal.Header>
72-
<form onSubmit={handleSubmit}>
115+
<form onSubmit={handleSubmit} onChange={handleChange}>
73116
<Modal.Body>
74117
<SchemaForm
75118
fields={fields}
76-
values={values}
119+
values={formValues}
77120
messages={messages.SchemaForm}
78121
/>
79122
</Modal.Body>

‎kompassi-v2-frontend/src/components/forms/FormEditor.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,23 @@ function injectChoices(
5656
// has no choices pre-populated by the server
5757
(!field.choices || field.choices.length === 0)
5858
) {
59-
// TODO(#643) subsetValues
6059
const dimension = findEditorDimension(
6160
dimensions,
6261
field.dimension,
6362
getFieldDimensionUniverse(field, defaultDimensionUniverse),
6463
);
6564
if (dimension) {
65+
const subsetValues = field.subsetValues?.length
66+
? new Set(field.subsetValues)
67+
: undefined;
6668
return {
6769
...field,
68-
choices: dimension.values.map(({ slug, title }) => ({
69-
slug,
70-
title: title || slug,
71-
})),
70+
choices: dimension.values
71+
.filter(({ slug }) => !subsetValues || subsetValues.has(slug))
72+
.map(({ slug, title }) => ({
73+
slug,
74+
title: title || slug,
75+
})),
7276
};
7377
}
7478
}

‎kompassi-v2-frontend/src/components/forms/SchemaFormInput.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,9 @@ function SchemaFormInput({
228228
}
229229
case "MultiSelect":
230230
case "DimensionMultiSelect":
231+
// the checkboxes are uncontrolled, so they must be remounted to show a new value or new choices
231232
return (
232-
<Fragment key={JSON.stringify(value)}>
233+
<Fragment key={JSON.stringify([value, field.choices])}>
233234
{field.choices?.map((choice) => {
234235
const name = `${slug}.${choice.slug}`;
235236
const choiceId = `${idPrefix}${idPrefix ? "-" : ""}${field.slug}-${

‎kompassi-v2-frontend/src/components/forms/editFieldForm.ts‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function findEditorDimension(
4040

4141
/// The dimension dropdown of the field editor encodes the universe into the choice slug
4242
/// so that one dropdown can offer the dimensions of both universes.
43-
function encodeDimensionChoice(dimension: EditorDimension): string {
43+
export function encodeDimensionChoice(dimension: EditorDimension): string {
4444
return dimension.universe
4545
? `${dimension.universe}/${dimension.slug}`
4646
: dimension.slug;
@@ -60,6 +60,14 @@ function decodeDimensionChoice(value: string): {
6060
};
6161
}
6262

63+
export function findEditorDimensionByChoice(
64+
dimensions: EditorDimension[],
65+
choice: string,
66+
): EditorDimension | undefined {
67+
const { dimension, dimensionUniverse } = decodeDimensionChoice(choice);
68+
return findEditorDimension(dimensions, dimension, dimensionUniverse);
69+
}
70+
6371
export interface AnnotationOption {
6472
slug: string;
6573
title: string;
@@ -92,6 +100,8 @@ export function getFieldEditorFields(
92100
dimensions: EditorDimension[],
93101
annotations: AnnotationOption[] = [],
94102
formContext: FormEditorContext = "survey",
103+
/// The dimension currently chosen in the dimension dropdown, whose values are offered as subsetValues.
104+
selectedDimension?: EditorDimension,
95105
): Field[] {
96106
const t = messages;
97107
const slugField: Field = {
@@ -213,6 +223,21 @@ export function getFieldEditorFields(
213223
...t.dimension,
214224
};
215225

226+
const subsetValuesFields: Field[] = selectedDimension?.values.length
227+
? [
228+
{
229+
type: "MultiSelect",
230+
slug: "subsetValues",
231+
required: false,
232+
choices: selectedDimension.values.map(({ slug, title }) => ({
233+
slug,
234+
title: title || slug,
235+
})),
236+
...t.subsetValues,
237+
},
238+
]
239+
: [];
240+
216241
const maxLengthField: Field = {
217242
type: "NumberField",
218243
slug: "maxLength",
@@ -238,6 +263,10 @@ export function getFieldEditorFields(
238263
return editableFieldFields.concat([choicesField]); // , encryptTo]);
239264
case "DimensionSingleSelect":
240265
case "DimensionMultiSelect":
266+
return editableFieldFields.concat([
267+
dimensionField,
268+
...subsetValuesFields,
269+
]); // , encryptTo]);
241270
case "DimensionSingleCheckbox":
242271
return editableFieldFields.concat([dimensionField]); // , encryptTo]);
243272
case "RadioMatrix":
@@ -321,6 +350,7 @@ export function formDataToField(
321350
fields: Field[],
322351
initialValues: Field,
323352
formData: FormData,
353+
defaultDimensionUniverse?: DimensionUniverse,
324354
): Field {
325355
const values: Values = {
326356
...processFormData(fields, formData),
@@ -355,14 +385,32 @@ export function formDataToField(
355385
};
356386
case "DimensionSingleSelect":
357387
case "DimensionMultiSelect":
358-
case "DimensionSingleCheckbox":
359-
// an unqualified choice must clear a qualifier the field had before
388+
case "DimensionSingleCheckbox": {
389+
// The editor offers no value checkboxes when the chosen dimension is unknown to it or has
390+
// no values. A saved subset must survive such an edit unless the dimension was changed,
391+
// as the subset of one dimension means nothing for another.
392+
const dimensionChanged =
393+
values.dimension !==
394+
fieldToValues(initialValues, defaultDimensionUniverse).dimension;
395+
let subsetValues: string[] | undefined;
396+
if (fields.some((field) => field.slug === "subsetValues")) {
397+
subsetValues = values.subsetValues as string[] | undefined;
398+
} else if (
399+
!dimensionChanged &&
400+
initialValues.type !== "DimensionSingleCheckbox"
401+
) {
402+
subsetValues = initialValues.subsetValues;
403+
}
360404
return {
361405
...initialValues,
362406
...values,
407+
// nothing ticked means every value is offered, which is spelled as no subsetValues at all
408+
subsetValues: subsetValues?.length ? subsetValues : undefined,
409+
// an unqualified choice must clear a qualifier the field had before
363410
dimensionUniverse: undefined,
364411
...decodeDimensionChoice(String(values.dimension ?? "")),
365412
} as Field;
413+
}
366414
default:
367415
return { ...initialValues, ...values };
368416
}

‎kompassi-v2-frontend/src/translations/en.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@ const translations = {
310310
title: "Dimension",
311311
helpText: "Which dimension should the field get its choices from?",
312312
},
313+
subsetValues: {
314+
title: "Offered values",
315+
helpText:
316+
"Tick the values the respondent may choose from. Leave all unticked to offer every value of the dimension. Responses already holding a value that is left out cannot be edited or passed forward until the value is ticked again.",
317+
},
313318
dimensionUniverse: {
314319
program: "Program item",
315320
involvement: "Program host",

‎kompassi-v2-frontend/src/translations/fi.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,11 @@ const translations: Translations = {
313313
title: "Dimensio",
314314
helpText: "Mistä dimensiosta tämä kenttä saa vastausvaihtoehtonsa?",
315315
},
316+
subsetValues: {
317+
title: "Tarjottavat arvot",
318+
helpText:
319+
"Valitse arvot, joista vastaaja voi valita. Jätä kaikki valitsematta, jos haluat tarjota dimension kaikki arvot. Vastauksia, joissa on jo valitsematta jätetty arvo, ei voi muokata eikä viedä eteenpäin ennen kuin arvo valitaan uudelleen.",
320+
},
316321
dimensionUniverse: {
317322
program: "Ohjelmanumero",
318323
involvement: "Ohjelmanpitäjä",

‎kompassi-v2-frontend/src/translations/sv.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ const translations: Translations = {
304304
title: "Dimension",
305305
helpText: "Vilken dimension ska fältet hämta sina val från?",
306306
},
307+
subsetValues: {
308+
title: "Erbjudna värden",
309+
helpText:
310+
"Kryssa för de värden som svararen kan välja mellan. Lämna alla okryssade för att erbjuda dimensionens alla värden. Svar som redan innehåller ett värde som lämnats bort kan inte redigeras eller föras vidare förrän värdet kryssas för igen.",
311+
},
307312
dimensionUniverse: {
308313
program: "Programpunkt",
309314
involvement: "Programvärd",

‎kompassi/dimensions/graphql/mutations/delete_dimension_value.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ def mutate(
3636
raise Exception("Cannot remove dimension value")
3737

3838
value.delete()
39+
value.dimension.refresh_dependents()
3940

4041
return DeleteDimensionValue(slug=input.value_slug) # type: ignore

‎kompassi/dimensions/models/dimension.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from .universe import Universe
1818

1919
if TYPE_CHECKING:
20+
from collections.abc import Iterable
21+
2022
from kompassi.forms.models.field import Choice
2123

2224
from .dimension_value import DimensionValue
@@ -189,7 +191,10 @@ def get_values(self, language: str | None = None) -> models.QuerySet[DimensionVa
189191
value_order_field = self.get_value_order_field(language)
190192
return self.values.all().order_by(value_order_field)
191193

192-
def as_choices(self, language: str | None = None) -> list[Choice]:
194+
def as_choices(self, language: str | None = None, value_slugs: Iterable[str] | None = None) -> list[Choice]:
195+
"""
196+
:param value_slugs: If given, only these values are included. Slugs of nonexistent values are ignored.
197+
"""
193198
from kompassi.forms.models.field import Choice
194199

195200
title_fields = [f"title_{lang}" for lang in SUPPORTED_LANGUAGE_CODES]
@@ -198,12 +203,16 @@ def as_choices(self, language: str | None = None) -> list[Choice]:
198203
if language in SUPPORTED_LANGUAGE_CODES:
199204
title_fields = [f"title_{language}"] + [field for field in title_fields if field != f"title_{language}"]
200205

206+
values = self.get_values(language)
207+
if value_slugs is not None:
208+
values = values.filter(slug__in=value_slugs)
209+
201210
return [
202211
Choice(
203212
slug=slug,
204213
title=next((localized_title for localized_title in localized_titles if localized_title), slug),
205214
)
206-
for slug, *localized_titles in self.get_values(language).values_list("slug", *title_fields)
215+
for slug, *localized_titles in values.values_list("slug", *title_fields)
207216
]
208217

209218
def get_title(self, lang: str = DEFAULT_LANGUAGE) -> str:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.db import migrations
2+
3+
4+
def clear_subset_values(apps, schema_editor):
5+
"""
6+
promote_field_to_dimension used to write subsetValues to every promoted field without it ever
7+
being enforced. Now that subsetValues restricts the choices of a dimension field, it must not
8+
silently restrict promoted fields.
9+
"""
10+
Form = apps.get_model("forms", "Form")
11+
12+
for form in Form.objects.all():
13+
changed = False
14+
for field_list in (form.fields, form.cached_enriched_fields):
15+
for field in field_list:
16+
if "subsetValues" in field:
17+
del field["subsetValues"]
18+
changed = True
19+
if changed:
20+
form.save(update_fields=["fields", "cached_enriched_fields"])
21+
22+
23+
class Migration(migrations.Migration):
24+
dependencies = [ # noqa: RUF012
25+
("forms", "0059_survey_followup_purpose_and_parent"),
26+
]
27+
28+
operations = [ # noqa: RUF012
29+
migrations.RunPython(
30+
code=clear_subset_values,
31+
reverse_code=migrations.RunPython.noop,
32+
elidable=True,
33+
),
34+
]

0 commit comments

Comments
 (0)