humainflow-web/src/app/shared/node-settings-dialog/node-settings-dialog.html

207 lines
9.1 KiB
HTML

@if (state()) {
<div class="fixed inset-0 z-(--z-dialog-over-modal)">
<div class="absolute inset-0 bg-black/50" (click)="cancel($event)"></div>
<div class="absolute left-1/2 top-1/2 flex max-h-[min(80vh,720px)] w-[min(92vw,560px)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-2xl border border-(--color-border) bg-white p-6 shadow-(--shadow-modal)">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900">{{ state()!.title }}</h3>
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
</div>
<div class="grid gap-4 overflow-auto pr-1">
@for (field of ungroupedFields(); track field.key) {
<ng-container *ngTemplateOutlet="fieldControl; context: { $implicit: field }"></ng-container>
}
@for (group of fieldGroups(); track group.name) {
<section class="rounded-md border border-slate-200">
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-slate-600 hover:text-slate-900"
[attr.aria-expanded]="isGroupOpen(group.name)"
(click)="toggleGroup(group.name, $event)">
<mat-icon [fontIcon]="isGroupOpen(group.name) ? 'expand_less' : 'expand_more'" class="!h-4 !w-4 !text-base !leading-4"></mat-icon>
<span>{{ group.name }}</span>
@if (groupErrorCount(group.fields) > 0) {
<span class="ml-auto rounded-full bg-red-50 px-2 text-[10px] font-bold text-red-700">
{{ groupErrorCount(group.fields) }} to fix
</span>
} @else if (!isGroupOpen(group.name) && groupSetCount(group.fields) > 0) {
<span class="ml-auto rounded-full bg-indigo-50 px-2 text-[10px] font-bold text-indigo-700">
{{ groupSetCount(group.fields) }} set
</span>
}
</button>
@if (isGroupOpen(group.name)) {
<div class="grid gap-4 border-t border-slate-200 p-3">
@for (field of group.fields; track field.key) {
<ng-container *ngTemplateOutlet="fieldControl; context: { $implicit: field }"></ng-container>
}
</div>
}
</section>
}
</div>
<div class="mt-auto flex justify-end gap-2">
@if (isPreviewOnly()) {
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
} @else {
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
<button type="button" mat-flat-button [disabled]="hasInvalidField()" (click)="save($event)">Save</button>
}
</div>
</div>
</div>
}
<!-- One field control, so the plain list and the collapsible sections cannot drift apart. -->
<ng-template #fieldControl let-field>
<div class="text-sm font-medium text-slate-700">
<!--
The control and its reset on one row: the button keeps its place and greys out rather than
appearing when a value is typed, so the field never changes height and the option is
visible before there is anything to reset.
-->
<div class="flex items-start gap-2">
<div class="min-w-0 grow">
@switch (field.type) {
@case ('display') {
<fieldset class="mt-1 border border-slate-200 rounded-md bg-slate-50 p-3">
<div class="flex items-start justify-between gap-2">
<legend class="px-1 text-xs font-semibold text-slate-600">{{ field.label }}</legend>
@if (field.copyable) {
<button
type="button"
mat-stroked-button
class="!min-w-0 !px-2 !py-0 !text-[11px]"
matTooltip="Copy"
aria-label="Copy value"
(click)="copyFieldValue(field.key, $event)">
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
}
</div>
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words">{{ draft[field.key] }}</div>
</fieldset>
}
@case ('textarea') {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<textarea
matInput
[rows]="field.rows ?? 8"
[placeholder]="field.placeholder ?? ''"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
</textarea>
</mat-form-field>
}
@case ('select') {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<mat-select
[disabled]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
@for (option of field.options ?? []; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
@case ('checkbox') {
<div class="mt-2">
<mat-checkbox
[disabled]="field.readonly === true"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)">
{{ field.label }}
</mat-checkbox>
</div>
}
@case ('number') {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<input
matInput
type="number"
[placeholder]="field.placeholder ?? ''"
[attr.min]="field.min ?? null"
[attr.max]="field.max ?? null"
[attr.step]="field.stepIncrement ?? field.step ?? null"
[readonly]="field.readonly === true"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
</mat-form-field>
}
@default {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<input
matInput
[type]="field.type === 'password' && !isPasswordVisible(field.key) ? 'password' : 'text'"
[placeholder]="field.placeholder ?? ''"
[attr.maxlength]="field.maxLength ?? null"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
@if (field.type === 'password') {
<button
type="button"
mat-icon-button
matSuffix
[attr.aria-label]="isPasswordVisible(field.key) ? 'Hide password' : 'Show password'"
(click)="togglePasswordVisibility(field.key, $event)">
<mat-icon [fontIcon]="isPasswordVisible(field.key) ? 'visibility_off' : 'visibility'"></mat-icon>
</button>
}
</mat-form-field>
}
}
</div>
@if (showUseDefault(field)) {
<button
type="button"
mat-stroked-button
class="mt-2 shrink-0 !min-h-0 !min-w-0 !px-2 !py-0.5 !text-[11px] !leading-4"
[disabled]="!canUseDefault(field)"
[matTooltip]="usesDefault(field) ? 'Already using the default' : 'Clear this field so the default applies'"
(click)="useDefault(field, $event)">
Use default
</button>
}
</div>
<!--
What is under the control, ranked: what is wrong, then what the field is doing, then what
it is for. Outside the switch so every control type reports the same way, select included.
The tip used to be rendered twice - once inside the form field as a mat-hint, once here in
error red - so an ordinary description read as a failure and overlapped the button.
-->
@if (hasFieldMeta(field)) {
<div class="mt-1.5 text-[11px] leading-4">
@if (fieldError(field); as error) {
<span class="font-semibold text-red-700">{{ error }}</span>
} @else if (defaultHint(field); as hint) {
<span class="font-normal text-slate-500">{{ hint }}</span>
}
</div>
}
@if (field.tip) {
<p class="mt-1 text-[11px] font-normal leading-4 text-slate-500">{{ field.tip }}</p>
}
</div>
</ng-template>