From 359c7a89bf06d61c0d0c2b5737c8fcb490465e5b Mon Sep 17 00:00:00 2001 From: Alex Inkin Date: Wed, 14 Jan 2026 21:19:46 +0400 Subject: [PATCH] feat: add empty array placeholder in forms (#3095) --- .../shared/src/i18n/dictionaries/de.ts | 1 + .../shared/src/i18n/dictionaries/en.ts | 1 + .../shared/src/i18n/dictionaries/es.ts | 1 + .../shared/src/i18n/dictionaries/fr.ts | 1 + .../shared/src/i18n/dictionaries/pl.ts | 1 + .../portal/components/form.component.ts | 2 +- .../form/containers/array.component.ts | 17 +++++++++-- .../form/controls/multiselect.component.ts | 28 ++++++++----------- 8 files changed, 33 insertions(+), 19 deletions(-) diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index d33df23a4..c467df91d 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -596,4 +596,5 @@ export default { 626: 'Hochladen', 627: 'UI öffnen', 628: 'In Zwischenablage kopiert', + 629: 'Die Liste ist leer', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index fd3abdeae..0fe5fa9bb 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -595,4 +595,5 @@ export const ENGLISH = { 'Upload': 626, // as in, upload a file 'Open UI': 627, // as in, upload a file 'Copied to clipboard': 628, + 'The list is empty': 629, } as Record diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index e40a68f18..b1022ff62 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -596,4 +596,5 @@ export default { 626: 'Subir', 627: 'Abrir UI', 628: 'Copiado al portapapeles', + 629: 'La lista está vacía', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index bad123e1b..aa3cce393 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -596,4 +596,5 @@ export default { 626: 'Téléverser', 627: 'Ouvrir UI', 628: 'Copié dans le presse-papiers', + 629: 'La liste est vide', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 8683b9caa..e39fdb756 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -596,4 +596,5 @@ export default { 626: 'Prześlij', 627: 'Otwórz UI', 628: 'Skopiowano do schowka', + 629: 'Lista jest pusta', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/portal/components/form.component.ts b/web/projects/ui/src/app/routes/portal/components/form.component.ts index eec7e7f2f..3b300e3c2 100644 --- a/web/projects/ui/src/app/routes/portal/components/form.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form.component.ts @@ -80,7 +80,7 @@ export interface FormContext { margin: 1rem -1px -1rem; gap: 1rem; background: var(--tui-background-elevation-1); - border-top: 1px solid var(--tui-background-base-alt); + box-shadow: inset 0 1px var(--tui-background-neutral-1); } `, imports: [ diff --git a/web/projects/ui/src/app/routes/portal/components/form/containers/array.component.ts b/web/projects/ui/src/app/routes/portal/components/form/containers/array.component.ts index 8b7a028b1..50a46f7dc 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/containers/array.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/containers/array.component.ts @@ -87,6 +87,8 @@ import { FormObjectComponent } from './object.component' } + } @empty { +
{{ 'The list is empty' | i18n }}
} `, styles: ` @@ -99,8 +101,8 @@ import { FormObjectComponent } from './object.component' .label { display: flex; - font-size: 1.25rem; - font-weight: bold; + align-items: center; + font: var(--tui-font-heading-6); } .add { @@ -157,6 +159,17 @@ import { FormObjectComponent } from './object.component' animation-name: tuiFade, tuiCollapse; } } + + .placeholder { + display: flex; + align-items: center; + justify-content: center; + height: var(--tui-height-m); + color: var(--tui-text-tertiary); + border-radius: var(--tui-radius-m); + border: 1px dashed var(--tui-background-neutral-1); + margin: 0.5rem 0 0; + } `, hostDirectives: [ControlDirective], imports: [ diff --git a/web/projects/ui/src/app/routes/portal/components/form/controls/multiselect.component.ts b/web/projects/ui/src/app/routes/portal/components/form/controls/multiselect.component.ts index 91995576f..731bbe594 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/controls/multiselect.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/controls/multiselect.component.ts @@ -1,12 +1,11 @@ -import { Component } from '@angular/core' +import { Component, computed } from '@angular/core' import { FormsModule } from '@angular/forms' import { invert } from '@start9labs/shared' import { IST } from '@start9labs/start-sdk' -import { tuiPure } from '@taiga-ui/cdk' import { TuiIcon, TuiTextfield } from '@taiga-ui/core' import { TuiChevron, TuiMultiSelect, TuiTooltip } from '@taiga-ui/kit' -import { Control } from './control' import { HintPipe } from '../pipes/hint.pipe' +import { Control } from './control' @Component({ selector: 'form-multiselect', @@ -21,7 +20,8 @@ import { HintPipe } from '../pipes/hint.pipe' [disabled]="disabled" [readOnly]="readOnly" [items]="items" - [(ngModel)]="selected" + [ngModel]="selected()" + (ngModelChange)="onSelected($event)" (blur)="control.onTouched()" > @if (spec | hint; as hint) { @@ -58,30 +58,26 @@ export class FormMultiselectComponent extends Control< private readonly isExceedingLimit = (item: string) => !!this.spec.maxLength && - this.selected.length >= this.spec.maxLength && - !this.selected.includes(item) + this.selected().length >= this.spec.maxLength && + !this.selected().includes(item) readonly disabledItemHandler = (item: string): boolean => this.isDisabled(item) || this.isExceedingLimit(item) readonly items = Object.values(this.spec.values) + readonly selected = computed( + () => + this.control.value().map((key: string) => this.spec.values[key] || '') || + [], + ) get disabled(): boolean { return typeof this.spec.disabled === 'string' } - get selected(): string[] { - return this.memoize(this.value) - } - - set selected(value: string[]) { + onSelected(value: string[]) { this.value = Object.entries(this.spec.values) .filter(([_, v]) => value.includes(v)) .map(([k]) => k) } - - @tuiPure - private memoize(value: null | readonly string[]): string[] { - return value?.map(key => this.spec.values[key] || '') || [] - } }