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

249 lines
8.8 KiB
TypeScript

import { ChangeDetectionStrategy, Component, effect, ElementRef, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
NodeSettingField,
NodeSettingsDialogService,
NodeSettingsValues,
validateFieldValue
} from '@services/dialogs/node-settings-dialog';
@Component({
selector: 'app-node-settings-dialog-host',
standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTooltipModule],
templateUrl: './node-settings-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NodeSettingsDialogHostComponent {
private dialog = inject(NodeSettingsDialogService);
private host = inject(ElementRef<HTMLElement>);
private refreshVersion = 0;
state = this.dialog.state;
draft: NodeSettingsValues = {};
fields: NodeSettingField[] = [];
passwordVisibility: Record<string, boolean> = {};
/**
* Which collapsible sections the user has opened. Every one starts closed.
*
* A signal, not a plain Set: this component is OnPush, so a mutated Set only re-renders when the
* change happens to arrive through a template event. That held here by luck and would stop
* holding the moment anything toggled a section from code.
*/
private readonly openGroups = signal<ReadonlySet<string>>(new Set<string>());
constructor() {
effect(() => {
const state = this.state();
if (!state) return;
this.refreshVersion += 1;
this.fields = state.fields;
this.draft = this.buildDraft(state.fields, state.initial);
this.passwordVisibility = {};
this.openGroups.set(new Set<string>());
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
});
});
}
cancel(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close(null);
}
save(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.hasInvalidField()) return;
this.dialog.close({ ...this.draft });
}
/**
* Why this field cannot be saved, or null. A readonly field is never reported: its value is not
* the user's to fix, and a dialog that cannot be saved or corrected is a dead end.
*/
fieldError(field: NodeSettingField): string | null {
if (field.readonly === true) return null;
return validateFieldValue(field, this.draft[field.key]);
}
hasInvalidField(): boolean {
return this.fields.some((field) => this.fieldError(field) !== null);
}
/** Whether anything needs saying under the control, so the row is never an empty gap. */
hasFieldMeta(field: NodeSettingField): boolean {
return this.fieldError(field) !== null || this.defaultHint(field) !== null;
}
/**
* Whether the field has a default to go back to at all. Kept apart from {@link canUseDefault},
* which says whether pressing it would do anything: the control stays in place and greys out
* instead of appearing and disappearing, so the field's height never changes as you type and
* the option is discoverable before you have typed anything.
*/
showUseDefault(field: NodeSettingField): boolean {
return field.defaultsWhenEmpty === true && field.readonly !== true;
}
/** Whether an optional field is currently unset, so its default applies. */
usesDefault(field: NodeSettingField): boolean {
if (field.defaultsWhenEmpty !== true) return false;
const value = this.draft[field.key];
return value === undefined || value === null || value === '';
}
/** What an empty optional field is doing, so it does not read as unfinished. */
defaultHint(field: NodeSettingField): string | null {
if (!this.usesDefault(field)) return null;
return field.defaultValue ? `Using the default: ${field.defaultValue}` : 'Using the default';
}
/**
* Offered only on a field that holds a value. Going back to unset is the one thing a filled box
* cannot express on its own: clearing it by hand looks the same as never having decided.
*/
canUseDefault(field: NodeSettingField): boolean {
return this.showUseDefault(field) && !this.usesDefault(field);
}
useDefault(field: NodeSettingField, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.setFieldValue(field.key, '');
}
/** So a bad value inside a closed section is not invisible while Save refuses to work. */
groupErrorCount(fields: NodeSettingField[]): number {
return fields.filter((field) => this.fieldError(field) !== null).length;
}
isPreviewOnly(): boolean {
return this.state()?.previewOnly === true;
}
setFieldValue(key: string, value: string | boolean | number) {
this.draft[key] = value;
void this.refreshFieldsFromDraft();
}
async copyFieldValue(key: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draft[key];
const text = typeof value === 'string' ? value : String(value ?? '');
try {
await navigator.clipboard.writeText(text);
} catch {
// Ignore clipboard errors in unsupported/denied contexts.
}
}
isPasswordVisible(key: string): boolean {
return this.passwordVisibility[key] === true;
}
togglePasswordVisibility(key: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.passwordVisibility = {
...this.passwordVisibility,
[key]: !this.isPasswordVisible(key)
};
}
/** The fields shown directly, in their given order. */
ungroupedFields(): NodeSettingField[] {
return this.fields.filter((field) => !field.group);
}
/** The collapsible sections, in the order their first field appears. */
fieldGroups(): Array<{ name: string; fields: NodeSettingField[] }> {
const groups = new Map<string, NodeSettingField[]>();
for (const field of this.fields) {
if (!field.group) continue;
if (!groups.has(field.group)) groups.set(field.group, []);
groups.get(field.group)!.push(field);
}
return [...groups.entries()].map(([name, fields]) => ({ name, fields }));
}
isGroupOpen(name: string): boolean {
return this.openGroups().has(name);
}
toggleGroup(name: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.openGroups.update((current) => {
const next = new Set(current);
if (!next.delete(name)) next.add(name);
return next;
});
}
/**
* How many fields in a closed section carry a value, so a section that is doing something never
* looks the same as one that is not.
*/
groupSetCount(fields: NodeSettingField[]): number {
return fields.filter((field) => {
const value = this.draft[field.key];
return value !== undefined && value !== null && value !== '';
}).length;
}
private buildDraft(fields: NodeSettingField[], initial: NodeSettingsValues): NodeSettingsValues {
const values: NodeSettingsValues = {};
for (const field of fields) {
const initialValue = initial[field.key];
if (initialValue !== undefined) {
values[field.key] = initialValue;
continue;
}
if (field.type === 'checkbox') {
values[field.key] = false;
} else {
values[field.key] = '';
}
}
return values;
}
private async refreshFieldsFromDraft() {
const state = this.state();
if (!state?.onValuesChange) return;
const requestVersion = ++this.refreshVersion;
const currentDraft = { ...this.draft };
const next = await state.onValuesChange(currentDraft);
if (!next || requestVersion !== this.refreshVersion) return;
this.fields = next.fields;
const rebuiltDraft = this.buildDraft(next.fields, next.initial ?? {});
for (const field of next.fields) {
if (!Object.prototype.hasOwnProperty.call(currentDraft, field.key)) continue;
const currentValue = currentDraft[field.key];
if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) {
const currentStringValue = typeof currentValue === 'string' ? currentValue : String(currentValue ?? '');
const optionStillAvailable = field.options.some((option) => option.value === currentStringValue);
if (!optionStillAvailable) continue;
}
rebuiltDraft[field.key] = currentValue;
}
this.draft = rebuiltDraft;
}
}