ADR: Sphere Picker in Business Activity-Form
ADR: Sphere Picker in Business Activity-Form
Section titled “ADR: Sphere Picker in Business Activity-Form”Status
Section titled “Status”PROPOSED
AC ↔ D# mapping
Section titled “AC ↔ D# mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1 (tui-select widget choice), D2 (spheres fetch on init) | Picker rendered above activity-type dropdown. |
| AC-2 | D3 (default selection = SPORT) | Preserves existing UX; admins create gym activities most often. |
| AC-3 | D4 (edit-mode disabled via editableFields) | Remove 'sphereId' from editableFields list (activity.form.ts:59-77). Existing pattern. |
| AC-4 | D5 (reactive type filtering) | Effect/computed signal listens to sphere changes, narrows type options. |
| AC-5 | D6 (real UUID submit) | No sentinel value; control is bound to real sphereId. |
| AC-6 | D7 (dead-code deletion) | Sentinel + signal + resolveDefaultSphereId + canSubmit guard all removed. |
| AC-7 | D8 (signal-based local cache) | One fetch on init, cached in component signal — no re-fetch on interaction. |
| AC-8 | D9 (unit + integration test plan) | 5 unit tests + 1 integration smoke. |
Decisions
Section titled “Decisions”D1 — Widget: Taiga UI tui-select
Section titled “D1 — Widget: Taiga UI tui-select”Decision: use tui-select for the sphere picker, consistent with the activity-form’s existing tui-select usages (activity-type, category, status).
Rationale:
- Visual consistency with the rest of the form (no new widget vocabulary for one field).
- 5 spheres is a comfortable dropdown size.
- Compact — keeps the form short.
Rejected alternatives: chip-row (better suited for browse pages, not forms), radio (too much vertical space).
Binding pattern (v3 — corrected against business app idiom):
Bind ID strings as items, NOT DTO objects. Use a TuiStringHandler<string> to map id → localized label. Precedent: create-time-slot-rule.modal.ts:88,143 (and activity-contributor-form.modal.ts:75, create-session.modal.ts:98). No identityMatcher exists anywhere in client/src — do not introduce it.
// Componentpublic readonly availableSpheres = signal<SphereAdminDto[]>([]);public readonly sphereIds = computed(() => this.availableSpheres().map((s) => s.id));
public readonly stringifySphere: TuiStringHandler<string> = (id) => { const sphere = this.availableSpheres().find((s) => s.id === id); if (!sphere) return ''; const lang = this.languageService.current(); // default 'en' per LanguageService return sphere.name[lang] ?? sphere.name.en; // SphereI18NName.en is required};<!-- Pattern matches activity-form.page.html:60-63 and create-time-slot-rule.modal.html:89-91 --><tui-textfield tuiChevron [stringify]="stringifySphere"> <input tuiSelect formControlName="sphereId" /> <tui-data-list-wrapper *tuiDropdown new [items]="sphereIds()" /></tui-textfield>Note: tuiSelect in Taiga UI v5 is an input directive, not a component. [stringify] lives on the wrapping tui-textfield, [items] on the tui-data-list-wrapper. Do not use <tui-select [items] [stringify]> — that element does not exist in v5.
Why ID strings, not DTO objects: the sphereId form control already holds a UUID string (current schema). Binding ID strings keeps that contract; stringify does the cosmetic resolution. Object binding would require identityMatcher + downstream serialization gymnastics that the codebase does not use anywhere.
Locale source: LanguageService.current() (core/services/language.service.ts:20). Default is 'en', NOT 'uk'. Spec AC-1’s “UK per Taiga config” claim was incorrect — fix in the implementing MR’s spec amendment if surfaced, otherwise treat as a documentation NIT.
D2 — Spheres fetch on form init
Section titled “D2 — Spheres fetch on form init”Decision: fetch spheres once via Api.invoke(spheresAdminList) in the form’s ngOnInit (or effect if signal-based init). Result stored in a component signal availableSpheres. Picker [items] is bound to that signal.
Rationale:
- The list is small (5 items, growing rarely).
- Form is short-lived — re-fetching on every form-open is acceptable cost.
- No need for a global sphere cache service for one form.
D3 — Default selection = SPORT in create mode
Section titled “D3 — Default selection = SPORT in create mode”Decision: after the spheres signal resolves, look up the sphere with code === 'SPORT' and setValue it on the form’s sphereId control. Mirror the previous behaviour.
Rationale: the most common case is creating a gym (sport) activity. Defaulting to SPORT preserves the current admin’s muscle memory. Admins working on CINEMA / DINING simply change the picker once per activity.
D4 — Edit mode: picker disabled (via editableFields removal)
Section titled “D4 — Edit mode: picker disabled (via editableFields removal)”Decision (v3 corrected): remove 'sphereId' from the editableFields list in forms/activity.form.ts:59-77. createFormGroupFromDto (core/utils/create-form-group-from-dto.ts:37,47) constructs each control with {disabled: !editable} — that’s the established immutability idiom in this codebase. Do NOT use [tuiTextfieldDisabled] (zero hits in client/src) or formControl.disable() (zero hits).
Serialization note: toUpdateDto() (activity.form.ts:170) uses getRawValue(), so the disabled sphereId is still serialized in PATCH payloads. The backend’s update handler ignores sphereId mutations (per activities.service.ts:259), so this is harmless.
Rationale:
activities.service.ts:259explicitly comments “sphere is immutable on activities for now” — the backend will not honor sphereId changes on PATCH.- Showing a disabled picker is more informative than hiding the field entirely (admins can still see which sphere the activity belongs to).
- Tooltip explanation deferred (out of scope unless user feedback demands it).
D5 — Reactive activity-type filtering
Section titled “D5 — Reactive activity-type filtering”Decision: add a computed (or effect) that derives the visible activity-type options from the currently-selected sphere’s allowedActivityTypes. When the sphere changes:
- The visible type options narrow to the new sphere’s allowed list.
- If the current
activityTypecontrol value is not in the new allowed list, the control issetValue(null).
v3+v4 — initial type default in create mode: activity.form.ts:109 currently defaults type: 'SHOW'. SPORT (default sphere in create) does NOT allow SHOW, so the reactive effect would immediately clear type on form open — bad UX flash. Change the value from 'SHOW' to null — the type KEY must stay in the defaults object because createFormGroupFromDto iterates defaults to build controls; deleting the key would make formControlName="type" throw at runtime. Use a cast: type: null as unknown as CreateActivityDto['type'] (the inline union from create-activity-dto.ts:38). ActivityType does NOT exist as an exported type in @core/api/models; ActivityTypeCode (activity-type-code.ts:7) is an alternative cast target if preferred. The cast is needed because Partial<CreateActivityDtoWithSphere> does not accept null for the enum field. Validators.required gates submit. Rejected: (a) async pre-fill from sphere.defaultActivityType (causes async null→value flicker, complicates testing); (c) suppress auto-clear in create mode (reintroduces the invalid-combo 400 this ticket exists to prevent).
v3 — Q1 confirmed (per spec AC-4): when sphere changes and current type is no longer allowed, setValue(null) — NOT pre-fill from new sphere’s defaultActivityType. Silent pre-fill risks unnoticed wrong type. Pre-fill = follow-up ticket if user feedback requests it.
Rationale:
- Prevents admin from posting an invalid combination (backend would 400, but UX should refuse upfront).
- Auto-clear avoids “selected option is no longer visible but still bound” confusion.
- Dropping the
'SHOW'initial default removes a guaranteed-invalid starting state for the default sphere.
D6 — Submit posts real UUID
Section titled “D6 — Submit posts real UUID”Decision: the form’s sphereId form control is bound directly to the picker. The control holds a real UUID (or empty string if unselected). Submit handler posts the value as-is.
Rationale:
- The sentinel + resolution dance is the workaround being removed.
- Form validity (
Validators.requiredonsphereId) gates submit naturally — no special signal needed.
D7 — Dead-code deletion
Section titled “D7 — Dead-code deletion”Decision: remove all of:
ACTIVITY_FORM_DEFAULT_SPHERE_IDconstant (and its export, if any).sphereResolutionStatesignal.resolveDefaultSphereId()method.- The
canSubmitcheck onsphereResolutionState. - The safety-net
sphereId === ACTIVITY_FORM_DEFAULT_SPHERE_IDguard in the submit handler.
v2 — verified external references (all must be updated/removed together):
- Definition:
features/dashboard/activities/forms/activity.form.ts:34(NOT the page file). Also used as thedefaults.sphereIdvalue atactivity.form.ts:124— replace the default with''(control staysrequired). - Import + usages in the page:
activity-form.page.ts:15(import),:288(safety-net guard). - Page spec:
activity-form.page.spec.ts:35(import),:162,:216,:258— these tests assert the sentinel-resolution behaviour being deleted; rewrite per D9. - Form spec:
forms/activity-form.sphere.spec.ts:24(SPORT_SPHERE_ID_PLACEHOLDER = 'SPORT_DEFAULT'string literal) — update or remove the affected cases. - The page-file deletion targets sit at:
sphereResolutionState(activity-form.page.ts:128-130),canSubmitsphere guard (:132-137),resolveDefaultSphereId()(:191-207), submit safety net (:280-297).
v3+v4 — spec file rewrite scope (expanded after critic iter 2):
activity-form.page.spec.ts— KEEP file, REWRITE:- File-header comment block (
:1-16) — stale sentinel scheme description. - Tests at
:162,:216,:258— per AC-8 (new picker assertions: default SPORT resolution, sphere change → type filter clear, edit mode disabled, submit posts real UUID, MOVIE-under-CINEMA integration). - Test at
:185-192(canSubmit pending/resolved) — referencessphereResolutionState()andcanSubmit()sphere gate that D7 deletes; rewrite or remove entirely (form validity gates submit instead).
- File-header comment block (
activity-form.sphere.spec.ts— KEEP file, partial REWRITE:- Test 1 (
toBeDefined) — still passes with''default; minor adjustment only. - Test 2 (non-empty sentinel) — REWRITE to assert
''default + picker-driven value. - Test 3 (edit preserves sphereId) — survives with minor adjustment to reflect editableFields-based disable.
- Test 1 (
v3+v4 — stale comments to remove together with the workaround code:
activity-form.page.ts:120(block comment about SPORT_DEFAULT resolution).activity-form.page.ts:187(block comment about resolveDefaultSphereId).activity.form.ts:17-33(block comment about sentinel).activity.form.ts:121-123(inline comment near defaults).forms/activity-form.sphere.spec.ts:1-15(file-header comment describing sentinel scheme).forms/activity-form.sphere.spec.ts:20-24(constant + describing comment block).- Any
CreateActivityDtoWithSphereTODO that references the unification — verify and remove if obsolete.
Rationale: dead code post-replacement is technical debt. The whole point of this ticket is to remove the workaround.
D8 — Signal-based local cache (per-form-instance)
Section titled “D8 — Signal-based local cache (per-form-instance)”Decision: availableSpheres = signal<SphereAdminDto[]>([]) populated once on init via Api.invoke(spheresAdminList). Picker reads from the signal; no service-layer caching.
Rationale:
- Activity-form is opened transiently. Caching at service level would require invalidation logic on sphere mutations (super-admin only, very rare).
- One fetch per form-open is cheap enough.
- Keeps the code local to the form, no cross-cutting service.
Alternative considered: introduce a SpheresState service with a shared signal cache. Rejected for MVP — over-engineering for one consumer.
D9 — Test plan (5 unit + 1 integration)
Section titled “D9 — Test plan (5 unit + 1 integration)”Decision: test cases enumerated in the spec’s Test Plan section.
Rationale: unit tests cover the deterministic logic (default, filtering, edit-mode disable, submit payload). Integration smoke proves the full form-to-API path. Cross-product browser testing not needed (Taiga UI behaves consistently across browsers).
Risks (post-mitigation)
Section titled “Risks (post-mitigation)”-
Translation completeness (corrected in v2): sphere
nameis returned as an i18n map (SphereI18NName, 5 locales) — the client selects the locale (see D1 note). The picker UI label “Сфера активності” needs an i18n key in the business app’s translations file. -
Edge case — single sphere matches activity type: if a sphere allows only one activity type (e.g. CINEMA allows only MOVIE), the type dropdown shows only one option. UX wise, this is fine — admin can still verify the selection.
-
Backward compat: existing in-flight activity edits (admin had form open before this deploy) will see the new picker on submit. Since sphere is immutable in edit mode anyway, no behavior change for them.
Out of scope
Section titled “Out of scope”- Sphere CRUD UI (super-admin task, deferred — see P3 #9).
- gym_app sphere filter chip-row (P3 #7 — separate, will use the same
GET /api/client/spheresbut mobile UI patterns differ). - Tooltip explanation of “sphere immutable” in edit mode (separate UX polish ticket if needed).
- Multi-sphere activity support (schema change, out of scope).
Verification
Section titled “Verification”- Read
activity-form.page.tspost-edit — confirm all deletion targets from D7 are gone. npm run start:dev, open activity-form, verify picker renders with 5 spheres, default SPORT.- Change sphere to CINEMA — verify activity-type dropdown updates to
['MOVIE']. - Create a MOVIE activity under CINEMA, verify POST returns 201 + activity appears in list with correct sphere.
- Open existing SPORT activity in edit mode — verify picker shows SPORT, disabled.
- Karma unit suite passes for
activity-form.page.spec.ts.
STATUS: READY_FOR_REVIEW