Skip to content

Commit 8e0cff3

Browse files
committed
feat(case types): a property can take its choices from a concept scheme
A choice list was typed into the attribute that used it, so two case types both asking for a wijk each carried their own copy and the two drifted. OpenRegister holds those lists as SKOS concept schemes, and a property now points at one with conceptScheme. The precedence has one home: the scheme rules when it is set, the inline enumValues list rules when it is not, and a definition carrying both is reported rather than quietly resolved. The authoring form says so while the list is being typed and the index says so on hover. dossiq declares the binding and does not render the picker. The key is not forwarded to the case form yet, because OpenRegister has not published x-openregister-concept-scheme in its property vocabulary and a form may only forward a key the vocabulary holds.
1 parent c96457d commit 8e0cff3

8 files changed

Lines changed: 565 additions & 7 deletions

File tree

‎lib/Settings/dossiq_register.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,7 @@
10691069
"propertyDefinition": {
10701070
"slug": "propertyDefinition",
10711071
"icon": "FormatListBulletedType",
1072-
"version": "1.3.0",
1072+
"version": "1.4.0",
10731073
"x-schema-org": "schema:PropertyValueSpecification",
10741074
"x-zgw-equivalent": "Eigenschap",
10751075
"title": "Property Definition",
@@ -1182,6 +1182,11 @@
11821182
"title": "Calculation",
11831183
"description": "A JSON expression that derives this answer from other fields, evaluated by OpenRegister. A Twig expression is not accepted: the JSON form is diffable and an auditor can read it a year later, which is the decision this field carries."
11841184
},
1185+
"conceptScheme": {
1186+
"type": "string",
1187+
"title": "Concept scheme",
1188+
"description": "The SKOS concept scheme in OpenRegister whose concepts are this field's options, such as a municipality-wide list of wijken or afhandelkanalen. Set it and the scheme rules: the case form offers the scheme's concepts and stores the chosen concept's URI, and the inline list under Limit answers to a list is ignored. Leave it empty and enumValues rules. OpenRegister owns the scheme and the picker; dossiq stores which scheme a field is bound to. The case form does not read it yet: the key x-openregister-concept-scheme is not in OpenRegister's published vocabulary, and a form may only forward a key the vocabulary holds."
1189+
},
11851190
"propertySource": {
11861191
"type": "string",
11871192
"title": "Values from a register",

‎openspec/changes/code-lists-from-concepts/tasks.md‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22

33
Tier: V1. Kind: config. Row 11.10.
44

5-
- [ ] 1.1 `lib/Settings/dossiq_register.json` `propertyDefinition.conceptScheme`.
5+
- [x] 1.1 `lib/Settings/dossiq_register.json` `propertyDefinition.conceptScheme`,
6+
schema version moved to 1.4.0 so an instance reconciles the new key.
67
- `@spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md`
7-
- [ ] 1.2 The case data property renderer: scheme picker when set (D-1,
8-
D-2); precedence unit test.
9-
- [ ] 1.3 Property definitions index: Scheme column; authoring warning
8+
- [x] 1.2 The case data property renderer: scheme picker when set (D-1,
9+
D-2); precedence unit test. `src/services/conceptScheme.js` holds the one
10+
precedence rule the form and the index both read; the binding is declared
11+
and not forwarded, because OpenRegister has not published
12+
`x-openregister-concept-scheme` yet (`PENDING_PLATFORM_KEYS`), and the
13+
picker is the platform's per D-2. Mutation-checked: dropping the scheme
14+
branch from `optionSourceFor()` reddens three assertions, one of them
15+
`expected 'inline' to be 'scheme'`.
16+
- [x] 1.3 Property definitions index: Scheme column; authoring warning
1017
when both are set.
11-
- [ ] 2.1 `tests/e2e/code-lists-from-concepts.spec.ts`; `openspec validate
12-
code-lists-from-concepts --strict`.
18+
- [x] 2.1 `tests/e2e/code-lists-from-concepts.spec.ts`; `openspec validate
19+
code-lists-from-concepts --strict` passes.

‎src/components/PropertyDefinitionFields.vue‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,32 @@
158158
class="pd-fields__textarea"
159159
@input="setEnumValues($event.target.value)" />
160160
</div>
161+
<div class="pd-fields__field">
162+
<label class="pd-fields__label" :for="id('concept-scheme')">{{
163+
t('dossiq', 'Or take the choices from a concept scheme')
164+
}}</label>
165+
<input
166+
:id="id('concept-scheme')"
167+
:value="value.conceptScheme || ''"
168+
type="text"
169+
class="pd-fields__input"
170+
:placeholder="t('dossiq', 'wijken')"
171+
@input="setConceptScheme($event.target.value)">
172+
<span class="pd-fields__hint">
173+
{{
174+
t(
175+
'dossiq',
176+
'The list lives in OpenRegister and every case type binds to the same one, so a municipal list is kept in one place.',
177+
)
178+
}}
179+
</span>
180+
<span
181+
v-if="schemeWarning"
182+
class="pd-fields__hint pd-fields__hint--warning"
183+
data-testid="concept-scheme-warning">
184+
{{ schemeWarning }}
185+
</span>
186+
</div>
161187
</div>
162188

163189
<div class="pd-fields__row pd-fields__row--stacked">
@@ -238,6 +264,7 @@
238264

239265
<script>
240266
import { NcCheckboxRadioSwitch, NcTextField } from '@nextcloud/vue'
267+
import { hasCompetingSources } from '../services/conceptScheme.js'
241268
import {
242269
constraintsForType,
243270
formatsForType,
@@ -375,6 +402,26 @@ export default {
375402
return (this.value.enumValues || []).join('\n')
376403
},
377404
405+
/**
406+
* What to say when a field names a scheme and a list of its own.
407+
*
408+
* The scheme rules, so the typed list is what a handler will not see.
409+
* Saying so is the whole point: resolving it in silence is how an
410+
* administrator ships a field whose options are not the ones on screen.
411+
*
412+
* @return {string} The warning, or an empty string when there is none.
413+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
414+
*/
415+
schemeWarning() {
416+
if (!hasCompetingSources(this.value)) {
417+
return ''
418+
}
419+
return t(
420+
'dossiq',
421+
'This field names a concept scheme and carries its own list. The scheme wins and the typed choices are ignored.',
422+
)
423+
},
424+
378425
/** @spec openspec/changes/casetype-field-vocabulary/specs/property-definition-management/spec.md */
379426
calculationText() {
380427
if (!this.value.calculation) {
@@ -516,6 +563,14 @@ export default {
516563
})
517564
},
518565
566+
/**
567+
* @param {string} text The scheme reference, or an empty string to unbind.
568+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
569+
*/
570+
setConceptScheme(text) {
571+
this.set('conceptScheme', String(text).trim())
572+
},
573+
519574
/**
520575
* @param {string} text The typed choices, one per line.
521576
* @spec openspec/changes/casetype-field-vocabulary/specs/property-definition-management/spec.md
@@ -585,6 +640,7 @@ export default {
585640
}
586641
587642
.pd-fields__select,
643+
.pd-fields__input,
588644
.pd-fields__textarea {
589645
width: 100%;
590646
padding: 8px;
@@ -601,6 +657,10 @@ export default {
601657
color: var(--color-text-maxcontrast);
602658
}
603659
660+
.pd-fields__hint--warning {
661+
color: var(--color-warning-text, var(--color-error));
662+
}
663+
604664
.pd-fields__error {
605665
display: block;
606666
font-size: 12px;

‎src/services/conceptScheme.js‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Conduction / Dossiq Contributors
3+
* SPDX-License-Identifier: EUPL-1.2
4+
*
5+
* Where a field's choices come from, when two places could answer.
6+
*
7+
* A choice list used to be typed into the attribute that uses it. Two case
8+
* types that both ask for a reden each carried their own copy, and a
9+
* municipality-wide list of wijken or afhandelkanalen was retyped per type and
10+
* drifted. OpenRegister holds those lists as SKOS concept schemes, so a
11+
* property can point at one instead of carrying a copy.
12+
*
13+
* That leaves two sources on one definition, and the rule between them has to
14+
* live in one place or the form and the index will each guess. This module is
15+
* that place: the scheme rules when it is set, the inline list rules when it
16+
* is not, and a definition carrying both is an authoring mistake that is said
17+
* out loud rather than resolved in silence.
18+
*
19+
* dossiq declares the binding and does not render the picker. The picker is
20+
* the platform's, and the case form reads the binding once OpenRegister
21+
* publishes `x-openregister-concept-scheme` in its property vocabulary. Until
22+
* then the binding is stored and shown, and no value is coerced.
23+
*
24+
* @see openspec/changes/code-lists-from-concepts/design.md
25+
*/
26+
27+
/**
28+
* The scheme is the source of options.
29+
*
30+
* @type {string}
31+
*/
32+
export const SOURCE_SCHEME = 'scheme'
33+
34+
/**
35+
* The inline `enumValues` list is the source of options.
36+
*
37+
* @type {string}
38+
*/
39+
export const SOURCE_INLINE = 'inline'
40+
41+
/**
42+
* The field takes any answer its type allows.
43+
*
44+
* @type {string}
45+
*/
46+
export const SOURCE_NONE = 'none'
47+
48+
/**
49+
* The scheme a definition is bound to, trimmed, or an empty string.
50+
*
51+
* A reference typed with a stray space is the same reference. An answer that
52+
* is not a string at all is no binding: it is read as absent rather than
53+
* stringified, because `[object Object]` is not a scheme anyone can resolve.
54+
*
55+
* @param {object} definition The property definition.
56+
* @return {string} The scheme reference, or an empty string.
57+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
58+
*/
59+
export function schemeOf(definition) {
60+
const scheme = definition?.conceptScheme
61+
if (typeof scheme !== 'string') {
62+
return ''
63+
}
64+
return scheme.trim()
65+
}
66+
67+
/**
68+
* The inline choices a definition carries.
69+
*
70+
* @param {object} definition The property definition.
71+
* @return {string[]} The values, empty when there are none.
72+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
73+
*/
74+
export function inlineValuesOf(definition) {
75+
const values = definition?.enumValues
76+
if (!Array.isArray(values)) {
77+
return []
78+
}
79+
return values.filter((value) => typeof value === 'string' && value.trim())
80+
}
81+
82+
/**
83+
* Whether both sources are set on one definition.
84+
*
85+
* @param {object} definition The property definition.
86+
* @return {boolean} True when a scheme and an inline list are both present.
87+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
88+
*/
89+
export function hasCompetingSources(definition) {
90+
return schemeOf(definition) !== '' && inlineValuesOf(definition).length > 0
91+
}
92+
93+
/**
94+
* Which source rules for one definition, and what it offers.
95+
*
96+
* One answer, read by the authoring form and the index alike, so the warning
97+
* and the rendering cannot disagree about which list a handler will see.
98+
*
99+
* @param {object} definition The property definition.
100+
* @return {{source: string, scheme: string, values: string[], competing: boolean}}
101+
* The ruling source, the scheme it names, the inline values it would have
102+
* used, and whether the definition carries both.
103+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
104+
*/
105+
export function optionSourceFor(definition) {
106+
const scheme = schemeOf(definition)
107+
const values = inlineValuesOf(definition)
108+
const competing = scheme !== '' && values.length > 0
109+
if (scheme !== '') {
110+
return { source: SOURCE_SCHEME, scheme, values, competing }
111+
}
112+
if (values.length > 0) {
113+
return { source: SOURCE_INLINE, scheme: '', values, competing }
114+
}
115+
return { source: SOURCE_NONE, scheme: '', values: [], competing }
116+
}

‎src/services/propertyVocabulary.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ export const PENDING_PLATFORM_KEYS = {
7373
owner: 'openregister',
7474
reason: 'Asked for by integriq registry-backed-field-source, not published yet.',
7575
},
76+
conceptScheme: {
77+
key: 'x-openregister-concept-scheme',
78+
owner: 'openregister',
79+
reason: 'The SKOS scheme binding code-lists-from-concepts declares, owned by openregister skos-concept-registers, not published yet.',
80+
},
7681
}
7782

7883
/**

‎src/views/settings/tabs/PropertiesTab.vue‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
<span v-if="pd.maxLength" class="property-row__max">
3030
{{ t('dossiq', 'max {n}', { n: pd.maxLength }) }}
3131
</span>
32+
<span
33+
v-if="schemeSummary(pd)"
34+
class="property-row__scheme"
35+
:title="schemeTitle(pd)"
36+
data-testid="property-scheme">
37+
{{ schemeSummary(pd) }}
38+
</span>
3239
<span class="property-row__required">
3340
{{ requiredLabel(pd) }}
3441
</span>
@@ -129,6 +136,10 @@ import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
129136
import DeleteIcon from 'vue-material-design-icons/Delete.vue'
130137
import PencilIcon from 'vue-material-design-icons/Pencil.vue'
131138
import PropertyDefinitionFields from '../../../components/PropertyDefinitionFields.vue'
139+
import {
140+
hasCompetingSources,
141+
schemeOf,
142+
} from '../../../services/conceptScheme.js'
132143
import {
133144
fetchPropertyVocabulary,
134145
resolveStoredType,
@@ -156,6 +167,7 @@ function blankForm() {
156167
ref: '',
157168
calculation: null,
158169
propertySource: '',
170+
conceptScheme: '',
159171
enumValues: [],
160172
isRequired: false,
161173
requiredAtStatus: null,
@@ -285,6 +297,44 @@ export default {
285297
return pd.format ? `${type} · ${pd.format}` : type
286298
},
287299
300+
/**
301+
* Which scheme this field's choices come from, for the index.
302+
*
303+
* Empty when the field is not bound to one, so an inline list and a
304+
* free-text field read the same as they always did.
305+
*
306+
* @param {object} pd The property definition.
307+
* @return {string} The scheme reference, or an empty string.
308+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
309+
*/
310+
schemeSummary(pd) {
311+
const scheme = schemeOf(pd)
312+
if (!scheme) {
313+
return ''
314+
}
315+
return t('dossiq', 'scheme {scheme}', { scheme })
316+
},
317+
318+
/**
319+
* What the scheme label says when a reader hovers it.
320+
*
321+
* A field carrying both sources says so here too, so the warning is
322+
* not only on the form the author has since closed.
323+
*
324+
* @param {object} pd The property definition.
325+
* @return {string} The hover text.
326+
* @spec openspec/changes/code-lists-from-concepts/specs/property-definition-management/spec.md
327+
*/
328+
schemeTitle(pd) {
329+
if (hasCompetingSources(pd)) {
330+
return t(
331+
'dossiq',
332+
'The choices come from this concept scheme. The list typed on this field is ignored.',
333+
)
334+
}
335+
return t('dossiq', 'The choices come from this concept scheme.')
336+
},
337+
288338
/**
289339
* Why a save is refused, or an empty string when it is not.
290340
*
@@ -507,6 +557,11 @@ export default {
507557
color: var(--color-text-maxcontrast);
508558
}
509559
560+
.property-row__scheme {
561+
font-size: 12px;
562+
color: var(--color-text-maxcontrast);
563+
}
564+
510565
.property-row__required {
511566
font-size: 12px;
512567
color: var(--color-text-maxcontrast);

0 commit comments

Comments
 (0)