249 lines
8.8 KiB
TypeScript
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;
|
|
}
|
|
}
|