@myrmidon/cadmus-thesaurus-store 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, model, signal, effect, Component, Optional, Inject, input, output, computed, untracked } from '@angular/core';
|
|
3
|
+
import * as i1 from '@angular/forms';
|
|
4
|
+
import { FormBuilder, ReactiveFormsModule, FormControl, Validators, FormGroup, FormsModule } from '@angular/forms';
|
|
5
|
+
import { AsyncPipe } from '@angular/common';
|
|
6
|
+
import { of } from 'rxjs';
|
|
7
|
+
import * as i1$1 from '@angular/material/dialog';
|
|
8
|
+
import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog';
|
|
9
|
+
import * as i8 from '@angular/material/progress-bar';
|
|
10
|
+
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
11
|
+
import * as i2 from '@angular/material/button';
|
|
12
|
+
import { MatButtonModule, MatIconButton } from '@angular/material/button';
|
|
13
|
+
import * as i3 from '@angular/material/checkbox';
|
|
14
|
+
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
15
|
+
import * as i5 from '@angular/material/icon';
|
|
16
|
+
import { MatIconModule, MatIcon } from '@angular/material/icon';
|
|
17
|
+
import * as i6$1 from '@angular/material/tooltip';
|
|
18
|
+
import { MatTooltipModule, MatTooltip } from '@angular/material/tooltip';
|
|
19
|
+
import * as i6 from '@angular/material/input';
|
|
20
|
+
import { MatInputModule, MatInput } from '@angular/material/input';
|
|
21
|
+
import * as i4 from '@angular/material/form-field';
|
|
22
|
+
import { MatFormFieldModule, MatFormField } from '@angular/material/form-field';
|
|
23
|
+
import * as i7 from '@angular/material/menu';
|
|
24
|
+
import { MatMenuModule } from '@angular/material/menu';
|
|
25
|
+
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
26
|
+
import { DialogService } from '@myrmidon/ngx-mat-tools';
|
|
27
|
+
import { PagedTreeStore, BrowserTreeNodeComponent, EditablePagedTreeStoreServiceBase, ChangeOperationType } from '@myrmidon/paged-data-browsers';
|
|
28
|
+
import { moveItemInArray, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
|
|
29
|
+
import { MatChipListbox, MatChipOption, MatChipRemove } from '@angular/material/chips';
|
|
30
|
+
import { MatExpansionPanel } from '@angular/material/expansion';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A filter to be used for thesaurus paged tree browsers.
|
|
34
|
+
*/
|
|
35
|
+
class ThesaurusPagedTreeFilterComponent {
|
|
36
|
+
constructor() {
|
|
37
|
+
this.dialogRef = inject(MatDialogRef, {
|
|
38
|
+
optional: true,
|
|
39
|
+
});
|
|
40
|
+
this.data = inject(MAT_DIALOG_DATA, { optional: true });
|
|
41
|
+
/**
|
|
42
|
+
* The filter.
|
|
43
|
+
*/
|
|
44
|
+
this.filter = model(...(ngDevMode ? [undefined, { debugName: "filter" }] : []));
|
|
45
|
+
this.wrapped = signal(false, ...(ngDevMode ? [{ debugName: "wrapped" }] : []));
|
|
46
|
+
const formBuilder = inject(FormBuilder);
|
|
47
|
+
const data = this.data;
|
|
48
|
+
// form
|
|
49
|
+
this.label = formBuilder.control(null);
|
|
50
|
+
this.form = formBuilder.group({
|
|
51
|
+
label: this.label,
|
|
52
|
+
});
|
|
53
|
+
// bind dialog data if any
|
|
54
|
+
if (this.dialogRef) {
|
|
55
|
+
this.wrapped.set(true);
|
|
56
|
+
if (data) {
|
|
57
|
+
this.filter.set(data.filter);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.wrapped.set(false);
|
|
62
|
+
}
|
|
63
|
+
// update form when filter changes
|
|
64
|
+
effect(() => {
|
|
65
|
+
this.updateForm(this.filter());
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
ngOnInit() {
|
|
69
|
+
this.updateForm(this.filter());
|
|
70
|
+
}
|
|
71
|
+
updateForm(filter) {
|
|
72
|
+
if (!filter) {
|
|
73
|
+
this.form.reset();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.label.setValue(filter.label ?? null);
|
|
77
|
+
this.form.markAsPristine();
|
|
78
|
+
}
|
|
79
|
+
getFilter() {
|
|
80
|
+
return {
|
|
81
|
+
label: this.label.value ?? undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
reset() {
|
|
85
|
+
this.form.reset();
|
|
86
|
+
this.filter.set(null);
|
|
87
|
+
this.dialogRef?.close(null);
|
|
88
|
+
}
|
|
89
|
+
apply() {
|
|
90
|
+
const filter = this.getFilter();
|
|
91
|
+
this.filter.set(filter);
|
|
92
|
+
this.dialogRef?.close(filter);
|
|
93
|
+
}
|
|
94
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusPagedTreeFilterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
95
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: ThesaurusPagedTreeFilterComponent, isStandalone: true, selector: "cadmus-thesaurus-paged-tree-filter", inputs: { filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filter: "filterChange" }, ngImport: i0, template: "<form\r\n [formGroup]=\"form\"\r\n [style.margin.px]=\"wrapped() ? 16 : 0\"\r\n (submit)=\"apply()\"\r\n>\r\n <div class=\"form-row\">\r\n <!-- label -->\r\n <mat-form-field>\r\n <input matInput placeholder=\"label\" [formControl]=\"label\" />\r\n </mat-form-field>\r\n\r\n <!-- buttons -->\r\n <div class=\"button-row\">\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Reset\"\r\n (click)=\"reset()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Apply\"\r\n [disabled]=\"form.invalid\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i6$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
|
|
96
|
+
}
|
|
97
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusPagedTreeFilterComponent, decorators: [{
|
|
98
|
+
type: Component,
|
|
99
|
+
args: [{ selector: 'cadmus-thesaurus-paged-tree-filter', imports: [
|
|
100
|
+
ReactiveFormsModule,
|
|
101
|
+
MatButtonModule,
|
|
102
|
+
MatFormFieldModule,
|
|
103
|
+
MatInputModule,
|
|
104
|
+
MatIconModule,
|
|
105
|
+
MatTooltipModule,
|
|
106
|
+
], template: "<form\r\n [formGroup]=\"form\"\r\n [style.margin.px]=\"wrapped() ? 16 : 0\"\r\n (submit)=\"apply()\"\r\n>\r\n <div class=\"form-row\">\r\n <!-- label -->\r\n <mat-form-field>\r\n <input matInput placeholder=\"label\" [formControl]=\"label\" />\r\n </mat-form-field>\r\n\r\n <!-- buttons -->\r\n <div class=\"button-row\">\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Reset\"\r\n (click)=\"reset()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Apply\"\r\n [disabled]=\"form.invalid\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}\n"] }]
|
|
107
|
+
}], ctorParameters: () => [], propDecorators: { filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }, { type: i0.Output, args: ["filterChange"] }] } });
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A dialog component to edit a thesaurus entry.
|
|
111
|
+
*/
|
|
112
|
+
class ThesEntryEditorComponent {
|
|
113
|
+
constructor(dialogRef, data, formBuilder) {
|
|
114
|
+
this.dialogRef = dialogRef;
|
|
115
|
+
this.data = data;
|
|
116
|
+
this.entry = model(...(ngDevMode ? [undefined, { debugName: "entry" }] : []));
|
|
117
|
+
this.wrapped = false;
|
|
118
|
+
// bind to data properties if any
|
|
119
|
+
if (data) {
|
|
120
|
+
this.entry.set(data);
|
|
121
|
+
this.wrapped = true;
|
|
122
|
+
}
|
|
123
|
+
// custom validator for ID existence
|
|
124
|
+
const idExistsValidator = (control) => {
|
|
125
|
+
const entry = this.entry();
|
|
126
|
+
if (!entry || entry.idLocked || !entry.idExists) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const id = control.value?.trim();
|
|
130
|
+
if (!id) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
// if editing, allow the current id (do not check against itself)
|
|
134
|
+
if (entry.id === id) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return entry.idExists(id) ? { idExists: true } : null;
|
|
138
|
+
};
|
|
139
|
+
// custom validator for hierarchical ID prefix
|
|
140
|
+
const hierarchicalIdValidator = (control) => {
|
|
141
|
+
const entry = this.entry();
|
|
142
|
+
if (!entry || entry.idLocked || !entry.hierarchical || !entry.parentId) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const id = control.value?.trim();
|
|
146
|
+
if (!id) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const prefix = entry.parentId + '.';
|
|
150
|
+
if (!id.startsWith(prefix) || id.length <= prefix.length) {
|
|
151
|
+
return { hierarchicalPrefix: { requiredPrefix: prefix } };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
};
|
|
155
|
+
// form controls
|
|
156
|
+
this.id = new FormControl(data?.id || '', {
|
|
157
|
+
nonNullable: true,
|
|
158
|
+
validators: [
|
|
159
|
+
Validators.required,
|
|
160
|
+
Validators.maxLength(300),
|
|
161
|
+
idExistsValidator,
|
|
162
|
+
hierarchicalIdValidator,
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
this.value = new FormControl(data?.value || '', {
|
|
166
|
+
nonNullable: true,
|
|
167
|
+
validators: [Validators.required, Validators.maxLength(500)],
|
|
168
|
+
});
|
|
169
|
+
this.form = formBuilder.group({
|
|
170
|
+
id: this.id,
|
|
171
|
+
value: this.value,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
cancel() {
|
|
175
|
+
this.dialogRef?.close();
|
|
176
|
+
}
|
|
177
|
+
save() {
|
|
178
|
+
if (this.form.invalid) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const old = this.entry();
|
|
182
|
+
// save data and close the dialog
|
|
183
|
+
this.dialogRef?.close({
|
|
184
|
+
id: old.idLocked ? old.id : this.id.value?.trim(),
|
|
185
|
+
value: this.value.value?.trim(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesEntryEditorComponent, deps: [{ token: i1$1.MatDialogRef, optional: true }, { token: MAT_DIALOG_DATA, optional: true }, { token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
189
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesEntryEditorComponent, isStandalone: true, selector: "cadmus-thesaurus-entry-editor", inputs: { entry: { classPropertyName: "entry", publicName: "entry", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entry: "entryChange" }, ngImport: i0, template: "<form [formGroup]=\"form\" (submit)=\"save()\" [class.wrapped]=\"wrapped\">\r\n <fieldset>\r\n <legend>entry</legend>\r\n <div class=\"form-row\">\r\n <!-- id -->\r\n @if (entry()!.idLocked) {\r\n <span class=\"readonly\">{{ entry()!.id }}</span>\r\n } @else {\r\n <mat-form-field>\r\n <mat-label>ID</mat-label>\r\n <input matInput [formControl]=\"id\" />\r\n @if ($any(id).errors?.required && (id.dirty || id.touched)) {\r\n <mat-error>ID required</mat-error>\r\n } @if ($any(id).errors?.maxLength && (id.dirty || id.touched)) {\r\n <mat-error>ID too long</mat-error>\r\n } @if ($any(id).errors?.idExists && (id.dirty || id.touched)) {\r\n <mat-error>ID already exists</mat-error>\r\n } @if ($any(id).errors?.requiredPrefix && (id.dirty || id.touched)) {\r\n <mat-error>ID must start with \"{{ entry()!.parentId }}.\"</mat-error>\r\n }\r\n <!-- hint -->\r\n @if (entry()?.parentId) {\r\n <mat-hint>parent: {{ entry()!.parentId }}</mat-hint>\r\n }\r\n </mat-form-field>\r\n }\r\n <!-- value -->\r\n <mat-form-field>\r\n <mat-label>value</mat-label>\r\n <input matInput [formControl]=\"value\" />\r\n @if ($any(value).errors?.required && (value.dirty || value.touched)) {\r\n <mat-error>value required</mat-error>\r\n } @if ($any(value).errors?.maxLength && (value.dirty || value.touched))\r\n {\r\n <mat-error>value too long</mat-error>\r\n }\r\n </mat-form-field>\r\n </div>\r\n <!-- buttons -->\r\n <div>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Discard changes\"\r\n (click)=\"cancel()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Accept changes\"\r\n [disabled]=\"form.invalid || form.pristine\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center}.form-row *{flex:0 0 auto}.readonly{color:silver}.wrapped{padding:16px}fieldset{border:1px solid silver;border-radius:6px;padding:6px}legend{color:silver}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i6$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] }); }
|
|
190
|
+
}
|
|
191
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesEntryEditorComponent, decorators: [{
|
|
192
|
+
type: Component,
|
|
193
|
+
args: [{ selector: 'cadmus-thesaurus-entry-editor', imports: [
|
|
194
|
+
ReactiveFormsModule,
|
|
195
|
+
MatButtonModule,
|
|
196
|
+
MatFormFieldModule,
|
|
197
|
+
MatIconModule,
|
|
198
|
+
MatInputModule,
|
|
199
|
+
MatTooltipModule,
|
|
200
|
+
], template: "<form [formGroup]=\"form\" (submit)=\"save()\" [class.wrapped]=\"wrapped\">\r\n <fieldset>\r\n <legend>entry</legend>\r\n <div class=\"form-row\">\r\n <!-- id -->\r\n @if (entry()!.idLocked) {\r\n <span class=\"readonly\">{{ entry()!.id }}</span>\r\n } @else {\r\n <mat-form-field>\r\n <mat-label>ID</mat-label>\r\n <input matInput [formControl]=\"id\" />\r\n @if ($any(id).errors?.required && (id.dirty || id.touched)) {\r\n <mat-error>ID required</mat-error>\r\n } @if ($any(id).errors?.maxLength && (id.dirty || id.touched)) {\r\n <mat-error>ID too long</mat-error>\r\n } @if ($any(id).errors?.idExists && (id.dirty || id.touched)) {\r\n <mat-error>ID already exists</mat-error>\r\n } @if ($any(id).errors?.requiredPrefix && (id.dirty || id.touched)) {\r\n <mat-error>ID must start with \"{{ entry()!.parentId }}.\"</mat-error>\r\n }\r\n <!-- hint -->\r\n @if (entry()?.parentId) {\r\n <mat-hint>parent: {{ entry()!.parentId }}</mat-hint>\r\n }\r\n </mat-form-field>\r\n }\r\n <!-- value -->\r\n <mat-form-field>\r\n <mat-label>value</mat-label>\r\n <input matInput [formControl]=\"value\" />\r\n @if ($any(value).errors?.required && (value.dirty || value.touched)) {\r\n <mat-error>value required</mat-error>\r\n } @if ($any(value).errors?.maxLength && (value.dirty || value.touched))\r\n {\r\n <mat-error>value too long</mat-error>\r\n }\r\n </mat-form-field>\r\n </div>\r\n <!-- buttons -->\r\n <div>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Discard changes\"\r\n (click)=\"cancel()\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Accept changes\"\r\n [disabled]=\"form.invalid || form.pristine\"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n</form>\r\n", styles: [".form-row{display:flex;gap:8px;align-items:center}.form-row *{flex:0 0 auto}.readonly{color:silver}.wrapped{padding:16px}fieldset{border:1px solid silver;border-radius:6px;padding:6px}legend{color:silver}\n"] }]
|
|
201
|
+
}], ctorParameters: () => [{ type: i1$1.MatDialogRef, decorators: [{
|
|
202
|
+
type: Optional
|
|
203
|
+
}] }, { type: undefined, decorators: [{
|
|
204
|
+
type: Optional
|
|
205
|
+
}, {
|
|
206
|
+
type: Inject,
|
|
207
|
+
args: [MAT_DIALOG_DATA]
|
|
208
|
+
}] }, { type: i1.FormBuilder }], propDecorators: { entry: [{ type: i0.Input, args: [{ isSignal: true, alias: "entry", required: false }] }, { type: i0.Output, args: ["entryChange"] }] } });
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* An editable paged tree browser for thesaurus entries. Editing happens in memory
|
|
212
|
+
* and when complete, the changes can be committed to the underlying data store
|
|
213
|
+
* via the EditableStaticThesaurusPagedTreeStoreService.
|
|
214
|
+
*/
|
|
215
|
+
class EditableThesaurusBrowserComponent {
|
|
216
|
+
constructor() {
|
|
217
|
+
this._dialog = inject(MatDialog);
|
|
218
|
+
this._snackBar = inject(MatSnackBar);
|
|
219
|
+
this._dialogService = inject(DialogService);
|
|
220
|
+
/**
|
|
221
|
+
* The editable service to use to load the nodes.
|
|
222
|
+
*/
|
|
223
|
+
this.service = input(...(ngDevMode ? [undefined, { debugName: "service" }] : []));
|
|
224
|
+
/**
|
|
225
|
+
* True to show the tree root nodes filter.
|
|
226
|
+
*/
|
|
227
|
+
this.hasRootFilter = input(false, ...(ngDevMode ? [{ debugName: "hasRootFilter" }] : []));
|
|
228
|
+
/**
|
|
229
|
+
* Emitted when a node is clicked.
|
|
230
|
+
*/
|
|
231
|
+
this.nodePick = output();
|
|
232
|
+
/**
|
|
233
|
+
* Emitted when the changes state changes.
|
|
234
|
+
*/
|
|
235
|
+
this.changesStateChange = output();
|
|
236
|
+
/**
|
|
237
|
+
* The store instance, built from the service.
|
|
238
|
+
*/
|
|
239
|
+
this.store = computed(() => {
|
|
240
|
+
const service = this.service();
|
|
241
|
+
if (!service) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const store = new PagedTreeStore(service);
|
|
245
|
+
return store;
|
|
246
|
+
}, ...(ngDevMode ? [{ debugName: "store" }] : []));
|
|
247
|
+
this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
|
|
248
|
+
this.selectedNode = signal(null, ...(ngDevMode ? [{ debugName: "selectedNode" }] : []));
|
|
249
|
+
this.debug = new FormControl(false, {
|
|
250
|
+
nonNullable: true,
|
|
251
|
+
});
|
|
252
|
+
this.hideLoc = new FormControl(false, {
|
|
253
|
+
nonNullable: true,
|
|
254
|
+
});
|
|
255
|
+
this.hideFilter = new FormControl(false, {
|
|
256
|
+
nonNullable: true,
|
|
257
|
+
});
|
|
258
|
+
this.filter$ = of({});
|
|
259
|
+
this.nodes$ = of([]);
|
|
260
|
+
this.label = new FormControl(null);
|
|
261
|
+
this.form = new FormGroup({
|
|
262
|
+
label: this.label,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
ngOnInit() {
|
|
266
|
+
const store = this.store();
|
|
267
|
+
if (!store) {
|
|
268
|
+
console.error('Store not available - service input is required');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// setup observables (delayed because we require store to be available)
|
|
272
|
+
this.nodes$ = store.nodes$;
|
|
273
|
+
this.filter$ = store.filter$;
|
|
274
|
+
if (!store.getNodes().length) {
|
|
275
|
+
this.loading.set(true);
|
|
276
|
+
store.setFilter({}).finally(() => {
|
|
277
|
+
this.loading.set(false);
|
|
278
|
+
console.log('nodes loaded', store.getNodes());
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// subscribe to changes state
|
|
282
|
+
const service = this.service();
|
|
283
|
+
if (service) {
|
|
284
|
+
this._sub = service.hasChanges$.subscribe((hasChanges) => {
|
|
285
|
+
this.changesStateChange.emit(hasChanges);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
ngOnDestroy() {
|
|
290
|
+
this._sub?.unsubscribe();
|
|
291
|
+
}
|
|
292
|
+
reset() {
|
|
293
|
+
const store = this.store();
|
|
294
|
+
if (!store)
|
|
295
|
+
return;
|
|
296
|
+
this.loading.set(true);
|
|
297
|
+
store.reset().finally(() => {
|
|
298
|
+
this.loading.set(false);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
onToggleExpanded(node) {
|
|
302
|
+
const store = this.store();
|
|
303
|
+
if (!store)
|
|
304
|
+
return;
|
|
305
|
+
this.loading.set(true);
|
|
306
|
+
if (node.expanded) {
|
|
307
|
+
store.collapse(node.id).finally(() => {
|
|
308
|
+
this.loading.set(false);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
store.expand(node.id).finally(() => {
|
|
313
|
+
this.loading.set(false);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
onPageChangeRequest(request) {
|
|
318
|
+
const store = this.store();
|
|
319
|
+
if (!store)
|
|
320
|
+
return;
|
|
321
|
+
this.loading.set(true);
|
|
322
|
+
store.changePage(request.node.id, request.paging.pageNumber).finally(() => {
|
|
323
|
+
this.loading.set(false);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
onFilterChange(filter) {
|
|
327
|
+
const store = this.store();
|
|
328
|
+
if (!store)
|
|
329
|
+
return;
|
|
330
|
+
console.log('filter change', filter);
|
|
331
|
+
this.loading.set(true);
|
|
332
|
+
store.setFilter(filter || {}).finally(() => {
|
|
333
|
+
this.loading.set(false);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
onEditFilterRequest(node) {
|
|
337
|
+
const store = this.store();
|
|
338
|
+
if (!store)
|
|
339
|
+
return;
|
|
340
|
+
const dialogRef = this._dialog.open(ThesaurusPagedTreeFilterComponent, {
|
|
341
|
+
data: {
|
|
342
|
+
filter: node.filter,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
dialogRef.afterClosed().subscribe((filter) => {
|
|
346
|
+
if (filter === null) {
|
|
347
|
+
store.setNodeFilter(node.id, null);
|
|
348
|
+
}
|
|
349
|
+
else if (filter) {
|
|
350
|
+
store.setNodeFilter(node.id, filter);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
expandAll() {
|
|
355
|
+
const store = this.store();
|
|
356
|
+
if (!store)
|
|
357
|
+
return;
|
|
358
|
+
store.expandAll();
|
|
359
|
+
}
|
|
360
|
+
collapseAll() {
|
|
361
|
+
const store = this.store();
|
|
362
|
+
if (!store)
|
|
363
|
+
return;
|
|
364
|
+
store.collapseAll();
|
|
365
|
+
}
|
|
366
|
+
clear() {
|
|
367
|
+
const store = this.store();
|
|
368
|
+
if (!store)
|
|
369
|
+
return;
|
|
370
|
+
store.clear();
|
|
371
|
+
}
|
|
372
|
+
onNodeClick(node) {
|
|
373
|
+
this.selectedNode.set(node);
|
|
374
|
+
if (!node.hasChildren) {
|
|
375
|
+
this.nodePick.emit(node);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
findLabels() {
|
|
379
|
+
const store = this.store();
|
|
380
|
+
if (!store || !this.label.value)
|
|
381
|
+
return;
|
|
382
|
+
store.findLabels(this.label.value);
|
|
383
|
+
}
|
|
384
|
+
removeHilites() {
|
|
385
|
+
const store = this.store();
|
|
386
|
+
if (!store)
|
|
387
|
+
return;
|
|
388
|
+
store.removeHilites();
|
|
389
|
+
}
|
|
390
|
+
// editing operations
|
|
391
|
+
editEntry(entry) {
|
|
392
|
+
return new Promise((resolve) => {
|
|
393
|
+
const dialogRef = this._dialog.open(ThesEntryEditorComponent, {
|
|
394
|
+
data: entry,
|
|
395
|
+
});
|
|
396
|
+
dialogRef.afterClosed().subscribe((result) => {
|
|
397
|
+
resolve(result);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async addChild() {
|
|
402
|
+
const store = this.store();
|
|
403
|
+
const selected = this.selectedNode();
|
|
404
|
+
if (!store || !selected) {
|
|
405
|
+
this._snackBar.open('Please select a node first', 'Close', {
|
|
406
|
+
duration: 2000,
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// get the current entries to check for duplicates
|
|
411
|
+
const currentService = this.service();
|
|
412
|
+
if (!currentService)
|
|
413
|
+
return;
|
|
414
|
+
// determine the parent ID for the new child
|
|
415
|
+
const parentKey = selected.key; // original entry ID
|
|
416
|
+
// edit the new entry
|
|
417
|
+
const newEntry = await this.editEntry({
|
|
418
|
+
id: '',
|
|
419
|
+
value: '',
|
|
420
|
+
hierarchical: true,
|
|
421
|
+
parentId: parentKey,
|
|
422
|
+
idExists: (id) => currentService.getCurrentEntries().some((e) => e.id === id),
|
|
423
|
+
});
|
|
424
|
+
if (!newEntry) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
store
|
|
428
|
+
.addChild(selected.id, {
|
|
429
|
+
label: newEntry.value,
|
|
430
|
+
y: 0, // will be calculated by the store
|
|
431
|
+
x: 0, // will be calculated by the store
|
|
432
|
+
tag: selected.tag,
|
|
433
|
+
})
|
|
434
|
+
.then((result) => {
|
|
435
|
+
if (result) {
|
|
436
|
+
// update the temporary node with the user-provided ID,
|
|
437
|
+
// this will be handled in the service's nodeToEntry method
|
|
438
|
+
result._userProvidedId = newEntry.id;
|
|
439
|
+
this._snackBar.open('Child added successfully', 'Close', {
|
|
440
|
+
duration: 2000,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async addSibling() {
|
|
446
|
+
const store = this.store();
|
|
447
|
+
const selected = this.selectedNode();
|
|
448
|
+
if (!store || !selected) {
|
|
449
|
+
this._snackBar.open('Please select a node first', 'Close', {
|
|
450
|
+
duration: 2000,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// get the current entries to check for duplicates
|
|
455
|
+
const currentService = this.service();
|
|
456
|
+
if (!currentService)
|
|
457
|
+
return;
|
|
458
|
+
const currentEntries = currentService.getCurrentEntries();
|
|
459
|
+
const selectedKey = selected.key; // original entry ID
|
|
460
|
+
// determine the parent ID for the new sibling
|
|
461
|
+
const selectedParts = selectedKey.split('.');
|
|
462
|
+
const parentKey = selectedParts.length > 1 ? selectedParts.slice(0, -1).join('.') : '';
|
|
463
|
+
const newEntry = await this.editEntry({
|
|
464
|
+
id: '',
|
|
465
|
+
value: '',
|
|
466
|
+
hierarchical: true,
|
|
467
|
+
parentId: parentKey,
|
|
468
|
+
idExists: (id) => currentService.getCurrentEntries().some((e) => e.id === id),
|
|
469
|
+
});
|
|
470
|
+
if (!newEntry) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// check that the new ID has the same depth
|
|
474
|
+
if (newEntry.id.split('.').length !== selectedParts.length) {
|
|
475
|
+
this._snackBar.open(`Sibling must have same depth as "${selectedKey}"`, 'Close', { duration: 3000 });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
store
|
|
479
|
+
.addSibling(selected.id, {
|
|
480
|
+
label: newEntry.value,
|
|
481
|
+
tag: selected.tag,
|
|
482
|
+
})
|
|
483
|
+
.then((result) => {
|
|
484
|
+
if (result) {
|
|
485
|
+
// update the temporary node with the user-provided ID
|
|
486
|
+
result._userProvidedId = newEntry.id;
|
|
487
|
+
this._snackBar.open('Sibling added successfully', 'Close', {
|
|
488
|
+
duration: 2000,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
removeNode() {
|
|
494
|
+
const store = this.store();
|
|
495
|
+
const selected = this.selectedNode();
|
|
496
|
+
if (!store || !selected) {
|
|
497
|
+
this._snackBar.open('Please select a node first', 'Close', {
|
|
498
|
+
duration: 2000,
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const selectedKey = selected.key || `temp_${selected.id}`;
|
|
503
|
+
this._dialogService
|
|
504
|
+
.confirm('Confirm Deletion', `Delete "${selectedKey}" (${selected.label}) and all its descendants?`)
|
|
505
|
+
.subscribe((result) => {
|
|
506
|
+
if (!result) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
store.removeNode(selected.id).then((result) => {
|
|
510
|
+
if (result) {
|
|
511
|
+
this.selectedNode.set(null);
|
|
512
|
+
this._snackBar.open('Node removed successfully', 'Close', {
|
|
513
|
+
duration: 2000,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async editNode() {
|
|
520
|
+
const currentService = this.service();
|
|
521
|
+
if (!currentService)
|
|
522
|
+
return;
|
|
523
|
+
const store = this.store();
|
|
524
|
+
const selected = this.selectedNode();
|
|
525
|
+
if (!store || !selected) {
|
|
526
|
+
this._snackBar.open('Please select a node first', 'Close', {
|
|
527
|
+
duration: 2000,
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const selectedKey = selected.key;
|
|
532
|
+
const isNewNode = selected.id < 0; // temporary ID means it's a new node
|
|
533
|
+
const newEntry = await this.editEntry({
|
|
534
|
+
id: selectedKey || '',
|
|
535
|
+
value: selected.label,
|
|
536
|
+
idLocked: !isNewNode,
|
|
537
|
+
idExists: (id) => currentService.getCurrentEntries().some((entry) => entry.id === id),
|
|
538
|
+
});
|
|
539
|
+
if (!newEntry)
|
|
540
|
+
return;
|
|
541
|
+
store
|
|
542
|
+
.replaceNode(selected.id, {
|
|
543
|
+
label: newEntry.value,
|
|
544
|
+
tag: selected.tag,
|
|
545
|
+
})
|
|
546
|
+
.then((result) => {
|
|
547
|
+
if (result) {
|
|
548
|
+
// update the temporary node with the user-provided ID if it's a new node
|
|
549
|
+
if (isNewNode) {
|
|
550
|
+
result._userProvidedId = newEntry.id;
|
|
551
|
+
}
|
|
552
|
+
this._snackBar.open('Node updated successfully', 'Close', {
|
|
553
|
+
duration: 2000,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: EditableThesaurusBrowserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
559
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: EditableThesaurusBrowserComponent, isStandalone: true, selector: "cadmus-editable-thesaurus-browser", inputs: { service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null }, hasRootFilter: { classPropertyName: "hasRootFilter", publicName: "hasRootFilter", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodePick: "nodePick", changesStateChange: "changesStateChange" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\">\r\n <fieldset>\r\n <legend>finder</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched))\r\n {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <div class=\"form-row\" style=\"gap: 0\">\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n </fieldset>\r\n </form>\r\n @if (hasRootFilter()) {\r\n <fieldset>\r\n <legend>global filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n }\r\n </div>\r\n\r\n <!-- editing actions -->\r\n <div class=\"editing-actions\">\r\n <button\r\n type=\"button\"\r\n mat-raised-button\r\n color=\"primary\"\r\n [disabled]=\"!selectedNode()\"\r\n [matMenuTriggerFor]=\"addMenu\"\r\n >\r\n <mat-icon>add</mat-icon>\r\n add\r\n </button>\r\n\r\n <mat-menu #addMenu=\"matMenu\">\r\n <button type=\"button\" mat-menu-item (click)=\"addChild()\">\r\n <mat-icon>subdirectory_arrow_right</mat-icon>\r\n add child\r\n </button>\r\n <button type=\"button\" mat-menu-item (click)=\"addSibling()\">\r\n <mat-icon>arrow_right_alt</mat-icon>\r\n add sibling\r\n </button>\r\n </mat-menu>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"accent\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"editNode()\"\r\n >\r\n <mat-icon>edit</mat-icon>\r\n edit\r\n </button>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"warn\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"removeNode()\"\r\n >\r\n <mat-icon>delete</mat-icon>\r\n remove\r\n </button>\r\n\r\n @if (selectedNode()) {\r\n <span class=\"selected-info\"> selected: {{ selectedNode()!.label }} </span>\r\n }\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div\r\n class=\"button-row\"\r\n [class.hilite]=\"node.hilite\"\r\n [class.selected]=\"selectedNode()?.id === node.id\"\r\n (click)=\"onNodeClick(node)\"\r\n >\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["#container{padding:16px}.form-row{display:flex;align-items:center;gap:8px;margin-bottom:16px}.editing-actions{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:12px;border:1px solid #e0e0e0;border-radius:4px;background-color:#fafafa}.selected-info{margin-left:auto;font-style:italic;color:#666}.button-row{cursor:pointer;padding:4px;border-radius:4px;transition:background-color .2s}.button-row:hover{background-color:#f5f5f5}.button-row.selected{background-color:#e3f2fd;border:1px solid #2196f3}.button-row.hilite{background-color:#fff3e0}#tree{overflow-y:auto}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i7.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i7.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i7.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatProgressBarModule }, { kind: "component", type: i8.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "component", type: ThesaurusPagedTreeFilterComponent, selector: "cadmus-thesaurus-paged-tree-filter", inputs: ["filter"], outputs: ["filterChange"] }, { kind: "component", type: BrowserTreeNodeComponent, selector: "pdb-browser-tree-node", inputs: ["node", "paging", "debug", "hideLabel", "hideLoc", "hidePaging", "hideFilter", "indentSize", "rangeWidth"], outputs: ["toggleExpandedRequest", "changePageRequest", "editNodeFilterRequest"] }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); }
|
|
560
|
+
}
|
|
561
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: EditableThesaurusBrowserComponent, decorators: [{
|
|
562
|
+
type: Component,
|
|
563
|
+
args: [{ selector: 'cadmus-editable-thesaurus-browser', imports: [
|
|
564
|
+
AsyncPipe,
|
|
565
|
+
ReactiveFormsModule,
|
|
566
|
+
MatButtonModule,
|
|
567
|
+
MatCheckboxModule,
|
|
568
|
+
MatFormFieldModule,
|
|
569
|
+
MatIconModule,
|
|
570
|
+
MatInputModule,
|
|
571
|
+
MatMenuModule,
|
|
572
|
+
MatProgressBarModule,
|
|
573
|
+
MatTooltipModule,
|
|
574
|
+
ThesaurusPagedTreeFilterComponent,
|
|
575
|
+
BrowserTreeNodeComponent,
|
|
576
|
+
], template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\">\r\n <fieldset>\r\n <legend>finder</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched))\r\n {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <div class=\"form-row\" style=\"gap: 0\">\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </div>\r\n </div>\r\n </fieldset>\r\n </form>\r\n @if (hasRootFilter()) {\r\n <fieldset>\r\n <legend>global filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n }\r\n </div>\r\n\r\n <!-- editing actions -->\r\n <div class=\"editing-actions\">\r\n <button\r\n type=\"button\"\r\n mat-raised-button\r\n color=\"primary\"\r\n [disabled]=\"!selectedNode()\"\r\n [matMenuTriggerFor]=\"addMenu\"\r\n >\r\n <mat-icon>add</mat-icon>\r\n add\r\n </button>\r\n\r\n <mat-menu #addMenu=\"matMenu\">\r\n <button type=\"button\" mat-menu-item (click)=\"addChild()\">\r\n <mat-icon>subdirectory_arrow_right</mat-icon>\r\n add child\r\n </button>\r\n <button type=\"button\" mat-menu-item (click)=\"addSibling()\">\r\n <mat-icon>arrow_right_alt</mat-icon>\r\n add sibling\r\n </button>\r\n </mat-menu>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"accent\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"editNode()\"\r\n >\r\n <mat-icon>edit</mat-icon>\r\n edit\r\n </button>\r\n\r\n <button\r\n type=\"button\"\r\n mat-stroked-button\r\n color=\"warn\"\r\n [disabled]=\"!selectedNode()\"\r\n (click)=\"removeNode()\"\r\n >\r\n <mat-icon>delete</mat-icon>\r\n remove\r\n </button>\r\n\r\n @if (selectedNode()) {\r\n <span class=\"selected-info\"> selected: {{ selectedNode()!.label }} </span>\r\n }\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div\r\n class=\"button-row\"\r\n [class.hilite]=\"node.hilite\"\r\n [class.selected]=\"selectedNode()?.id === node.id\"\r\n (click)=\"onNodeClick(node)\"\r\n >\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["#container{padding:16px}.form-row{display:flex;align-items:center;gap:8px;margin-bottom:16px}.editing-actions{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:12px;border:1px solid #e0e0e0;border-radius:4px;background-color:#fafafa}.selected-info{margin-left:auto;font-style:italic;color:#666}.button-row{cursor:pointer;padding:4px;border-radius:4px;transition:background-color .2s}.button-row:hover{background-color:#f5f5f5}.button-row.selected{background-color:#e3f2fd;border:1px solid #2196f3}.button-row.hilite{background-color:#fff3e0}#tree{overflow-y:auto}\n"] }]
|
|
577
|
+
}], propDecorators: { service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], hasRootFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "hasRootFilter", required: false }] }], nodePick: [{ type: i0.Output, args: ["nodePick"] }], changesStateChange: [{ type: i0.Output, args: ["changesStateChange"] }] } });
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* A label rendering function which removes from a label
|
|
581
|
+
* all the characters past the last colon, trimming the result.
|
|
582
|
+
* This is a typical rendering when dealing with hierarchical
|
|
583
|
+
* thesaurus entries, e.g. "furniture: table: color", where
|
|
584
|
+
* we can shorten the label to just "color", as "furniture"
|
|
585
|
+
* and "table" are its ancestors.
|
|
586
|
+
*/
|
|
587
|
+
function renderLabelFromLastColon(label) {
|
|
588
|
+
if (!label) {
|
|
589
|
+
return label;
|
|
590
|
+
}
|
|
591
|
+
const i = label.lastIndexOf(':');
|
|
592
|
+
return i > -1 && i + 1 < label.length ? label.substring(i + 1).trim() : label;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* A static paged tree store service for thesaurus entries.
|
|
596
|
+
* This builds tree nodes from all the thesaurus entries from a specific
|
|
597
|
+
* thesaurus, assuming that entry IDs are hierarchical and
|
|
598
|
+
* separated by dots (.).
|
|
599
|
+
* Nodes are received all at once for a thesaurus, so this is essentially
|
|
600
|
+
* an adapter to the paged tree store service interface.
|
|
601
|
+
* The entries are a flat list where hierarchy is implied by the IDs:
|
|
602
|
+
* for instance, "animal.mammal.dog" is a child of "animal.mammal"
|
|
603
|
+
* which is a child of "animal". Numeric IDs are added to each node
|
|
604
|
+
* based on the order of entries to ensure stable IDs.
|
|
605
|
+
* The label of each node can be rendered as-is from the entry value,
|
|
606
|
+
* or a custom rendering function can be provided to the constructor.
|
|
607
|
+
*/
|
|
608
|
+
class StaticThesaurusPagedTreeStoreService {
|
|
609
|
+
/**
|
|
610
|
+
* Constructor.
|
|
611
|
+
* @param entries The thesaurus entries.
|
|
612
|
+
* @param _renderLabel An optional function to render the label
|
|
613
|
+
* of each node.
|
|
614
|
+
*/
|
|
615
|
+
constructor(entries, _renderLabel) {
|
|
616
|
+
this._renderLabel = _renderLabel;
|
|
617
|
+
this._nodes = [];
|
|
618
|
+
this._built = false;
|
|
619
|
+
// assign a number to each entry for stable IDs
|
|
620
|
+
this._entries = entries.map((entry, index) => ({
|
|
621
|
+
...entry,
|
|
622
|
+
n: index + 1,
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Ensure that nodes have been built from entries.
|
|
627
|
+
*/
|
|
628
|
+
ensureNodes() {
|
|
629
|
+
// lazily build the nodes only once
|
|
630
|
+
if (this._built) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// create a map of sibling numbers (x) where:
|
|
634
|
+
// - key=node.id (0 is used for the virtual root);
|
|
635
|
+
// - value=max x value for that parent.
|
|
636
|
+
const xMap = new Map();
|
|
637
|
+
// build nodes from entries
|
|
638
|
+
for (const entry of this._entries) {
|
|
639
|
+
const hasDot = entry.id.includes('.');
|
|
640
|
+
const keyParts = entry.id.split('.');
|
|
641
|
+
// create the node
|
|
642
|
+
const node = {
|
|
643
|
+
id: entry.n,
|
|
644
|
+
label: hasDot && this._renderLabel
|
|
645
|
+
? this._renderLabel(entry.value)
|
|
646
|
+
: entry.value,
|
|
647
|
+
key: entry.id,
|
|
648
|
+
value: entry.value,
|
|
649
|
+
paging: { pageNumber: 0, pageCount: 0, total: 0 },
|
|
650
|
+
y: hasDot ? keyParts.length : 1,
|
|
651
|
+
x: 0, // will be set later
|
|
652
|
+
parentId: undefined, // will be set later
|
|
653
|
+
hasChildren: false, // will be set later
|
|
654
|
+
};
|
|
655
|
+
// if it has a dot, find its parent and set parentId
|
|
656
|
+
if (hasDot) {
|
|
657
|
+
// build the parent key
|
|
658
|
+
const parentKey = keyParts.slice(0, keyParts.length - 1).join('.');
|
|
659
|
+
// find the parent node with that key
|
|
660
|
+
const parentNode = this._nodes.find((n) => n.key === parentKey);
|
|
661
|
+
// if found, set parentId and x in child and hasChildren in parent
|
|
662
|
+
if (parentNode) {
|
|
663
|
+
node.parentId = parentNode.id;
|
|
664
|
+
parentNode.hasChildren = true;
|
|
665
|
+
if (xMap.has(parentNode.id)) {
|
|
666
|
+
xMap.set(parentNode.id, xMap.get(parentNode.id) + 1);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
xMap.set(parentNode.id, 1);
|
|
670
|
+
}
|
|
671
|
+
node.x = xMap.get(parentNode.id);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// parent not found, treat as root
|
|
675
|
+
if (xMap.has(0)) {
|
|
676
|
+
xMap.set(0, xMap.get(0) + 1);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
xMap.set(0, 1);
|
|
680
|
+
}
|
|
681
|
+
node.x = xMap.get(0);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
// else it's a root node
|
|
686
|
+
if (xMap.has(0)) {
|
|
687
|
+
xMap.set(0, xMap.get(0) + 1);
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
xMap.set(0, 1);
|
|
691
|
+
}
|
|
692
|
+
node.x = xMap.get(0);
|
|
693
|
+
}
|
|
694
|
+
this._nodes.push(node);
|
|
695
|
+
}
|
|
696
|
+
this._built = true;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Get the specified page of nodes.
|
|
700
|
+
* @param filter The filter.
|
|
701
|
+
* @param pageNumber The page number.
|
|
702
|
+
* @param pageSize The page size.
|
|
703
|
+
* @param hasMockRoot Whether the root node is a mock node. Not used here.
|
|
704
|
+
*/
|
|
705
|
+
getNodes(filter, pageNumber, pageSize, hasMockRoot) {
|
|
706
|
+
this.ensureNodes();
|
|
707
|
+
// apply filtering
|
|
708
|
+
let nodes = this._nodes.filter((n) => {
|
|
709
|
+
// filter by parentId (required by tree structure)
|
|
710
|
+
if (filter.parentId !== undefined && filter.parentId !== null) {
|
|
711
|
+
if (n.parentId !== filter.parentId) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
if (n.parentId) {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// filter by label
|
|
721
|
+
if (filter.label) {
|
|
722
|
+
const filterValue = filter.label.toLowerCase();
|
|
723
|
+
if (!n.label.toLowerCase().includes(filterValue)) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return true;
|
|
728
|
+
});
|
|
729
|
+
// page and return
|
|
730
|
+
const paged = nodes.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
|
731
|
+
return of({
|
|
732
|
+
items: paged,
|
|
733
|
+
pageNumber: pageNumber,
|
|
734
|
+
pageSize: pageSize,
|
|
735
|
+
pageCount: Math.ceil(nodes.length / pageSize),
|
|
736
|
+
total: nodes.length,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A readonly paged tree browser for thesaurus entries. This is a lower-level component
|
|
743
|
+
* used as an adapter between the generic paged tree browser and the thesaurus entries
|
|
744
|
+
* data model.
|
|
745
|
+
*/
|
|
746
|
+
class ThesaurusBrowserComponent {
|
|
747
|
+
constructor() {
|
|
748
|
+
this._dialog = inject(MatDialog);
|
|
749
|
+
/**
|
|
750
|
+
* The service to use to load the nodes.
|
|
751
|
+
*/
|
|
752
|
+
this.service = input(new StaticThesaurusPagedTreeStoreService([], renderLabelFromLastColon), ...(ngDevMode ? [{ debugName: "service" }] : []));
|
|
753
|
+
/**
|
|
754
|
+
* Emitted when a node is clicked.
|
|
755
|
+
*/
|
|
756
|
+
this.nodePick = output();
|
|
757
|
+
/**
|
|
758
|
+
* The store instance, built from the service.
|
|
759
|
+
*/
|
|
760
|
+
this.store = computed(() => {
|
|
761
|
+
const service = this.service();
|
|
762
|
+
const store = new PagedTreeStore(service);
|
|
763
|
+
this.nodes$ = store.nodes$;
|
|
764
|
+
this.filter$ = store.filter$;
|
|
765
|
+
return store;
|
|
766
|
+
}, ...(ngDevMode ? [{ debugName: "store" }] : []));
|
|
767
|
+
this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
|
|
768
|
+
this.debug = new FormControl(false, {
|
|
769
|
+
nonNullable: true,
|
|
770
|
+
});
|
|
771
|
+
this.hideLoc = new FormControl(false, {
|
|
772
|
+
nonNullable: true,
|
|
773
|
+
});
|
|
774
|
+
this.hideFilter = new FormControl(false, {
|
|
775
|
+
nonNullable: true,
|
|
776
|
+
});
|
|
777
|
+
this.label = new FormControl(null);
|
|
778
|
+
this.form = new FormGroup({
|
|
779
|
+
label: this.label,
|
|
780
|
+
});
|
|
781
|
+
const store = this.store();
|
|
782
|
+
this.nodes$ = store.nodes$;
|
|
783
|
+
this.filter$ = store.filter$;
|
|
784
|
+
}
|
|
785
|
+
ngOnInit() {
|
|
786
|
+
if (!this.store().getNodes().length) {
|
|
787
|
+
this.loading.set(true);
|
|
788
|
+
this.store()
|
|
789
|
+
.setFilter({})
|
|
790
|
+
.finally(() => {
|
|
791
|
+
this.loading.set(false);
|
|
792
|
+
console.log('nodes loaded', this.store().getNodes());
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
ngOnDestroy() {
|
|
797
|
+
this._sub?.unsubscribe();
|
|
798
|
+
}
|
|
799
|
+
reset() {
|
|
800
|
+
this.loading.set(true);
|
|
801
|
+
this.store()
|
|
802
|
+
.reset()
|
|
803
|
+
.finally(() => {
|
|
804
|
+
this.loading.set(false);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
onToggleExpanded(node) {
|
|
808
|
+
this.loading.set(true);
|
|
809
|
+
if (node.expanded) {
|
|
810
|
+
this.store()
|
|
811
|
+
.collapse(node.id)
|
|
812
|
+
.finally(() => {
|
|
813
|
+
this.loading.set(false);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
this.store()
|
|
818
|
+
.expand(node.id)
|
|
819
|
+
.finally(() => {
|
|
820
|
+
this.loading.set(false);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
onPageChangeRequest(request) {
|
|
825
|
+
this.loading.set(true);
|
|
826
|
+
this.store()
|
|
827
|
+
.changePage(request.node.id, request.paging.pageNumber)
|
|
828
|
+
.finally(() => {
|
|
829
|
+
this.loading.set(false);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
onFilterChange(filter) {
|
|
833
|
+
console.log('filter change', filter);
|
|
834
|
+
this.loading.set(true);
|
|
835
|
+
this.store()
|
|
836
|
+
.setFilter(filter || {})
|
|
837
|
+
.finally(() => {
|
|
838
|
+
this.loading.set(false);
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
onEditFilterRequest(node) {
|
|
842
|
+
const dialogRef = this._dialog.open(ThesaurusPagedTreeFilterComponent, {
|
|
843
|
+
data: {
|
|
844
|
+
filter: node.filter,
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
dialogRef.afterClosed().subscribe((filter) => {
|
|
848
|
+
// undefined = user dismissed without changes
|
|
849
|
+
if (filter === null) {
|
|
850
|
+
this.store().setNodeFilter(node.id, null);
|
|
851
|
+
}
|
|
852
|
+
else if (filter) {
|
|
853
|
+
this.store().setNodeFilter(node.id, filter);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
expandAll() {
|
|
858
|
+
this.store().expandAll();
|
|
859
|
+
}
|
|
860
|
+
collapseAll() {
|
|
861
|
+
this.store().collapseAll();
|
|
862
|
+
}
|
|
863
|
+
clear() {
|
|
864
|
+
this.store().clear();
|
|
865
|
+
}
|
|
866
|
+
onNodeClick(node) {
|
|
867
|
+
if (!node.hasChildren) {
|
|
868
|
+
this.nodePick.emit(node);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
findLabels() {
|
|
872
|
+
if (!this.label.value) {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
this.store().findLabels(this.label.value);
|
|
876
|
+
}
|
|
877
|
+
removeHilites() {
|
|
878
|
+
this.store().removeHilites();
|
|
879
|
+
}
|
|
880
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusBrowserComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
881
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusBrowserComponent, isStandalone: true, selector: "cadmus-thesaurus-browser", inputs: { service: { classPropertyName: "service", publicName: "service", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodePick: "nodePick" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\" class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched)) {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </form>\r\n <fieldset>\r\n <legend>filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div class=\"button-row\" [class.hilite]=\"node.hilite\">\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n @if (!node.hasChildren) {\r\n <button type=\"button\" mat-icon-button (click)=\"onNodeClick(node)\">\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["form{margin:16px}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.hilite{background-color:#ffffe0;border:2px solid gold;border-radius:6px;padding:2px 4px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatProgressBarModule }, { kind: "component", type: i8.MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "component", type: ThesaurusPagedTreeFilterComponent, selector: "cadmus-thesaurus-paged-tree-filter", inputs: ["filter"], outputs: ["filterChange"] }, { kind: "component", type: BrowserTreeNodeComponent, selector: "pdb-browser-tree-node", inputs: ["node", "paging", "debug", "hideLabel", "hideLoc", "hidePaging", "hideFilter", "indentSize", "rangeWidth"], outputs: ["toggleExpandedRequest", "changePageRequest", "editNodeFilterRequest"] }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); }
|
|
882
|
+
}
|
|
883
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusBrowserComponent, decorators: [{
|
|
884
|
+
type: Component,
|
|
885
|
+
args: [{ selector: 'cadmus-thesaurus-browser', imports: [
|
|
886
|
+
AsyncPipe,
|
|
887
|
+
ReactiveFormsModule,
|
|
888
|
+
MatButtonModule,
|
|
889
|
+
MatCheckboxModule,
|
|
890
|
+
MatFormFieldModule,
|
|
891
|
+
MatIconModule,
|
|
892
|
+
MatInputModule,
|
|
893
|
+
MatProgressBarModule,
|
|
894
|
+
MatTooltipModule,
|
|
895
|
+
ThesaurusPagedTreeFilterComponent,
|
|
896
|
+
BrowserTreeNodeComponent,
|
|
897
|
+
], template: "<div id=\"container\">\r\n <!-- filters -->\r\n <div id=\"filters\" class=\"form-row\">\r\n <form [formGroup]=\"form\" (submit)=\"findLabels()\" class=\"form-row\">\r\n <mat-form-field>\r\n <mat-label>label</mat-label>\r\n <input matInput [formControl]=\"label\" />\r\n @if ($any(label).errors?.required && (label.dirty || label.touched)) {\r\n <mat-error>label required</mat-error>\r\n }\r\n </mat-form-field>\r\n <button type=\"submit\" mat-icon-button class=\"mat-primary\">\r\n <mat-icon>search</mat-icon>\r\n </button>\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n (click)=\"removeHilites()\"\r\n class=\"mat-warn\"\r\n >\r\n <mat-icon>clear</mat-icon>\r\n </button>\r\n </form>\r\n <fieldset>\r\n <legend>filter</legend>\r\n <cadmus-thesaurus-paged-tree-filter\r\n [filter]=\"filter$ | async\"\r\n (filterChange)=\"onFilterChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n\r\n <!-- tree -->\r\n <div id=\"tree\">\r\n <!-- progress -->\r\n @if (loading()) {\r\n <div>\r\n <mat-progress-bar mode=\"indeterminate\"></mat-progress-bar>\r\n </div>\r\n }\r\n <!-- nodes -->\r\n @if (nodes$ | async; as nodes) {\r\n <div>\r\n @for (node of nodes; track node.id; let i = $index) {\r\n <div class=\"button-row\" [class.hilite]=\"node.hilite\">\r\n <pdb-browser-tree-node\r\n [node]=\"node\"\r\n [debug]=\"debug.value\"\r\n [paging]=\"\r\n node.expanded &&\r\n i + 1 < nodes.length &&\r\n nodes[i + 1].paging.pageCount > 1\r\n ? nodes[i + 1].paging\r\n : undefined\r\n \"\r\n [hideFilter]=\"hideFilter.value\"\r\n [hideLoc]=\"hideLoc.value\"\r\n (toggleExpandedRequest)=\"onToggleExpanded($any($event))\"\r\n (changePageRequest)=\"onPageChangeRequest($event)\"\r\n (editNodeFilterRequest)=\"onEditFilterRequest($any($event))\"\r\n />\r\n @if (!node.hasChildren) {\r\n <button type=\"button\" mat-icon-button (click)=\"onNodeClick(node)\">\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-row\">\r\n <mat-checkbox [formControl]=\"debug\">debug</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideFilter\">no filter</mat-checkbox>\r\n <mat-checkbox [formControl]=\"hideLoc\">no loc.</mat-checkbox>\r\n <button\r\n type=\"button\"\r\n mat-flat-button\r\n (click)=\"collapseAll()\"\r\n style=\"margin-left: 24px\"\r\n >\r\n collapse\r\n </button>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: ["form{margin:16px}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.nr{width:6em}fieldset{border:1px solid silver;border-radius:6px;padding:8px 16px;margin:8px 0}legend{color:silver}th{text-align:left;font-weight:400}#tag-selector{width:8em}.tag-chip{border:2px solid transparent;border-radius:6px}.button-row{display:flex;align-items:center;flex-wrap:wrap}.button-row *{flex:0 0 auto}.hilite{background-color:#ffffe0;border:2px solid gold;border-radius:6px;padding:2px 4px}\n"] }]
|
|
898
|
+
}], ctorParameters: () => [], propDecorators: { service: [{ type: i0.Input, args: [{ isSignal: true, alias: "service", required: false }] }], nodePick: [{ type: i0.Output, args: ["nodePick"] }] } });
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Thesaurus tree component.
|
|
902
|
+
* This component displays a set of hierarchical thesaurus entries
|
|
903
|
+
* in a tree, provided that each entry marks its hierarchy with
|
|
904
|
+
* dots. For instance, say you have the hierarchy "furniture" -
|
|
905
|
+
* "type" - "color". You might have an entry whose ID is
|
|
906
|
+
* "furniture.table.red", with a sibling "furniture.table.green",
|
|
907
|
+
* and a parent "furniture.table". This parent is there only to
|
|
908
|
+
* provide a label to the parent node, but only leaf nodes can be
|
|
909
|
+
* picked by the user. Whenever one is picked, the entryChange
|
|
910
|
+
* event is emitted.
|
|
911
|
+
*/
|
|
912
|
+
class ThesaurusTreeComponent {
|
|
913
|
+
constructor() {
|
|
914
|
+
/**
|
|
915
|
+
* The thesaurus entries.
|
|
916
|
+
*/
|
|
917
|
+
this.entries = input(...(ngDevMode ? [undefined, { debugName: "entries" }] : []));
|
|
918
|
+
/**
|
|
919
|
+
* The optional node label rendering function. Note that even if you
|
|
920
|
+
* specify a label renderer function, the entryChange event always
|
|
921
|
+
* emits the original label.
|
|
922
|
+
*/
|
|
923
|
+
this.renderLabel = input(renderLabelFromLastColon, ...(ngDevMode ? [{ debugName: "renderLabel" }] : []));
|
|
924
|
+
/**
|
|
925
|
+
* Fired when a thesaurus entry is selected.
|
|
926
|
+
*/
|
|
927
|
+
this.entryChange = output();
|
|
928
|
+
/**
|
|
929
|
+
* The tree store service, dependent on the current entries and renderLabel.
|
|
930
|
+
*/
|
|
931
|
+
this.service = computed(() => {
|
|
932
|
+
const entries = this.entries();
|
|
933
|
+
return new StaticThesaurusPagedTreeStoreService(entries || [], this.renderLabel());
|
|
934
|
+
}, ...(ngDevMode ? [{ debugName: "service" }] : []));
|
|
935
|
+
/**
|
|
936
|
+
* The filter component class to use.
|
|
937
|
+
*/
|
|
938
|
+
this.filterComponent = ThesaurusPagedTreeFilterComponent;
|
|
939
|
+
}
|
|
940
|
+
onNodePick(node) {
|
|
941
|
+
// only allow selection of leaf nodes (nodes without children)
|
|
942
|
+
if (!node.hasChildren) {
|
|
943
|
+
// find the original entry
|
|
944
|
+
const entry = this.entries()?.find((e) => e.id === node.key);
|
|
945
|
+
if (entry) {
|
|
946
|
+
this.entryChange.emit(entry);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusTreeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
951
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusTreeComponent, isStandalone: true, selector: "cadmus-thesaurus-tree", inputs: { entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, renderLabel: { classPropertyName: "renderLabel", publicName: "renderLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entryChange: "entryChange" }, ngImport: i0, template: "<div>\r\n <!-- tree -->\r\n @if (service() && entries()?.length) {\r\n <div class=\"tree-wrapper\">\r\n <cadmus-thesaurus-browser\r\n [service]=\"service()\"\r\n (nodePick)=\"onNodePick($event)\"\r\n />\r\n </div>\r\n }\r\n</div>\r\n", styles: [".th-tree-progress-bar{margin-left:30px}.th-tree-nested-node{padding-left:30px}mat-tree{margin-left:40px}.mat-tree-node{padding:0;background-color:#fff}.mat-nested-tree-node{top:-24px}ul,li{list-style:none;margin:0;padding:0}li.th-tree-container{border-bottom:0}ul{padding-left:40px}li{padding-left:40px;border:1px dotted grey;border-width:0 0 1px 1px;position:relative;top:-24px}li.mat-tree-node,li div{margin:0;position:relative;top:24px}li ul{border-top:1px dotted grey;margin-left:-40px;padding-left:60px}.mat-nested-tree-node:last-child ul{border-left:1px solid white;margin-left:-41px}.mat-mdc-icon-button{z-index:100}span.found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#0cc078;border-radius:6px}span.not-found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#fb6962;border-radius:6px}.hilite{background-color:#fdfd96}.filter-field{width:100%;margin-bottom:1rem}.tree-container{border:1px solid #e0e0e0;border-radius:4px;overflow:auto;max-height:500px}.tree-wrapper{border:1px solid #e0e0e0;border-radius:4px;overflow:hidden;background:#fff}.no-entries{text-align:center;padding:2rem;color:#666;font-style:italic;border:1px solid #e0e0e0;border-radius:4px;background:#f9f9f9}.no-entries p{margin:0}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: ThesaurusBrowserComponent, selector: "cadmus-thesaurus-browser", inputs: ["service"], outputs: ["nodePick"] }] }); }
|
|
952
|
+
}
|
|
953
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusTreeComponent, decorators: [{
|
|
954
|
+
type: Component,
|
|
955
|
+
args: [{ selector: 'cadmus-thesaurus-tree', imports: [FormsModule, ReactiveFormsModule, ThesaurusBrowserComponent], template: "<div>\r\n <!-- tree -->\r\n @if (service() && entries()?.length) {\r\n <div class=\"tree-wrapper\">\r\n <cadmus-thesaurus-browser\r\n [service]=\"service()\"\r\n (nodePick)=\"onNodePick($event)\"\r\n />\r\n </div>\r\n }\r\n</div>\r\n", styles: [".th-tree-progress-bar{margin-left:30px}.th-tree-nested-node{padding-left:30px}mat-tree{margin-left:40px}.mat-tree-node{padding:0;background-color:#fff}.mat-nested-tree-node{top:-24px}ul,li{list-style:none;margin:0;padding:0}li.th-tree-container{border-bottom:0}ul{padding-left:40px}li{padding-left:40px;border:1px dotted grey;border-width:0 0 1px 1px;position:relative;top:-24px}li.mat-tree-node,li div{margin:0;position:relative;top:24px}li ul{border-top:1px dotted grey;margin-left:-40px;padding-left:60px}.mat-nested-tree-node:last-child ul{border-left:1px solid white;margin-left:-41px}.mat-mdc-icon-button{z-index:100}span.found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#0cc078;border-radius:6px}span.not-found-count{margin:0 4px;padding:2px 4px;color:#fff;background-color:#fb6962;border-radius:6px}.hilite{background-color:#fdfd96}.filter-field{width:100%;margin-bottom:1rem}.tree-container{border:1px solid #e0e0e0;border-radius:4px;overflow:auto;max-height:500px}.tree-wrapper{border:1px solid #e0e0e0;border-radius:4px;overflow:hidden;background:#fff}.no-entries{text-align:center;padding:2rem;color:#666;font-style:italic;border:1px solid #e0e0e0;border-radius:4px;background:#f9f9f9}.no-entries p{margin:0}\n"] }]
|
|
956
|
+
}], propDecorators: { entries: [{ type: i0.Input, args: [{ isSignal: true, alias: "entries", required: false }] }], renderLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "renderLabel", required: false }] }], entryChange: [{ type: i0.Output, args: ["entryChange"] }] } });
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The prefix added to custom entries' IDs.
|
|
960
|
+
*/
|
|
961
|
+
const CUSTOM_ENTRY_PREFIX = '$';
|
|
962
|
+
/**
|
|
963
|
+
* A picker component for thesaurus entries.
|
|
964
|
+
* This component allows picking one or more entries from a given thesaurus.
|
|
965
|
+
* In its collapsed state, it shows the picked entries as chips; when
|
|
966
|
+
* expanded, it shows the thesaurus tree to pick from. Custom entries
|
|
967
|
+
* (not in the thesaurus) can be optionally allowed.
|
|
968
|
+
*/
|
|
969
|
+
class ThesaurusEntriesPickerComponent {
|
|
970
|
+
constructor(formBuilder) {
|
|
971
|
+
/**
|
|
972
|
+
* The thesaurus entries to pick from (required).
|
|
973
|
+
*/
|
|
974
|
+
this.availableEntries = input.required(...(ngDevMode ? [{ debugName: "availableEntries" }] : []));
|
|
975
|
+
/**
|
|
976
|
+
* The picked entries.
|
|
977
|
+
*/
|
|
978
|
+
this.entries = model([], ...(ngDevMode ? [{ debugName: "entries" }] : []));
|
|
979
|
+
/**
|
|
980
|
+
* True to show the entries with labels shortened according to
|
|
981
|
+
* their hierarchy.
|
|
982
|
+
*/
|
|
983
|
+
this.hierarchicLabels = input(false, ...(ngDevMode ? [{ debugName: "hierarchicLabels" }] : []));
|
|
984
|
+
/**
|
|
985
|
+
* True to automatically sort the picked entries (disables drag-and-drop).
|
|
986
|
+
* When false, entries can be manually reordered via drag-and-drop.
|
|
987
|
+
*/
|
|
988
|
+
this.autoSort = input(false, ...(ngDevMode ? [{ debugName: "autoSort" }] : []));
|
|
989
|
+
/**
|
|
990
|
+
* True to allow custom values (not in the entries list).
|
|
991
|
+
*/
|
|
992
|
+
this.allowCustom = input(false, ...(ngDevMode ? [{ debugName: "allowCustom" }] : []));
|
|
993
|
+
/**
|
|
994
|
+
* The minimum number of entries to pick.
|
|
995
|
+
*/
|
|
996
|
+
this.minEntries = input(0, ...(ngDevMode ? [{ debugName: "minEntries" }] : []));
|
|
997
|
+
/**
|
|
998
|
+
* The maximum number of entries to pick (0=unlimited).
|
|
999
|
+
*/
|
|
1000
|
+
this.maxEntries = input(0, ...(ngDevMode ? [{ debugName: "maxEntries" }] : []));
|
|
1001
|
+
/**
|
|
1002
|
+
* True when the picker is expanded (showing the entries list).
|
|
1003
|
+
*/
|
|
1004
|
+
this.expanded = model(false, ...(ngDevMode ? [{ debugName: "expanded" }] : []));
|
|
1005
|
+
/**
|
|
1006
|
+
* The message to show when there are no picked entries.
|
|
1007
|
+
*/
|
|
1008
|
+
this.emptyMessage = input('no entries', ...(ngDevMode ? [{ debugName: "emptyMessage" }] : []));
|
|
1009
|
+
/**
|
|
1010
|
+
* The number of remaining entries that can be picked (0=unlimited).
|
|
1011
|
+
* This is displayed only if maxEntries > 1.
|
|
1012
|
+
*/
|
|
1013
|
+
this.remaining = computed(() => {
|
|
1014
|
+
if (this.maxEntries() > 0) {
|
|
1015
|
+
return this.maxEntries() - this.entries().length;
|
|
1016
|
+
}
|
|
1017
|
+
return 0;
|
|
1018
|
+
}, ...(ngDevMode ? [{ debugName: "remaining" }] : []));
|
|
1019
|
+
// need arrow function to use 'this'
|
|
1020
|
+
this.renderLabel = (label) => {
|
|
1021
|
+
return this.hierarchicLabels() ? renderLabelFromLastColon(label) : label;
|
|
1022
|
+
};
|
|
1023
|
+
this.id = formBuilder.control('', {
|
|
1024
|
+
validators: [Validators.required, Validators.maxLength(100)],
|
|
1025
|
+
nonNullable: true,
|
|
1026
|
+
});
|
|
1027
|
+
this.value = formBuilder.control('', {
|
|
1028
|
+
validators: [Validators.required, Validators.maxLength(500)],
|
|
1029
|
+
nonNullable: true,
|
|
1030
|
+
});
|
|
1031
|
+
this.form = formBuilder.group({
|
|
1032
|
+
id: this.id,
|
|
1033
|
+
value: this.value,
|
|
1034
|
+
});
|
|
1035
|
+
// if auto-sort is turned on and we have entries, sort them
|
|
1036
|
+
effect(() => {
|
|
1037
|
+
// only track autoSort() changes, not entries()
|
|
1038
|
+
const shouldSort = this.autoSort();
|
|
1039
|
+
if (shouldSort) {
|
|
1040
|
+
// use untracked to read entries without creating a dependency
|
|
1041
|
+
const currentEntries = untracked(() => this.entries());
|
|
1042
|
+
if (currentEntries.length > 1) {
|
|
1043
|
+
const entries = [...currentEntries];
|
|
1044
|
+
this.sortEntries(entries);
|
|
1045
|
+
this.entries.set(entries);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
sortEntries(entries) {
|
|
1051
|
+
entries.sort((a, b) => {
|
|
1052
|
+
const aIsCustom = a.id?.startsWith(CUSTOM_ENTRY_PREFIX) ?? false;
|
|
1053
|
+
const bIsCustom = b.id?.startsWith(CUSTOM_ENTRY_PREFIX) ?? false;
|
|
1054
|
+
if (aIsCustom !== bIsCustom) {
|
|
1055
|
+
// place custom entries after non-custom ones
|
|
1056
|
+
return aIsCustom ? 1 : -1;
|
|
1057
|
+
}
|
|
1058
|
+
// same kind: sort by label/value
|
|
1059
|
+
return a.value.localeCompare(b.value);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
onEntryChange(entry) {
|
|
1063
|
+
// check if already present
|
|
1064
|
+
if (this.entries().some((e) => e.id === entry.id)) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// check if limit is reached
|
|
1068
|
+
if (this.maxEntries() && this.entries().length >= this.maxEntries()) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const entries = [...this.entries()];
|
|
1072
|
+
entries.push(entry);
|
|
1073
|
+
if (this.autoSort()) {
|
|
1074
|
+
this.sortEntries(entries);
|
|
1075
|
+
}
|
|
1076
|
+
this.entries.set(entries);
|
|
1077
|
+
}
|
|
1078
|
+
addCustomEntry() {
|
|
1079
|
+
if (this.form.invalid) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
// create the custom entry
|
|
1083
|
+
const customEntry = {
|
|
1084
|
+
id: CUSTOM_ENTRY_PREFIX + this.id.value,
|
|
1085
|
+
value: this.value.value,
|
|
1086
|
+
};
|
|
1087
|
+
// check if already present
|
|
1088
|
+
if (this.entries().some((e) => e.id === customEntry.id)) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
// check if limit is reached
|
|
1092
|
+
if (this.maxEntries() && this.entries().length >= this.maxEntries()) {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const entries = [...this.entries()];
|
|
1096
|
+
entries.push(customEntry);
|
|
1097
|
+
if (this.autoSort()) {
|
|
1098
|
+
this.sortEntries(entries);
|
|
1099
|
+
}
|
|
1100
|
+
this.entries.set(entries);
|
|
1101
|
+
this.form.reset();
|
|
1102
|
+
}
|
|
1103
|
+
removeEntry(entry) {
|
|
1104
|
+
const entries = [...this.entries().filter((e) => e.id !== entry.id)];
|
|
1105
|
+
this.entries.set(entries);
|
|
1106
|
+
}
|
|
1107
|
+
clear() {
|
|
1108
|
+
this.entries.set([]);
|
|
1109
|
+
// if min > 0, expand the picker
|
|
1110
|
+
if (this.minEntries() > 0 && !this.expanded()) {
|
|
1111
|
+
setTimeout(() => {
|
|
1112
|
+
this.expanded.set(true);
|
|
1113
|
+
}, 0);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
onDrop(event) {
|
|
1117
|
+
const entries = [...this.entries()];
|
|
1118
|
+
moveItemInArray(entries, event.previousIndex, event.currentIndex);
|
|
1119
|
+
this.entries.set(entries);
|
|
1120
|
+
}
|
|
1121
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusEntriesPickerComponent, deps: [{ token: i1.FormBuilder }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1122
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: ThesaurusEntriesPickerComponent, isStandalone: true, selector: "cadmus-thesaurus-entries-picker", inputs: { availableEntries: { classPropertyName: "availableEntries", publicName: "availableEntries", isSignal: true, isRequired: true, transformFunction: null }, entries: { classPropertyName: "entries", publicName: "entries", isSignal: true, isRequired: false, transformFunction: null }, hierarchicLabels: { classPropertyName: "hierarchicLabels", publicName: "hierarchicLabels", isSignal: true, isRequired: false, transformFunction: null }, autoSort: { classPropertyName: "autoSort", publicName: "autoSort", isSignal: true, isRequired: false, transformFunction: null }, allowCustom: { classPropertyName: "allowCustom", publicName: "allowCustom", isSignal: true, isRequired: false, transformFunction: null }, minEntries: { classPropertyName: "minEntries", publicName: "minEntries", isSignal: true, isRequired: false, transformFunction: null }, maxEntries: { classPropertyName: "maxEntries", publicName: "maxEntries", isSignal: true, isRequired: false, transformFunction: null }, expanded: { classPropertyName: "expanded", publicName: "expanded", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { entries: "entriesChange", expanded: "expandedChange" }, ngImport: i0, template: "<div id=\"container\">\r\n <!-- picked entries -->\r\n <div id=\"picked\" class=\"form-row\">\r\n <!-- count -->\r\n <span class=\"nr\" [class.error]=\"entries().length < minEntries()\">{{\r\n entries().length\r\n }}</span>\r\n <!-- max-->\r\n @if (maxEntries() > 1) {\r\n <span class=\"muted\">/{{ maxEntries() }}</span>\r\n @if (remaining()) {\r\n <span class=\"muted\">: -{{ remaining() }}</span>\r\n } }\r\n <!-- min -->\r\n @if (minEntries()) {\r\n <!-- min with error -->\r\n @if (entries().length < minEntries()) {\r\n <span class=\"error\"> (min {{ minEntries() }})</span>\r\n }\r\n <!-- min without error -->\r\n @else {\r\n <span class=\"muted\"> (min {{ minEntries() }})</span>\r\n } }\r\n <!-- list -->\r\n <mat-chip-listbox\r\n [cdkDropListDisabled]=\"autoSort()\"\r\n cdkDropList\r\n cdkDropListOrientation=\"horizontal\"\r\n (cdkDropListDropped)=\"onDrop($event)\"\r\n class=\"chip-list\"\r\n >\r\n @for (e of entries(); track e.id; let idx = $index) {\r\n <div class=\"chip-wrapper\">\r\n <mat-chip-option\r\n [cdkDragDisabled]=\"autoSort()\"\r\n cdkDrag\r\n [class.error]=\"!allowCustom() && e.id.startsWith('$')\"\r\n >\r\n {{ renderLabel(e.value) }}\r\n <button\r\n type=\"button\"\r\n matChipRemove\r\n (click)=\"removeEntry(e)\"\r\n aria-label=\"Remove entry\"\r\n >\r\n <mat-icon class=\"mat-warn\">cancel</mat-icon>\r\n </button>\r\n </mat-chip-option>\r\n </div>\r\n } @empty {\r\n <span class=\"muted empty-message\">{{ emptyMessage() }}</span>\r\n }\r\n </mat-chip-listbox>\r\n\r\n <!-- clear -->\r\n @if (entries().length > 0) {\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Remove all entries\"\r\n (click)=\"clear()\"\r\n [disabled]=\"entries().length === 0\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n }\r\n <!-- toggler -->\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Toggle picker\"\r\n (click)=\"expanded.set(!expanded())\"\r\n >\r\n @if (expanded()) {\r\n <mat-icon class=\"mat-primary\">publish</mat-icon>\r\n } @if (!expanded()) {\r\n <mat-icon class=\"mat-primary\">get_app</mat-icon>\r\n }\r\n </button>\r\n </div>\r\n\r\n <!-- entries picker -->\r\n <div id=\"picker\">\r\n <mat-expansion-panel\r\n [expanded]=\"expanded()\"\r\n (expandedChange)=\"expanded.set($event)\"\r\n >\r\n <div>\r\n <fieldset>\r\n <legend>available</legend>\r\n <cadmus-thesaurus-tree\r\n [entries]=\"availableEntries()\"\r\n [renderLabel]=\"renderLabel\"\r\n (entryChange)=\"onEntryChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n @if (allowCustom()) {\r\n <div>\r\n <form [formGroup]=\"form\" (submit)=\"addCustomEntry()\">\r\n <fieldset>\r\n <legend>custom</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"id\"\r\n placeholder=\"ID\"\r\n />\r\n </mat-form-field>\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"value\"\r\n placeholder=\"label\"\r\n />\r\n </mat-form-field>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Add custom entry\"\r\n [disabled]=\"\r\n !form.valid ||\r\n (maxEntries() > 0 && entries().length >= maxEntries())\r\n \"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n </form>\r\n </div>\r\n }\r\n </mat-expansion-panel>\r\n </div>\r\n</div>\r\n", styles: ["#container{width:100%}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.error{color:#8b0000}.nr{font-weight:700}.muted{color:gray}fieldset{border:1px solid silver;border-radius:8px;padding:8px 16px}.chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:4px}.chip-wrapper{display:inline-flex;position:relative;margin:2px}.chip-list mat-chip-option:not([cdkDragDisabled]){cursor:grab}.chip-list mat-chip-option:not([cdkDragDisabled]):active{cursor:grabbing}.chip-list mat-chip-option[cdkDragDisabled]{cursor:default}.chip-list .empty-message{margin-left:0}.chip-list mat-chip-option.mat-mdc-chip-selected{background-color:inherit}.chip-list mat-chip-option:not(.error).mat-mdc-chip-selected{background-color:#e0e0e0}mat-chip-option.error{background-color:#ffebee!important;border:1px solid darkred}.cdk-drag-preview{opacity:.95;box-shadow:0 8px 20px #0006;transform:rotate(3deg)}.drag-placeholder{width:4px;background:#1976d2;border-radius:2px;height:36px;box-shadow:0 0 8px #1976d299;animation:pulse .5s ease-in-out infinite}@keyframes pulse{0%,to{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.1)}}.chip-list .cdk-drag-placeholder{opacity:0;width:4px;min-width:4px}.chip-wrapper{transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper{will-change:transform}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer", "cdkDropListHasAnchor"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "component", type: MatChipListbox, selector: "mat-chip-listbox", inputs: ["multiple", "aria-orientation", "selectable", "compareWith", "required", "hideSingleSelectionIndicator", "value"], outputs: ["change"] }, { kind: "component", type: MatChipOption, selector: "mat-basic-chip-option, [mat-basic-chip-option], mat-chip-option, [mat-chip-option]", inputs: ["selectable", "selected"], outputs: ["selectionChange"] }, { kind: "directive", type: MatChipRemove, selector: "[matChipRemove]" }, { kind: "component", type: MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ThesaurusTreeComponent, selector: "cadmus-thesaurus-tree", inputs: ["entries", "renderLabel"], outputs: ["entryChange"] }] }); }
|
|
1123
|
+
}
|
|
1124
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: ThesaurusEntriesPickerComponent, decorators: [{
|
|
1125
|
+
type: Component,
|
|
1126
|
+
args: [{ selector: 'cadmus-thesaurus-entries-picker', imports: [
|
|
1127
|
+
ReactiveFormsModule,
|
|
1128
|
+
CdkDropList,
|
|
1129
|
+
CdkDrag,
|
|
1130
|
+
MatChipListbox,
|
|
1131
|
+
MatChipOption,
|
|
1132
|
+
MatChipRemove,
|
|
1133
|
+
MatExpansionPanel,
|
|
1134
|
+
MatFormField,
|
|
1135
|
+
MatIconButton,
|
|
1136
|
+
MatIcon,
|
|
1137
|
+
MatInput,
|
|
1138
|
+
MatTooltip,
|
|
1139
|
+
ThesaurusTreeComponent,
|
|
1140
|
+
], template: "<div id=\"container\">\r\n <!-- picked entries -->\r\n <div id=\"picked\" class=\"form-row\">\r\n <!-- count -->\r\n <span class=\"nr\" [class.error]=\"entries().length < minEntries()\">{{\r\n entries().length\r\n }}</span>\r\n <!-- max-->\r\n @if (maxEntries() > 1) {\r\n <span class=\"muted\">/{{ maxEntries() }}</span>\r\n @if (remaining()) {\r\n <span class=\"muted\">: -{{ remaining() }}</span>\r\n } }\r\n <!-- min -->\r\n @if (minEntries()) {\r\n <!-- min with error -->\r\n @if (entries().length < minEntries()) {\r\n <span class=\"error\"> (min {{ minEntries() }})</span>\r\n }\r\n <!-- min without error -->\r\n @else {\r\n <span class=\"muted\"> (min {{ minEntries() }})</span>\r\n } }\r\n <!-- list -->\r\n <mat-chip-listbox\r\n [cdkDropListDisabled]=\"autoSort()\"\r\n cdkDropList\r\n cdkDropListOrientation=\"horizontal\"\r\n (cdkDropListDropped)=\"onDrop($event)\"\r\n class=\"chip-list\"\r\n >\r\n @for (e of entries(); track e.id; let idx = $index) {\r\n <div class=\"chip-wrapper\">\r\n <mat-chip-option\r\n [cdkDragDisabled]=\"autoSort()\"\r\n cdkDrag\r\n [class.error]=\"!allowCustom() && e.id.startsWith('$')\"\r\n >\r\n {{ renderLabel(e.value) }}\r\n <button\r\n type=\"button\"\r\n matChipRemove\r\n (click)=\"removeEntry(e)\"\r\n aria-label=\"Remove entry\"\r\n >\r\n <mat-icon class=\"mat-warn\">cancel</mat-icon>\r\n </button>\r\n </mat-chip-option>\r\n </div>\r\n } @empty {\r\n <span class=\"muted empty-message\">{{ emptyMessage() }}</span>\r\n }\r\n </mat-chip-listbox>\r\n\r\n <!-- clear -->\r\n @if (entries().length > 0) {\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Remove all entries\"\r\n (click)=\"clear()\"\r\n [disabled]=\"entries().length === 0\"\r\n >\r\n <mat-icon class=\"mat-warn\">clear</mat-icon>\r\n </button>\r\n }\r\n <!-- toggler -->\r\n <button\r\n type=\"button\"\r\n mat-icon-button\r\n matTooltip=\"Toggle picker\"\r\n (click)=\"expanded.set(!expanded())\"\r\n >\r\n @if (expanded()) {\r\n <mat-icon class=\"mat-primary\">publish</mat-icon>\r\n } @if (!expanded()) {\r\n <mat-icon class=\"mat-primary\">get_app</mat-icon>\r\n }\r\n </button>\r\n </div>\r\n\r\n <!-- entries picker -->\r\n <div id=\"picker\">\r\n <mat-expansion-panel\r\n [expanded]=\"expanded()\"\r\n (expandedChange)=\"expanded.set($event)\"\r\n >\r\n <div>\r\n <fieldset>\r\n <legend>available</legend>\r\n <cadmus-thesaurus-tree\r\n [entries]=\"availableEntries()\"\r\n [renderLabel]=\"renderLabel\"\r\n (entryChange)=\"onEntryChange($event)\"\r\n />\r\n </fieldset>\r\n </div>\r\n @if (allowCustom()) {\r\n <div>\r\n <form [formGroup]=\"form\" (submit)=\"addCustomEntry()\">\r\n <fieldset>\r\n <legend>custom</legend>\r\n <div class=\"form-row\">\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"id\"\r\n placeholder=\"ID\"\r\n />\r\n </mat-form-field>\r\n <mat-form-field>\r\n <input\r\n matInput\r\n type=\"text\"\r\n [formControl]=\"value\"\r\n placeholder=\"label\"\r\n />\r\n </mat-form-field>\r\n <button\r\n type=\"submit\"\r\n mat-icon-button\r\n matTooltip=\"Add custom entry\"\r\n [disabled]=\"\r\n !form.valid ||\r\n (maxEntries() > 0 && entries().length >= maxEntries())\r\n \"\r\n >\r\n <mat-icon class=\"mat-primary\">check_circle</mat-icon>\r\n </button>\r\n </div>\r\n </fieldset>\r\n </form>\r\n </div>\r\n }\r\n </mat-expansion-panel>\r\n </div>\r\n</div>\r\n", styles: ["#container{width:100%}.form-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.form-row *{flex:0 0 auto}.error{color:#8b0000}.nr{font-weight:700}.muted{color:gray}fieldset{border:1px solid silver;border-radius:8px;padding:8px 16px}.chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:4px}.chip-wrapper{display:inline-flex;position:relative;margin:2px}.chip-list mat-chip-option:not([cdkDragDisabled]){cursor:grab}.chip-list mat-chip-option:not([cdkDragDisabled]):active{cursor:grabbing}.chip-list mat-chip-option[cdkDragDisabled]{cursor:default}.chip-list .empty-message{margin-left:0}.chip-list mat-chip-option.mat-mdc-chip-selected{background-color:inherit}.chip-list mat-chip-option:not(.error).mat-mdc-chip-selected{background-color:#e0e0e0}mat-chip-option.error{background-color:#ffebee!important;border:1px solid darkred}.cdk-drag-preview{opacity:.95;box-shadow:0 8px 20px #0006;transform:rotate(3deg)}.drag-placeholder{width:4px;background:#1976d2;border-radius:2px;height:36px;box-shadow:0 0 8px #1976d299;animation:pulse .5s ease-in-out infinite}@keyframes pulse{0%,to{opacity:.6;transform:scaleY(1)}50%{opacity:1;transform:scaleY(1.1)}}.chip-list .cdk-drag-placeholder{opacity:0;width:4px;min-width:4px}.chip-wrapper{transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper:not(.cdk-drag-placeholder){transition:transform .25s cubic-bezier(0,0,.2,1)}.cdk-drop-list-dragging .chip-wrapper{will-change:transform}\n"] }]
|
|
1141
|
+
}], ctorParameters: () => [{ type: i1.FormBuilder }], propDecorators: { availableEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableEntries", required: true }] }], entries: [{ type: i0.Input, args: [{ isSignal: true, alias: "entries", required: false }] }, { type: i0.Output, args: ["entriesChange"] }], hierarchicLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "hierarchicLabels", required: false }] }], autoSort: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoSort", required: false }] }], allowCustom: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustom", required: false }] }], minEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "minEntries", required: false }] }], maxEntries: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxEntries", required: false }] }], expanded: [{ type: i0.Input, args: [{ isSignal: true, alias: "expanded", required: false }] }, { type: i0.Output, args: ["expandedChange"] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }] } });
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Editable version of the static thesaurus paged tree store service.
|
|
1145
|
+
* This service allows editing operations on the flat ThesaurusEntry list
|
|
1146
|
+
* while maintaining the tree structure implied by the hierarchical IDs.
|
|
1147
|
+
*/
|
|
1148
|
+
class EditableStaticThesaurusPagedTreeStoreService extends EditablePagedTreeStoreServiceBase {
|
|
1149
|
+
constructor(entries, renderLabel) {
|
|
1150
|
+
super();
|
|
1151
|
+
this._entries = [...entries];
|
|
1152
|
+
this._renderLabel = renderLabel;
|
|
1153
|
+
this._staticService = new StaticThesaurusPagedTreeStoreService(this._entries, renderLabel);
|
|
1154
|
+
}
|
|
1155
|
+
fetchNodes(filter, pageNumber, pageSize, hasMockRoot) {
|
|
1156
|
+
// update the static service with current entries and delegate,
|
|
1157
|
+
// preserving the renderLabel function
|
|
1158
|
+
this._staticService = new StaticThesaurusPagedTreeStoreService(this._entries, this._renderLabel);
|
|
1159
|
+
return this._staticService.getNodes(filter, pageNumber, pageSize, hasMockRoot);
|
|
1160
|
+
}
|
|
1161
|
+
persistChanges(changes) {
|
|
1162
|
+
console.log('=== SAVING CHANGES ===');
|
|
1163
|
+
console.log('Changes to persist:', changes);
|
|
1164
|
+
const idMap = new Map();
|
|
1165
|
+
// process changes to update the flat entries list
|
|
1166
|
+
for (const change of changes) {
|
|
1167
|
+
switch (change.type) {
|
|
1168
|
+
case ChangeOperationType.ADD:
|
|
1169
|
+
if (change.node) {
|
|
1170
|
+
const newEntry = this.nodeToEntry(change.node);
|
|
1171
|
+
if (newEntry) {
|
|
1172
|
+
const existingIndex = this._entries.findIndex((e) => e.id === newEntry.id);
|
|
1173
|
+
if (existingIndex === -1) {
|
|
1174
|
+
this._entries.push(newEntry);
|
|
1175
|
+
// map temporary ID to the new permanent ID (array index + 1)
|
|
1176
|
+
if (change.node.id < 0) {
|
|
1177
|
+
const permanentId = this._entries.length;
|
|
1178
|
+
idMap.set(change.node.id, permanentId);
|
|
1179
|
+
}
|
|
1180
|
+
console.log('Added entry:', newEntry);
|
|
1181
|
+
}
|
|
1182
|
+
else {
|
|
1183
|
+
console.log('Entry already exists, skipping:', newEntry.id);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
break;
|
|
1188
|
+
case ChangeOperationType.REMOVE:
|
|
1189
|
+
if (change.originalNode) {
|
|
1190
|
+
// use the 'key' property which contains the ThesaurusEntry string ID
|
|
1191
|
+
const nodeWithKey = change.originalNode;
|
|
1192
|
+
const entryKey = nodeWithKey.key;
|
|
1193
|
+
if (entryKey) {
|
|
1194
|
+
this.removeEntryAndDescendants(entryKey);
|
|
1195
|
+
console.log('Removed entry and descendants:', entryKey);
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
console.warn('Node missing key property:', change.originalNode);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
else if (change.id < 0) {
|
|
1202
|
+
console.log('Removed temporary node that was never persisted:', change.id);
|
|
1203
|
+
}
|
|
1204
|
+
else {
|
|
1205
|
+
console.warn('Remove operation missing originalNode for ID:', change.id);
|
|
1206
|
+
}
|
|
1207
|
+
break;
|
|
1208
|
+
case ChangeOperationType.UPDATE:
|
|
1209
|
+
if (change.node && change.originalNode) {
|
|
1210
|
+
const nodeWithKey = change.originalNode;
|
|
1211
|
+
const entryKey = nodeWithKey.key;
|
|
1212
|
+
if (entryKey) {
|
|
1213
|
+
const entryToUpdate = this._entries.find((e) => e.id === entryKey);
|
|
1214
|
+
if (entryToUpdate) {
|
|
1215
|
+
const updatedEntry = this.nodeToEntry(change.node);
|
|
1216
|
+
if (updatedEntry) {
|
|
1217
|
+
// preserve the original hierarchical ID
|
|
1218
|
+
updatedEntry.id = entryToUpdate.id;
|
|
1219
|
+
const index = this._entries.findIndex((e) => e.id === entryToUpdate.id);
|
|
1220
|
+
if (index !== -1) {
|
|
1221
|
+
this._entries[index] = updatedEntry;
|
|
1222
|
+
console.log('Updated entry:', updatedEntry);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
console.warn('Could not find entry to update with key:', entryKey);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
else {
|
|
1231
|
+
console.warn('Update node missing key property:', change.originalNode);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
else if (change.id < 0) {
|
|
1235
|
+
console.log('Updated temporary node:', change.id);
|
|
1236
|
+
}
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
// Validate no duplicate IDs
|
|
1241
|
+
const ids = this._entries.map((e) => e.id);
|
|
1242
|
+
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
1243
|
+
if (duplicates.length > 0) {
|
|
1244
|
+
console.error('Duplicate IDs found:', duplicates);
|
|
1245
|
+
}
|
|
1246
|
+
console.log('Final entries after changes:', this._entries);
|
|
1247
|
+
console.log('ID mappings:', idMap);
|
|
1248
|
+
console.log('=== END SAVING CHANGES ===');
|
|
1249
|
+
return of(idMap);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Convert a TreeNode back to a ThesaurusEntry.
|
|
1253
|
+
*/
|
|
1254
|
+
nodeToEntry(node) {
|
|
1255
|
+
// For nodes with the key property, use it directly
|
|
1256
|
+
const nodeWithKey = node;
|
|
1257
|
+
if (nodeWithKey.key) {
|
|
1258
|
+
return {
|
|
1259
|
+
id: nodeWithKey.key,
|
|
1260
|
+
value: node.label,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
// For temporary nodes (negative IDs), construct a hierarchical ID
|
|
1264
|
+
if (node.id < 0) {
|
|
1265
|
+
// Check for user-provided ID
|
|
1266
|
+
const userProvidedId = nodeWithKey._userProvidedId;
|
|
1267
|
+
if (userProvidedId) {
|
|
1268
|
+
return {
|
|
1269
|
+
id: userProvidedId,
|
|
1270
|
+
value: node.label,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
// Find parent to construct the ID
|
|
1274
|
+
if (node.parentId) {
|
|
1275
|
+
const parentNode = this._nodes.get(node.parentId);
|
|
1276
|
+
const parentKey = parentNode?.key;
|
|
1277
|
+
if (parentKey) {
|
|
1278
|
+
return {
|
|
1279
|
+
id: `${parentKey}.temp${Math.abs(node.id)}`,
|
|
1280
|
+
value: node.label,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
// No parent, create a root-level temporary entry
|
|
1285
|
+
return {
|
|
1286
|
+
id: `temp${Math.abs(node.id)}`,
|
|
1287
|
+
value: node.label,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
console.warn('Could not convert node to entry:', node);
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Remove an entry and all its descendants from the entries list.
|
|
1295
|
+
*/
|
|
1296
|
+
removeEntryAndDescendants(entryId) {
|
|
1297
|
+
const toRemove = [];
|
|
1298
|
+
// find the entry and all its descendants
|
|
1299
|
+
for (const entry of this._entries) {
|
|
1300
|
+
if (entry.id === entryId || entry.id.startsWith(entryId + '.')) {
|
|
1301
|
+
toRemove.push(entry.id);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
// remove them from the list
|
|
1305
|
+
for (const id of toRemove) {
|
|
1306
|
+
const index = this._entries.findIndex((e) => e.id === id);
|
|
1307
|
+
if (index !== -1) {
|
|
1308
|
+
this._entries.splice(index, 1);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Get current entries (for demonstration purposes).
|
|
1314
|
+
*/
|
|
1315
|
+
getCurrentEntries() {
|
|
1316
|
+
return [...this._entries];
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/*
|
|
1321
|
+
* Public API Surface of cadmus-thesaurus-store
|
|
1322
|
+
*/
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Generated bundle index. Do not edit.
|
|
1326
|
+
*/
|
|
1327
|
+
|
|
1328
|
+
export { CUSTOM_ENTRY_PREFIX, EditableStaticThesaurusPagedTreeStoreService, EditableThesaurusBrowserComponent, StaticThesaurusPagedTreeStoreService, ThesEntryEditorComponent, ThesaurusBrowserComponent, ThesaurusEntriesPickerComponent, ThesaurusPagedTreeFilterComponent, ThesaurusTreeComponent, renderLabelFromLastColon };
|
|
1329
|
+
//# sourceMappingURL=myrmidon-cadmus-thesaurus-store.mjs.map
|