Skip to content

ADR: Sphere Picker in Business Activity-Form

ADR: Sphere Picker in Business Activity-Form

Section titled “ADR: Sphere Picker in Business Activity-Form”

PROPOSED

ACPrimary decisionsNotes
AC-1D1 (tui-select widget choice), D2 (spheres fetch on init)Picker rendered above activity-type dropdown.
AC-2D3 (default selection = SPORT)Preserves existing UX; admins create gym activities most often.
AC-3D4 (edit-mode disabled via editableFields)Remove 'sphereId' from editableFields list (activity.form.ts:59-77). Existing pattern.
AC-4D5 (reactive type filtering)Effect/computed signal listens to sphere changes, narrows type options.
AC-5D6 (real UUID submit)No sentinel value; control is bound to real sphereId.
AC-6D7 (dead-code deletion)Sentinel + signal + resolveDefaultSphereId + canSubmit guard all removed.
AC-7D8 (signal-based local cache)One fetch on init, cached in component signal — no re-fetch on interaction.
AC-8D9 (unit + integration test plan)5 unit tests + 1 integration smoke.

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.

// Component
public 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.

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:259 explicitly 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).

Decision: add a computed (or effect) that derives the visible activity-type options from the currently-selected sphere’s allowedActivityTypes. When the sphere changes:

  1. The visible type options narrow to the new sphere’s allowed list.
  2. If the current activityType control value is not in the new allowed list, the control is setValue(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.

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.required on sphereId) gates submit naturally — no special signal needed.

Decision: remove all of:

  • ACTIVITY_FORM_DEFAULT_SPHERE_ID constant (and its export, if any).
  • sphereResolutionState signal.
  • resolveDefaultSphereId() method.
  • The canSubmit check on sphereResolutionState.
  • The safety-net sphereId === ACTIVITY_FORM_DEFAULT_SPHERE_ID guard 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 the defaults.sphereId value at activity.form.ts:124 — replace the default with '' (control stays required).
  • 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), canSubmit sphere 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) — references sphereResolutionState() and canSubmit() sphere gate that D7 deletes; rewrite or remove entirely (form validity gates submit instead).
  • 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.

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 CreateActivityDtoWithSphere TODO 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.

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).


  • Translation completeness (corrected in v2): sphere name is 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.


  • 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/spheres but 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).

  1. Read activity-form.page.ts post-edit — confirm all deletion targets from D7 are gone.
  2. npm run start:dev, open activity-form, verify picker renders with 5 spheres, default SPORT.
  3. Change sphere to CINEMA — verify activity-type dropdown updates to ['MOVIE'].
  4. Create a MOVIE activity under CINEMA, verify POST returns 201 + activity appears in list with correct sphere.
  5. Open existing SPORT activity in edit mode — verify picker shows SPORT, disabled.
  6. Karma unit suite passes for activity-form.page.spec.ts.

STATUS: READY_FOR_REVIEW