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); private refreshVersion = 0; state = this.dialog.state; draft: NodeSettingsValues = {}; fields: NodeSettingField[] = []; passwordVisibility: Record = {}; /** * 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>(new Set()); 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()); 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(); 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; } }