angular-my-report 0.1.0 → 0.1.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.
- package/README.md +124 -40
- package/fesm2022/angular-my-report.mjs +7931 -0
- package/fesm2022/angular-my-report.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/engine/barcode.d.ts +17 -0
- package/lib/engine/chart.d.ts +26 -0
- package/lib/engine/chartLayout.d.ts +45 -0
- package/lib/engine/crosstab.d.ts +22 -0
- package/lib/engine/dataSources.d.ts +35 -0
- package/lib/engine/expressions.d.ts +46 -0
- package/lib/engine/measure.d.ts +7 -0
- package/lib/engine/parameters.d.ts +15 -0
- package/lib/engine/qr.d.ts +8 -0
- package/lib/engine/renderer.d.ts +43 -0
- package/lib/engine/table.d.ts +22 -0
- package/lib/model/defaults.d.ts +31 -0
- package/lib/model/factories.d.ts +31 -0
- package/lib/model/types.d.ts +244 -0
- package/lib/ui/css.d.ts +2 -0
- package/lib/ui/designer/banded-table-dialog.component.d.ts +27 -0
- package/lib/ui/designer/canvas.component.d.ts +41 -0
- package/lib/ui/designer/data-source-dialog.component.d.ts +37 -0
- package/lib/ui/designer/element-box.component.d.ts +32 -0
- package/lib/ui/designer/graphics.components.d.ts +36 -0
- package/lib/ui/designer/left-panel.component.d.ts +37 -0
- package/lib/ui/designer/safe-image.pipe.d.ts +13 -0
- package/lib/ui/designer/table-view.component.d.ts +19 -0
- package/lib/ui/designer.component.d.ts +59 -0
- package/lib/ui/designer.store.d.ts +82 -0
- package/lib/ui/inputs.d.ts +8 -0
- package/lib/ui/properties/band-props.component.d.ts +15 -0
- package/lib/ui/properties/element-props.component.d.ts +46 -0
- package/lib/ui/properties/expr-input.component.d.ts +43 -0
- package/lib/ui/properties/graphics-props.component.d.ts +52 -0
- package/lib/ui/properties/image-picker.component.d.ts +15 -0
- package/lib/ui/properties/properties-panel.component.d.ts +13 -0
- package/lib/ui/properties/report-extras.component.d.ts +40 -0
- package/lib/ui/properties/report-props.component.d.ts +34 -0
- package/lib/ui/properties/table-props.component.d.ts +31 -0
- package/lib/ui/viewer/page-view.component.d.ts +17 -0
- package/lib/ui/viewer/render.d.ts +43 -0
- package/lib/ui/viewer/viewer.component.d.ts +99 -0
- package/lib/ui/viewer.component.d.ts +40 -0
- package/package.json +16 -5
- package/{src/public-api.ts → public-api.d.ts} +0 -5
- package/ng-package.json +0 -7
- package/src/lib/engine/barcode.ts +0 -118
- package/src/lib/engine/chart.ts +0 -85
- package/src/lib/engine/chartLayout.ts +0 -265
- package/src/lib/engine/crosstab.ts +0 -96
- package/src/lib/engine/dataSources.ts +0 -204
- package/src/lib/engine/expressions.ts +0 -734
- package/src/lib/engine/measure.ts +0 -62
- package/src/lib/engine/parameters.ts +0 -96
- package/src/lib/engine/qr.ts +0 -26
- package/src/lib/engine/renderer.ts +0 -324
- package/src/lib/engine/table.ts +0 -58
- package/src/lib/model/defaults.ts +0 -249
- package/src/lib/model/factories.ts +0 -324
- package/src/lib/model/types.ts +0 -281
- package/src/lib/ui/css.ts +0 -12
- package/src/lib/ui/designer/banded-table-dialog.component.ts +0 -127
- package/src/lib/ui/designer/canvas.component.ts +0 -281
- package/src/lib/ui/designer/data-source-dialog.component.ts +0 -201
- package/src/lib/ui/designer/element-box.component.ts +0 -138
- package/src/lib/ui/designer/graphics.components.ts +0 -140
- package/src/lib/ui/designer/left-panel.component.ts +0 -189
- package/src/lib/ui/designer/safe-image.pipe.ts +0 -16
- package/src/lib/ui/designer/table-view.component.ts +0 -120
- package/src/lib/ui/designer.component.ts +0 -296
- package/src/lib/ui/designer.store.ts +0 -230
- package/src/lib/ui/inputs.ts +0 -34
- package/src/lib/ui/myreport.css +0 -267
- package/src/lib/ui/properties/band-props.component.ts +0 -86
- package/src/lib/ui/properties/element-props.component.ts +0 -326
- package/src/lib/ui/properties/expr-input.component.ts +0 -191
- package/src/lib/ui/properties/graphics-props.component.ts +0 -260
- package/src/lib/ui/properties/image-picker.component.ts +0 -62
- package/src/lib/ui/properties/properties-panel.component.ts +0 -37
- package/src/lib/ui/properties/report-extras.component.ts +0 -198
- package/src/lib/ui/properties/report-props.component.ts +0 -211
- package/src/lib/ui/properties/table-props.component.ts +0 -197
- package/src/lib/ui/viewer/page-view.component.ts +0 -73
- package/src/lib/ui/viewer/render.ts +0 -161
- package/src/lib/ui/viewer/viewer.component.ts +0 -555
- package/src/lib/ui/viewer.component.ts +0 -107
- package/tsconfig.lib.json +0 -12
- package/tsconfig.lib.prod.json +0 -5
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ChangeDetectionStrategy,
|
|
3
|
-
Component,
|
|
4
|
-
ElementRef,
|
|
5
|
-
HostListener,
|
|
6
|
-
ViewEncapsulation,
|
|
7
|
-
booleanAttribute,
|
|
8
|
-
effect,
|
|
9
|
-
inject,
|
|
10
|
-
input,
|
|
11
|
-
output,
|
|
12
|
-
signal,
|
|
13
|
-
untracked,
|
|
14
|
-
} from '@angular/core';
|
|
15
|
-
import { FormsModule } from '@angular/forms';
|
|
16
|
-
import type { DataSourceType, ReportDefinition, ReportElement } from '../model/types';
|
|
17
|
-
import { receiptReport } from '../model/factories';
|
|
18
|
-
import { newReport, uid } from '../model/defaults';
|
|
19
|
-
import { DesignerStore, fetchReport, validateReport } from './designer.store';
|
|
20
|
-
import { CanvasComponent } from './designer/canvas.component';
|
|
21
|
-
import { LeftPanelComponent } from './designer/left-panel.component';
|
|
22
|
-
import { DataSourceDialogComponent } from './designer/data-source-dialog.component';
|
|
23
|
-
import { ViewerComponent } from './viewer/viewer.component';
|
|
24
|
-
import { BandedTableDialogComponent } from './designer/banded-table-dialog.component';
|
|
25
|
-
import { PropertiesPanelComponent } from './properties/properties-panel.component';
|
|
26
|
-
import { parseParams, parseReport } from './inputs';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The report designer.
|
|
30
|
-
*
|
|
31
|
-
* ```html
|
|
32
|
-
* <myreport-designer path="/reports/sales.report.json" (save)="store($event)" />
|
|
33
|
-
* ```
|
|
34
|
-
*/
|
|
35
|
-
@Component({
|
|
36
|
-
selector: 'myreport-designer',
|
|
37
|
-
standalone: true,
|
|
38
|
-
imports: [FormsModule, CanvasComponent, LeftPanelComponent, PropertiesPanelComponent, DataSourceDialogComponent, ViewerComponent, BandedTableDialogComponent],
|
|
39
|
-
providers: [DesignerStore],
|
|
40
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
41
|
-
encapsulation: ViewEncapsulation.None,
|
|
42
|
-
styleUrl: './myreport.css',
|
|
43
|
-
// A class binding (not a static host class) so classes the host page set on the element are kept.
|
|
44
|
-
host: { '[class.myreport]': 'true' },
|
|
45
|
-
template: `
|
|
46
|
-
<div class="app">
|
|
47
|
-
<header class="toolbar">
|
|
48
|
-
<span class="brand">{{ brand() }}</span>
|
|
49
|
-
<select class="new-menu" title="New report from a template" [ngModel]="''" (ngModelChange)="newFrom($event)">
|
|
50
|
-
<option value="" disabled>New ▾</option>
|
|
51
|
-
<option value="blank">Blank report</option>
|
|
52
|
-
<option value="receipt80">Receipt — 80 mm roll</option>
|
|
53
|
-
<option value="receipt58">Receipt — 58 mm roll</option>
|
|
54
|
-
</select>
|
|
55
|
-
<button (click)="fileInput.click()">Open…</button>
|
|
56
|
-
<button (click)="saveReport()">Save</button>
|
|
57
|
-
<input #fileInput type="file" accept=".json,application/json" hidden (change)="open($event)" />
|
|
58
|
-
<span class="sep"></span>
|
|
59
|
-
<button [disabled]="!store.canUndo()" title="Undo (Ctrl+Z)" (click)="store.undo()">↶ Undo</button>
|
|
60
|
-
<button [disabled]="!store.canRedo()" title="Redo (Ctrl+Y)" (click)="store.redo()">↷ Redo</button>
|
|
61
|
-
<span class="sep"></span>
|
|
62
|
-
<button (click)="showData.set(true)">Data source…</button>
|
|
63
|
-
<span class="sep"></span>
|
|
64
|
-
<label class="check">
|
|
65
|
-
<input type="checkbox" [ngModel]="store.snap()" (ngModelChange)="store.snap.set($event)" /> Snap
|
|
66
|
-
</label>
|
|
67
|
-
<select title="Zoom" [ngModel]="store.zoom()" (ngModelChange)="store.zoom.set(+$event)">
|
|
68
|
-
@for (z of zooms; track z) {
|
|
69
|
-
<option [value]="z">{{ z * 100 }}%</option>
|
|
70
|
-
}
|
|
71
|
-
</select>
|
|
72
|
-
<span class="spacer"></span>
|
|
73
|
-
@if (status()) {
|
|
74
|
-
<span class="muted" [class.error]="status()!.error">{{ status()!.text }}</span>
|
|
75
|
-
}
|
|
76
|
-
@if (viewerUrl()) {
|
|
77
|
-
<a class="btn-link" [href]="viewerUrl()" target="_blank" title="Open the report in the full viewer (new tab)">Viewer ↗</a>
|
|
78
|
-
}
|
|
79
|
-
<button class="primary" (click)="showPreview.set(true)">▶ Preview</button>
|
|
80
|
-
</header>
|
|
81
|
-
<main class="workspace">
|
|
82
|
-
<rpt-left-panel (openDataSource)="showData.set(true)" (openBandedTable)="showBanded.set(true)" />
|
|
83
|
-
<rpt-canvas />
|
|
84
|
-
<rpt-properties-panel />
|
|
85
|
-
</main>
|
|
86
|
-
</div>
|
|
87
|
-
@if (showData()) {
|
|
88
|
-
<rpt-data-source-dialog (closed)="showData.set(false)" />
|
|
89
|
-
}
|
|
90
|
-
@if (showBanded()) {
|
|
91
|
-
<rpt-banded-table-dialog (closed)="showBanded.set(false)" />
|
|
92
|
-
}
|
|
93
|
-
@if (showPreview()) {
|
|
94
|
-
<rpt-viewer [embedded]="true" (closed)="showPreview.set(false)" />
|
|
95
|
-
}
|
|
96
|
-
`,
|
|
97
|
-
})
|
|
98
|
-
export class MyReportDesignerComponent {
|
|
99
|
-
protected readonly store = inject(DesignerStore);
|
|
100
|
-
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
101
|
-
|
|
102
|
-
/** URL of a .report.json to open. */
|
|
103
|
-
readonly path = input<string | null | undefined>(null);
|
|
104
|
-
/** A report definition to open (object or JSON string); used when `path` is empty. */
|
|
105
|
-
readonly report = input<ReportDefinition | string | null | undefined>(null);
|
|
106
|
-
/** Replaces the report's data source while designing: a URL, a JSON string, or the data itself. */
|
|
107
|
-
readonly datasource = input<unknown>(null);
|
|
108
|
-
/** How `datasource` is read: `json` (default) or `odata`. */
|
|
109
|
-
readonly type = input<DataSourceType | null | undefined>('json');
|
|
110
|
-
/** Parameter values (object, JSON string or query string `a=1&b=2`). */
|
|
111
|
-
readonly params = input<Record<string, unknown> | string | null | undefined>(null);
|
|
112
|
-
/** localStorage key to autosave the report to (and restore from when no path/report is given). */
|
|
113
|
-
readonly persistKey = input<string | null | undefined>(null);
|
|
114
|
-
/** Shows a "Viewer ↗" link in the toolbar pointing at this URL. */
|
|
115
|
-
readonly viewerUrl = input<string | null | undefined>(null);
|
|
116
|
-
/** Save downloads a .report.json file (default true). The `save` event fires either way. */
|
|
117
|
-
readonly download = input(true, { transform: booleanAttribute });
|
|
118
|
-
/** Toolbar title. */
|
|
119
|
-
readonly brand = input<string | null | undefined>('My Report');
|
|
120
|
-
|
|
121
|
-
/** Fires with the report when the user clicks Save. */
|
|
122
|
-
readonly save = output<ReportDefinition>();
|
|
123
|
-
/** Fires with the report after every change. */
|
|
124
|
-
readonly reportChange = output<ReportDefinition>();
|
|
125
|
-
/** Fires with the report after it was loaded from `path`. */
|
|
126
|
-
readonly loaded = output<ReportDefinition>();
|
|
127
|
-
/** Fires with a message when `path` could not be loaded. */
|
|
128
|
-
readonly loadError = output<string>();
|
|
129
|
-
|
|
130
|
-
protected readonly status = signal<{ text: string; error?: boolean } | null>(null);
|
|
131
|
-
protected readonly showData = signal(false);
|
|
132
|
-
protected readonly showPreview = signal(false);
|
|
133
|
-
protected readonly showBanded = signal(false);
|
|
134
|
-
protected readonly zooms = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
135
|
-
private clipboard: ReportElement[] = [];
|
|
136
|
-
|
|
137
|
-
constructor() {
|
|
138
|
-
// Open the report from `path`, else `report`, else the autosaved one, else a blank report.
|
|
139
|
-
effect((onCleanup) => {
|
|
140
|
-
const path = this.path()?.trim();
|
|
141
|
-
const report = this.report();
|
|
142
|
-
const key = this.persistKey()?.trim() || null;
|
|
143
|
-
untracked(() => {
|
|
144
|
-
this.store.persistKey.set(null);
|
|
145
|
-
if (path) {
|
|
146
|
-
const ac = new AbortController();
|
|
147
|
-
onCleanup(() => ac.abort());
|
|
148
|
-
this.status.set({ text: 'Loading report…' });
|
|
149
|
-
fetchReport(path, ac.signal)
|
|
150
|
-
.then((r) => {
|
|
151
|
-
this.store.load(r);
|
|
152
|
-
this.store.persistKey.set(key);
|
|
153
|
-
this.status.set(null);
|
|
154
|
-
this.loaded.emit(r);
|
|
155
|
-
})
|
|
156
|
-
.catch((e) => {
|
|
157
|
-
if (ac.signal.aborted) return;
|
|
158
|
-
const msg = `Could not load ${path}: ${(e as Error).message}`;
|
|
159
|
-
this.status.set({ text: msg, error: true });
|
|
160
|
-
this.loadError.emit(msg);
|
|
161
|
-
});
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
this.status.set(null);
|
|
165
|
-
const def = parseReport(report);
|
|
166
|
-
if (def) {
|
|
167
|
-
this.store.load(def);
|
|
168
|
-
this.store.persistKey.set(key);
|
|
169
|
-
} else if (!(key && this.store.restore(key))) {
|
|
170
|
-
this.store.load(newReport());
|
|
171
|
-
this.store.persistKey.set(key);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
effect(() => this.store.dataOverride.set(this.datasource() == null || this.datasource() === '' ? null : { source: this.datasource(), type: this.type() === 'odata' ? 'odata' : 'json' }));
|
|
177
|
-
effect(() => this.store.externalParams.set(parseParams(this.params())));
|
|
178
|
-
|
|
179
|
-
// Report changes → (reportChange)
|
|
180
|
-
let first = true;
|
|
181
|
-
effect(() => {
|
|
182
|
-
const r = this.store.report();
|
|
183
|
-
if (first) {
|
|
184
|
-
first = false;
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
untracked(() => this.reportChange.emit(r));
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Starts a new report from a template (the select resets itself to "New ▾"). */
|
|
192
|
-
protected newFrom(kind: string): void {
|
|
193
|
-
const make: Record<string, () => ReportDefinition> = {
|
|
194
|
-
blank: newReport,
|
|
195
|
-
receipt80: () => receiptReport(80),
|
|
196
|
-
receipt58: () => receiptReport(58),
|
|
197
|
-
};
|
|
198
|
-
if (make[kind] && confirm('Start a new report? Unsaved changes to the current one are lost (Save first if needed).')) {
|
|
199
|
-
this.store.load(make[kind]());
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
protected saveReport(): void {
|
|
204
|
-
const r = this.store.report();
|
|
205
|
-
this.save.emit(r);
|
|
206
|
-
if (!this.download()) return;
|
|
207
|
-
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
|
|
208
|
-
const a = document.createElement('a');
|
|
209
|
-
a.href = URL.createObjectURL(blob);
|
|
210
|
-
a.download = `${r.name.replace(/[^\w\- ]+/g, '').trim() || 'report'}.report.json`;
|
|
211
|
-
a.click();
|
|
212
|
-
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
protected async open(e: Event): Promise<void> {
|
|
216
|
-
const input = e.target as HTMLInputElement;
|
|
217
|
-
const file = input.files?.[0];
|
|
218
|
-
input.value = '';
|
|
219
|
-
if (!file) return;
|
|
220
|
-
try {
|
|
221
|
-
this.store.load(validateReport(JSON.parse(await file.text())));
|
|
222
|
-
} catch (err) {
|
|
223
|
-
alert('Could not open file: ' + (err as Error).message);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Keys belong to the designer only when focus is inside it (or nowhere in particular). */
|
|
228
|
-
private ownsKey(e: KeyboardEvent): boolean {
|
|
229
|
-
const t = e.target as HTMLElement | null;
|
|
230
|
-
return !t || t === document.body || t === document.documentElement || this.host.nativeElement.contains(t);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
@HostListener('window:keydown', ['$event'])
|
|
234
|
-
protected onKey(e: KeyboardEvent): void {
|
|
235
|
-
const target = e.target as HTMLElement;
|
|
236
|
-
if (!this.ownsKey(e)) return;
|
|
237
|
-
if (this.showData() || this.showPreview() || this.showBanded() || target.closest?.('input, textarea, select, [contenteditable]')) return;
|
|
238
|
-
const store = this.store;
|
|
239
|
-
const sel = store.selection();
|
|
240
|
-
const report = store.report();
|
|
241
|
-
const ids = sel.elementIds;
|
|
242
|
-
const ctrl = e.ctrlKey || e.metaKey;
|
|
243
|
-
const key = e.key.toLowerCase();
|
|
244
|
-
|
|
245
|
-
if (ctrl && key === 'z' && !e.shiftKey) {
|
|
246
|
-
e.preventDefault();
|
|
247
|
-
store.undo();
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (ctrl && (key === 'y' || (key === 'z' && e.shiftKey))) {
|
|
251
|
-
e.preventDefault();
|
|
252
|
-
store.redo();
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (ctrl && key === 'a' && sel.bandId) {
|
|
256
|
-
e.preventDefault();
|
|
257
|
-
const band = report.bands.find((b) => b.id === sel.bandId);
|
|
258
|
-
if (band) store.select({ elementIds: band.elements.map((x) => x.id), bandId: band.id });
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (!ids.length && !(ctrl && key === 'v')) return;
|
|
262
|
-
|
|
263
|
-
const selectedEls = () => report.bands.flatMap((b) => b.elements.filter((x) => ids.includes(x.id)));
|
|
264
|
-
|
|
265
|
-
if (key === 'delete' || key === 'backspace') {
|
|
266
|
-
e.preventDefault();
|
|
267
|
-
store.update((r) => r.bands.forEach((b) => (b.elements = b.elements.filter((x) => !ids.includes(x.id)))));
|
|
268
|
-
} else if (ctrl && key === 'c') {
|
|
269
|
-
this.clipboard = structuredClone(selectedEls());
|
|
270
|
-
} else if (ctrl && (key === 'v' || key === 'd')) {
|
|
271
|
-
e.preventDefault();
|
|
272
|
-
const source = key === 'd' ? selectedEls() : this.clipboard;
|
|
273
|
-
if (!source.length) return;
|
|
274
|
-
const bandId = sel.bandId ?? report.bands.find((b) => b.type === 'detail')?.id ?? report.bands[0].id;
|
|
275
|
-
const copies = source.map((x) => ({ ...structuredClone(x), id: uid('el'), x: x.x + 8, y: x.y + 8 }));
|
|
276
|
-
store.update((r) => r.bands.find((b) => b.id === bandId)!.elements.push(...copies));
|
|
277
|
-
store.select({ elementIds: copies.map((c) => c.id), bandId });
|
|
278
|
-
} else if (key.startsWith('arrow')) {
|
|
279
|
-
e.preventDefault();
|
|
280
|
-
const step = e.shiftKey ? 10 : store.snap() ? 8 : 1;
|
|
281
|
-
const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0;
|
|
282
|
-
const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0;
|
|
283
|
-
store.update((r) =>
|
|
284
|
-
r.bands.forEach((b) =>
|
|
285
|
-
b.elements.forEach((x) => {
|
|
286
|
-
if (!ids.includes(x.id)) return;
|
|
287
|
-
x.x = Math.max(0, x.x + dx);
|
|
288
|
-
x.y = Math.max(0, x.y + dy);
|
|
289
|
-
}),
|
|
290
|
-
),
|
|
291
|
-
);
|
|
292
|
-
} else if (key === 'escape') {
|
|
293
|
-
store.select({ elementIds: [], bandId: sel.bandId });
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { Injectable, computed, effect, signal, untracked } from '@angular/core';
|
|
2
|
-
import type { Band, DataSourceDef, DataSourceType, ReportDefinition, ReportElement } from '../model/types';
|
|
3
|
-
import { newReport } from '../model/defaults';
|
|
4
|
-
import { hasSource, loadData, type FieldInfo } from '../engine/dataSources';
|
|
5
|
-
import { documentFields, type Row } from '../engine/expressions';
|
|
6
|
-
import { applyParamsToDataSource, resolveParams } from '../engine/parameters';
|
|
7
|
-
|
|
8
|
-
export interface Selection {
|
|
9
|
-
elementIds: string[];
|
|
10
|
-
bandId: string | null;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface DataState {
|
|
14
|
-
rows: Row[];
|
|
15
|
-
/** The whole loaded document (root.x in formulas) and its scalar fields. */
|
|
16
|
-
root?: unknown;
|
|
17
|
-
docFields?: { name: string; type: string }[];
|
|
18
|
-
fields: FieldInfo[];
|
|
19
|
-
status: 'idle' | 'loading' | 'ready' | 'error';
|
|
20
|
-
error?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const GRID = 8;
|
|
24
|
-
const HISTORY_LIMIT = 100;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Data the host page supplies instead of the report's own data source:
|
|
28
|
-
* a URL (JSON file/API, or OData service / entity-set URL), a JSON string, or the data itself.
|
|
29
|
-
*/
|
|
30
|
-
export interface DataOverride {
|
|
31
|
-
source: unknown;
|
|
32
|
-
type: DataSourceType;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Applies a host-supplied data source on top of the report's data source definition. */
|
|
36
|
-
export function overrideDataSource(ds: DataSourceDef, o: DataOverride | null): DataSourceDef {
|
|
37
|
-
if (!o || o.source == null || o.source === '') return ds;
|
|
38
|
-
const src = o.source;
|
|
39
|
-
if (typeof src === 'string' && !/^\s*[[{]/.test(src)) {
|
|
40
|
-
const url = src.trim();
|
|
41
|
-
if (o.type === 'odata') {
|
|
42
|
-
// A full entity-set URL (…/Orders) already names the set; don't append the report's entitySet again.
|
|
43
|
-
const path = url.split('?')[0].replace(/\/+$/, '').toLowerCase();
|
|
44
|
-
const set = ds.entitySet.trim().toLowerCase();
|
|
45
|
-
const entitySet = set && path.endsWith('/' + set) ? '' : ds.entitySet;
|
|
46
|
-
return { ...ds, type: 'odata', url, inline: '', entitySet };
|
|
47
|
-
}
|
|
48
|
-
return { ...ds, type: 'json', url, inline: '' };
|
|
49
|
-
}
|
|
50
|
-
// Inline data: a JSON string, or an array/object passed as a property.
|
|
51
|
-
return { ...ds, type: 'json', url: '', inline: typeof src === 'string' ? src : JSON.stringify(src) };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function prune(r: ReportDefinition, s: Selection): Selection {
|
|
55
|
-
const ids = new Set(r.bands.flatMap((b) => b.elements.map((e) => e.id)));
|
|
56
|
-
return {
|
|
57
|
-
elementIds: s.elementIds.filter((id) => ids.has(id)),
|
|
58
|
-
bandId: r.bands.some((b) => b.id === s.bandId) ? s.bandId : null,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function findElement(r: ReportDefinition, id: string): { band: Band; el: ReportElement } | null {
|
|
63
|
-
for (const band of r.bands) {
|
|
64
|
-
const el = band.elements.find((e) => e.id === id);
|
|
65
|
-
if (el) return { band, el };
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* All designer/viewer state: the report (with undo/redo), selection, view options and loaded data.
|
|
72
|
-
* Provided per component (myreport-designer / myreport-viewer), so several can live on one page.
|
|
73
|
-
*/
|
|
74
|
-
@Injectable()
|
|
75
|
-
export class DesignerStore {
|
|
76
|
-
readonly report = signal<ReportDefinition>(newReport());
|
|
77
|
-
readonly selection = signal<Selection>({ elementIds: [], bandId: null });
|
|
78
|
-
readonly zoom = signal(1);
|
|
79
|
-
readonly snap = signal(true);
|
|
80
|
-
readonly data = signal<DataState>({ rows: [], fields: [], status: 'idle' });
|
|
81
|
-
/** localStorage key the report is autosaved to; null = no autosave. */
|
|
82
|
-
readonly persistKey = signal<string | null>(null);
|
|
83
|
-
/** Data supplied by the host page, replacing the report's data source. */
|
|
84
|
-
readonly dataOverride = signal<DataOverride | null>(null);
|
|
85
|
-
/** Parameter values supplied by the host page (below values typed in the parameter bar). */
|
|
86
|
-
readonly externalParams = signal<Record<string, unknown>>({});
|
|
87
|
-
|
|
88
|
-
private readonly past = signal<ReportDefinition[]>([]);
|
|
89
|
-
private readonly future = signal<ReportDefinition[]>([]);
|
|
90
|
-
readonly canUndo = computed(() => this.past().length > 0);
|
|
91
|
-
readonly canRedo = computed(() => this.future().length > 0);
|
|
92
|
-
|
|
93
|
-
private readonly reloadToken = signal(0);
|
|
94
|
-
/** Values typed in the preview's parameter bar (raw text), on top of host values and defaults. */
|
|
95
|
-
readonly paramInput = signal<Record<string, unknown>>({});
|
|
96
|
-
/** Effective, typed parameter values. */
|
|
97
|
-
readonly params = computed(() => resolveParams(this.report(), { ...this.externalParams(), ...this.paramInput() }));
|
|
98
|
-
/** The data source actually used: the report's own, or the host's override. */
|
|
99
|
-
readonly effectiveDataSource = computed(() => overrideDataSource(this.report().dataSource, this.dataOverride()));
|
|
100
|
-
/** Data source with {@Param} placeholders filled in; reloads when they change. */
|
|
101
|
-
private readonly dsKey = computed(() => JSON.stringify(applyParamsToDataSource(this.effectiveDataSource(), this.params())));
|
|
102
|
-
|
|
103
|
-
constructor() {
|
|
104
|
-
// Autosave to the browser (only when a persist key is set).
|
|
105
|
-
effect((onCleanup) => {
|
|
106
|
-
const r = this.report();
|
|
107
|
-
const key = this.persistKey();
|
|
108
|
-
if (!key) return;
|
|
109
|
-
const t = setTimeout(() => {
|
|
110
|
-
try {
|
|
111
|
-
localStorage.setItem(key, JSON.stringify(r));
|
|
112
|
-
} catch {
|
|
113
|
-
/* ignore */
|
|
114
|
-
}
|
|
115
|
-
}, 400);
|
|
116
|
-
onCleanup(() => clearTimeout(t));
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// (Re)load data whenever the data source definition changes.
|
|
120
|
-
effect((onCleanup) => {
|
|
121
|
-
const ds = JSON.parse(this.dsKey());
|
|
122
|
-
this.reloadToken();
|
|
123
|
-
untracked(() => {
|
|
124
|
-
if (!hasSource(ds)) {
|
|
125
|
-
this.data.set({ rows: [], fields: [], status: 'idle' });
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const ac = new AbortController();
|
|
129
|
-
this.data.update((d) => ({ ...d, status: 'loading', error: undefined }));
|
|
130
|
-
const t = setTimeout(() => {
|
|
131
|
-
loadData(ds, ac.signal)
|
|
132
|
-
.then((res) => this.data.set({ rows: res.rows, fields: res.fields, status: 'ready', root: res.root, docFields: documentFields(res.root) }))
|
|
133
|
-
.catch((e) => {
|
|
134
|
-
if (!ac.signal.aborted) this.data.set({ rows: [], fields: [], status: 'error', error: String(e?.message ?? e) });
|
|
135
|
-
});
|
|
136
|
-
}, 300);
|
|
137
|
-
onCleanup(() => {
|
|
138
|
-
clearTimeout(t);
|
|
139
|
-
ac.abort();
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Applies a change to a copy of the report. `history=false` for live drag updates (after `checkpoint()`). */
|
|
146
|
-
update(recipe: (r: ReportDefinition) => void, history = true): void {
|
|
147
|
-
const cur = this.report();
|
|
148
|
-
const next = structuredClone(cur);
|
|
149
|
-
recipe(next);
|
|
150
|
-
if (history) {
|
|
151
|
-
this.past.update((p) => [...p.slice(-HISTORY_LIMIT), cur]);
|
|
152
|
-
this.future.set([]);
|
|
153
|
-
}
|
|
154
|
-
this.report.set(next);
|
|
155
|
-
this.selection.update((s) => prune(next, s));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
checkpoint(): void {
|
|
159
|
-
const cur = this.report();
|
|
160
|
-
this.past.update((p) => [...p.slice(-HISTORY_LIMIT), cur]);
|
|
161
|
-
this.future.set([]);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
undo(): void {
|
|
165
|
-
const p = this.past();
|
|
166
|
-
if (!p.length) return;
|
|
167
|
-
const prev = p[p.length - 1];
|
|
168
|
-
this.future.update((f) => [this.report(), ...f]);
|
|
169
|
-
this.past.set(p.slice(0, -1));
|
|
170
|
-
this.report.set(prev);
|
|
171
|
-
this.selection.update((s) => prune(prev, s));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
redo(): void {
|
|
175
|
-
const [next, ...rest] = this.future();
|
|
176
|
-
if (!next) return;
|
|
177
|
-
this.past.update((p) => [...p, this.report()]);
|
|
178
|
-
this.future.set(rest);
|
|
179
|
-
this.report.set(next);
|
|
180
|
-
this.selection.update((s) => prune(next, s));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
load(r: ReportDefinition): void {
|
|
184
|
-
this.report.set(r);
|
|
185
|
-
this.past.set([]);
|
|
186
|
-
this.future.set([]);
|
|
187
|
-
this.selection.set({ elementIds: [], bandId: null });
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Loads the report autosaved under `key` (if any) and keeps autosaving there. */
|
|
191
|
-
restore(key: string): boolean {
|
|
192
|
-
let ok = false;
|
|
193
|
-
try {
|
|
194
|
-
const saved = localStorage.getItem(key);
|
|
195
|
-
if (saved) {
|
|
196
|
-
this.load(JSON.parse(saved));
|
|
197
|
-
ok = true;
|
|
198
|
-
}
|
|
199
|
-
} catch {
|
|
200
|
-
/* ignore */
|
|
201
|
-
}
|
|
202
|
-
this.persistKey.set(key);
|
|
203
|
-
return ok;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
select(s: Selection): void {
|
|
207
|
-
this.selection.set(s);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
snapValue(v: number): number {
|
|
211
|
-
return this.snap() ? Math.round(v / GRID) * GRID : Math.round(v);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
reloadData(): void {
|
|
215
|
-
this.reloadToken.update((n) => n + 1);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Fetches a .report.json and checks it looks like a report definition. */
|
|
220
|
-
export async function fetchReport(path: string, signal?: AbortSignal): Promise<ReportDefinition> {
|
|
221
|
-
const res = await fetch(path, { headers: { Accept: 'application/json' }, signal });
|
|
222
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} — ${path}`);
|
|
223
|
-
return validateReport(await res.json());
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function validateReport(r: unknown): ReportDefinition {
|
|
227
|
-
const def = r as ReportDefinition;
|
|
228
|
-
if (!def || def.version !== 1 || !Array.isArray(def.bands)) throw new Error('Not a report definition (expected version 1 with bands)');
|
|
229
|
-
return def;
|
|
230
|
-
}
|
package/src/lib/ui/inputs.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import type { ReportDefinition } from '../model/types';
|
|
2
|
-
import { validateReport } from './designer.store';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Normalizes a report given as an object or a JSON string (Web Component attributes are strings).
|
|
6
|
-
* Returns null when nothing usable was given.
|
|
7
|
-
*/
|
|
8
|
-
export function parseReport(v: ReportDefinition | string | null | undefined): ReportDefinition | null {
|
|
9
|
-
if (v == null || v === '') return null;
|
|
10
|
-
try {
|
|
11
|
-
return validateReport(typeof v === 'string' ? JSON.parse(v) : structuredClone(v));
|
|
12
|
-
} catch (e) {
|
|
13
|
-
console.error('[myreport] invalid report:', (e as Error).message);
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Parameter values as an object, a JSON string, or a query string ("Country=Egypt&Year=2024"). */
|
|
19
|
-
export function parseParams(v: Record<string, unknown> | string | null | undefined): Record<string, unknown> {
|
|
20
|
-
if (v == null || v === '') return {};
|
|
21
|
-
if (typeof v !== 'string') return { ...v };
|
|
22
|
-
const s = v.trim();
|
|
23
|
-
if (s.startsWith('{')) {
|
|
24
|
-
try {
|
|
25
|
-
return JSON.parse(s);
|
|
26
|
-
} catch {
|
|
27
|
-
console.error('[myreport] params is not valid JSON:', s);
|
|
28
|
-
return {};
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
const out: Record<string, unknown> = {};
|
|
32
|
-
new URLSearchParams(s.replace(/^\?/, '')).forEach((val, k) => (out[k.replace(/^(param|p)\./i, '')] = val));
|
|
33
|
-
return out;
|
|
34
|
-
}
|