angular-my-report 0.0.0-stage → 0.1.0
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 +99 -2
- package/ng-package.json +7 -0
- package/package.json +23 -3
- package/src/lib/engine/barcode.ts +118 -0
- package/src/lib/engine/chart.ts +85 -0
- package/src/lib/engine/chartLayout.ts +265 -0
- package/src/lib/engine/crosstab.ts +96 -0
- package/src/lib/engine/dataSources.ts +204 -0
- package/src/lib/engine/expressions.ts +734 -0
- package/src/lib/engine/measure.ts +62 -0
- package/src/lib/engine/parameters.ts +96 -0
- package/src/lib/engine/qr.ts +26 -0
- package/src/lib/engine/renderer.ts +324 -0
- package/src/lib/engine/table.ts +58 -0
- package/src/lib/model/defaults.ts +249 -0
- package/src/lib/model/factories.ts +324 -0
- package/src/lib/model/types.ts +281 -0
- package/src/lib/ui/css.ts +12 -0
- package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
- package/src/lib/ui/designer/canvas.component.ts +281 -0
- package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
- package/src/lib/ui/designer/element-box.component.ts +138 -0
- package/src/lib/ui/designer/graphics.components.ts +140 -0
- package/src/lib/ui/designer/left-panel.component.ts +189 -0
- package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
- package/src/lib/ui/designer/table-view.component.ts +120 -0
- package/src/lib/ui/designer.component.ts +296 -0
- package/src/lib/ui/designer.store.ts +230 -0
- package/src/lib/ui/inputs.ts +34 -0
- package/src/lib/ui/myreport.css +267 -0
- package/src/lib/ui/properties/band-props.component.ts +86 -0
- package/src/lib/ui/properties/element-props.component.ts +326 -0
- package/src/lib/ui/properties/expr-input.component.ts +191 -0
- package/src/lib/ui/properties/graphics-props.component.ts +260 -0
- package/src/lib/ui/properties/image-picker.component.ts +62 -0
- package/src/lib/ui/properties/properties-panel.component.ts +37 -0
- package/src/lib/ui/properties/report-extras.component.ts +198 -0
- package/src/lib/ui/properties/report-props.component.ts +211 -0
- package/src/lib/ui/properties/table-props.component.ts +197 -0
- package/src/lib/ui/viewer/page-view.component.ts +73 -0
- package/src/lib/ui/viewer/render.ts +161 -0
- package/src/lib/ui/viewer/viewer.component.ts +555 -0
- package/src/lib/ui/viewer.component.ts +107 -0
- package/src/public-api.ts +12 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +5 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
|
2
|
+
import type { Band, ReportElement } from '../../model/types';
|
|
3
|
+
import { BAND_LABELS, FOOTER_TYPES, contentWidth, createElement, createTable, orderedBands } from '../../model/defaults';
|
|
4
|
+
import { createColumn } from '../../engine/table';
|
|
5
|
+
import { createBarcode, createChart, createCrosstab, createQr, type FieldRef } from '../../model/factories';
|
|
6
|
+
import { DesignerStore } from '../designer.store';
|
|
7
|
+
import { ElementBoxComponent } from './element-box.component';
|
|
8
|
+
|
|
9
|
+
export const DRAG_MIME = 'application/x-report-item';
|
|
10
|
+
export type DragPayload =
|
|
11
|
+
| { kind: 'text' | 'line' | 'rect' | 'image' }
|
|
12
|
+
| { kind: 'table' | 'chart' | 'barcode' | 'qrcode' | 'crosstab'; fields: FieldRef[] }
|
|
13
|
+
| { kind: 'field'; field: string; type?: string; template?: string };
|
|
14
|
+
|
|
15
|
+
const BAND_TITLE_H = 20;
|
|
16
|
+
type DragMode = 'move' | 'e' | 's' | 'se';
|
|
17
|
+
|
|
18
|
+
/** Builds the element a toolbox/field drop creates, adjusted for the target band. */
|
|
19
|
+
export function elementFromPayload(p: DragPayload, band: Band, x: number, y: number, width: number): ReportElement {
|
|
20
|
+
if (p.kind === 'table') return createTable(x, y, Math.max(120, width - x), p.fields);
|
|
21
|
+
if (p.kind === 'chart') return createChart(x, y, Math.max(160, width - x), p.fields);
|
|
22
|
+
if (p.kind === 'crosstab') return createCrosstab(x, y, Math.max(200, width - x), p.fields);
|
|
23
|
+
if (p.kind === 'barcode') return createBarcode(x, y, p.fields);
|
|
24
|
+
if (p.kind === 'qrcode') return createQr(x, y, p.fields);
|
|
25
|
+
if (p.kind !== 'field') return createElement(p.kind, x, y);
|
|
26
|
+
if (p.template) return createElement('field', x, y, { text: p.template });
|
|
27
|
+
const numeric = p.type === 'number';
|
|
28
|
+
const inFooter = FOOTER_TYPES.includes(band.type);
|
|
29
|
+
return createElement('field', x, y, {
|
|
30
|
+
text: numeric && inFooter ? `{sum(${p.field})}` : `{${p.field}}`,
|
|
31
|
+
format: p.type === 'date' ? { kind: 'date' } : numeric && inFooter ? { kind: 'number', decimals: 2 } : {},
|
|
32
|
+
style: numeric ? { align: 'right' } : {},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Component({
|
|
37
|
+
selector: 'rpt-canvas',
|
|
38
|
+
standalone: true,
|
|
39
|
+
imports: [ElementBoxComponent],
|
|
40
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
41
|
+
template: `
|
|
42
|
+
<div class="canvas-scroll" (pointerdown)="onBackgroundDown($event)">
|
|
43
|
+
<div class="canvas-sizer" [style.width.px]="width() * store.zoom() + 2" [style.height.px]="totalHeight() * store.zoom() + 2">
|
|
44
|
+
<div class="canvas" [style.width.px]="width()" [style.transform]="'scale(' + store.zoom() + ')'">
|
|
45
|
+
@for (band of bands(); track band.id) {
|
|
46
|
+
<div class="band">
|
|
47
|
+
<div
|
|
48
|
+
class="band-title band-{{ band.type }}"
|
|
49
|
+
[class.selected]="isBandSelected(band)"
|
|
50
|
+
[style.height.px]="titleHeight"
|
|
51
|
+
(pointerdown)="store.select({ elementIds: [], bandId: band.id })"
|
|
52
|
+
>
|
|
53
|
+
{{ labels[band.type] }}
|
|
54
|
+
@if (groupField(band); as g) {
|
|
55
|
+
<span class="band-group">: {{ g }}</span>
|
|
56
|
+
}
|
|
57
|
+
</div>
|
|
58
|
+
<div
|
|
59
|
+
class="band-body"
|
|
60
|
+
[attr.data-band-id]="band.id"
|
|
61
|
+
[style.height.px]="band.height"
|
|
62
|
+
[style.width.px]="width()"
|
|
63
|
+
(dragover)="onDragOver($event)"
|
|
64
|
+
(drop)="onDrop($event, band)"
|
|
65
|
+
(pointerdown)="onBodyDown($event, band)"
|
|
66
|
+
>
|
|
67
|
+
@for (el of band.elements; track el.id) {
|
|
68
|
+
<rpt-element
|
|
69
|
+
[el]="el"
|
|
70
|
+
[content]="el.text"
|
|
71
|
+
[design]="true"
|
|
72
|
+
class="design-el kind-{{ el.kind }}"
|
|
73
|
+
[class.selected]="isSelected(el)"
|
|
74
|
+
[attr.title]="el.text || null"
|
|
75
|
+
(pointerdown)="onElementDown($event, el, band)"
|
|
76
|
+
>
|
|
77
|
+
@if (isSelected(el) && store.selection().elementIds.length === 1) {
|
|
78
|
+
<div class="handle handle-e" (pointerdown)="onHandleDown($event, el, band, 'e')"></div>
|
|
79
|
+
@if (el.kind !== 'table') {
|
|
80
|
+
<div class="handle handle-s" (pointerdown)="onHandleDown($event, el, band, 's')"></div>
|
|
81
|
+
<div class="handle handle-se" (pointerdown)="onHandleDown($event, el, band, 'se')"></div>
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</rpt-element>
|
|
85
|
+
}
|
|
86
|
+
</div>
|
|
87
|
+
<div class="band-resizer" title="Drag to resize band" (pointerdown)="startBandResize($event, band)"></div>
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
`,
|
|
94
|
+
})
|
|
95
|
+
export class CanvasComponent {
|
|
96
|
+
protected readonly store = inject(DesignerStore);
|
|
97
|
+
protected readonly labels = BAND_LABELS;
|
|
98
|
+
protected readonly titleHeight = BAND_TITLE_H;
|
|
99
|
+
|
|
100
|
+
protected readonly width = computed(() => contentWidth(this.store.report().page));
|
|
101
|
+
protected readonly bands = computed(() => orderedBands(this.store.report()).filter((b) => b.visible));
|
|
102
|
+
protected readonly totalHeight = computed(() => this.bands().reduce((h, b) => h + b.height + BAND_TITLE_H + 6, 0));
|
|
103
|
+
|
|
104
|
+
protected isSelected(el: ReportElement): boolean {
|
|
105
|
+
return this.store.selection().elementIds.includes(el.id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected isBandSelected(band: Band): boolean {
|
|
109
|
+
const s = this.store.selection();
|
|
110
|
+
return s.bandId === band.id && s.elementIds.length === 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected groupField(band: Band): string | null {
|
|
114
|
+
if (!band.groupId) return null;
|
|
115
|
+
const g = this.store.report().groups.find((x) => x.id === band.groupId);
|
|
116
|
+
return g ? g.field || '(no field)' : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected onBackgroundDown(e: PointerEvent): void {
|
|
120
|
+
if (e.target === e.currentTarget) this.store.select({ elementIds: [], bandId: null });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected onBodyDown(e: PointerEvent, band: Band): void {
|
|
124
|
+
if (e.target === e.currentTarget) this.store.select({ elementIds: [], bandId: band.id });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected onDragOver(e: DragEvent): void {
|
|
128
|
+
if (e.dataTransfer?.types.includes(DRAG_MIME)) {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected onDrop(e: DragEvent, band: Band): void {
|
|
135
|
+
const raw = e.dataTransfer?.getData(DRAG_MIME);
|
|
136
|
+
if (!raw) return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
const store = this.store;
|
|
139
|
+
const zoom = store.zoom();
|
|
140
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
141
|
+
const px = (e.clientX - rect.left) / zoom;
|
|
142
|
+
const py = (e.clientY - rect.top) / zoom;
|
|
143
|
+
const payload: DragPayload = JSON.parse(raw);
|
|
144
|
+
|
|
145
|
+
// A field dropped onto a table adds a column to it.
|
|
146
|
+
const target = band.elements.find((t) => t.kind === 'table' && px >= t.x && px <= t.x + t.w && py >= t.y && py <= t.y + t.h);
|
|
147
|
+
if (target && payload.kind === 'field') {
|
|
148
|
+
store.update((r) => {
|
|
149
|
+
const t = r.bands.find((b) => b.id === band.id)!.elements.find((x) => x.id === target.id)!.table!;
|
|
150
|
+
const col = createColumn(payload.field, payload.type);
|
|
151
|
+
if (payload.template) Object.assign(col, { header: '', text: payload.template });
|
|
152
|
+
if (payload.type === 'number') col.footer = `{sum(${payload.field})}`;
|
|
153
|
+
t.columns.push(col);
|
|
154
|
+
});
|
|
155
|
+
store.select({ elementIds: [target.id], bandId: band.id });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const width = this.width();
|
|
160
|
+
const el = elementFromPayload(payload, band, store.snapValue(px), store.snapValue(py), width);
|
|
161
|
+
el.x = Math.max(0, Math.min(el.x, width - el.w));
|
|
162
|
+
el.y = Math.max(0, Math.min(el.y, Math.max(0, band.height - el.h)));
|
|
163
|
+
store.update((r) => {
|
|
164
|
+
const b = r.bands.find((bb) => bb.id === band.id)!;
|
|
165
|
+
b.elements.push(el);
|
|
166
|
+
if (b.height < el.y + el.h) b.height = el.y + el.h;
|
|
167
|
+
});
|
|
168
|
+
store.select({ elementIds: [el.id], bandId: band.id });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
protected startBandResize(e: PointerEvent, band: Band): void {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
const store = this.store;
|
|
175
|
+
const zoom = store.zoom();
|
|
176
|
+
const startY = e.clientY;
|
|
177
|
+
const startH = band.height;
|
|
178
|
+
store.checkpoint();
|
|
179
|
+
const move = (ev: PointerEvent) => {
|
|
180
|
+
const h = Math.max(0, store.snapValue(startH + (ev.clientY - startY) / zoom));
|
|
181
|
+
store.update((r) => {
|
|
182
|
+
r.bands.find((b) => b.id === band.id)!.height = h;
|
|
183
|
+
}, false);
|
|
184
|
+
};
|
|
185
|
+
const up = () => {
|
|
186
|
+
window.removeEventListener('pointermove', move);
|
|
187
|
+
window.removeEventListener('pointerup', up);
|
|
188
|
+
};
|
|
189
|
+
window.addEventListener('pointermove', move);
|
|
190
|
+
window.addEventListener('pointerup', up);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
protected onElementDown(e: PointerEvent, el: ReportElement, band: Band): void {
|
|
194
|
+
if (e.button !== 0) return;
|
|
195
|
+
e.stopPropagation();
|
|
196
|
+
const selected = this.isSelected(el);
|
|
197
|
+
let ids = this.store.selection().elementIds;
|
|
198
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
199
|
+
ids = selected ? ids.filter((i) => i !== el.id) : [...ids, el.id];
|
|
200
|
+
this.store.select({ elementIds: ids, bandId: band.id });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!selected) {
|
|
204
|
+
ids = [el.id];
|
|
205
|
+
this.store.select({ elementIds: ids, bandId: band.id });
|
|
206
|
+
}
|
|
207
|
+
this.beginDrag(e, el, band, ids, 'move');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
protected onHandleDown(e: PointerEvent, el: ReportElement, band: Band, mode: DragMode): void {
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
this.beginDrag(e, el, band, [el.id], mode);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private beginDrag(e: PointerEvent, el: ReportElement, band: Band, ids: string[], mode: DragMode): void {
|
|
216
|
+
const store = this.store;
|
|
217
|
+
const zoom = store.zoom();
|
|
218
|
+
const width = this.width();
|
|
219
|
+
const startX = e.clientX;
|
|
220
|
+
const startY = e.clientY;
|
|
221
|
+
const targets = band.elements.filter((x) => (mode === 'move' ? ids.includes(x.id) : x.id === el.id)).map((x) => ({ ...x }));
|
|
222
|
+
const sourceBody = (e.currentTarget as HTMLElement).closest('.band-body') as HTMLElement;
|
|
223
|
+
let moved = false;
|
|
224
|
+
|
|
225
|
+
const move = (ev: PointerEvent) => {
|
|
226
|
+
const dx = (ev.clientX - startX) / zoom;
|
|
227
|
+
const dy = (ev.clientY - startY) / zoom;
|
|
228
|
+
if (!moved) {
|
|
229
|
+
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return;
|
|
230
|
+
moved = true;
|
|
231
|
+
store.checkpoint();
|
|
232
|
+
}
|
|
233
|
+
store.update((r) => {
|
|
234
|
+
const b = r.bands.find((bb) => bb.id === band.id)!;
|
|
235
|
+
for (const s of targets) {
|
|
236
|
+
const t = b.elements.find((x) => x.id === s.id);
|
|
237
|
+
if (!t) continue;
|
|
238
|
+
if (mode === 'move') {
|
|
239
|
+
t.x = Math.max(0, Math.min(store.snapValue(s.x + dx), width - t.w));
|
|
240
|
+
t.y = store.snapValue(s.y + dy);
|
|
241
|
+
} else {
|
|
242
|
+
if (mode !== 's') t.w = Math.max(4, Math.min(store.snapValue(s.w + dx), width - t.x));
|
|
243
|
+
if (mode !== 'e') t.h = Math.max(2, store.snapValue(s.h + dy));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, false);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const up = (ev: PointerEvent) => {
|
|
250
|
+
window.removeEventListener('pointermove', move);
|
|
251
|
+
window.removeEventListener('pointerup', up);
|
|
252
|
+
if (!moved || mode !== 'move') return;
|
|
253
|
+
// Dropped over another band? Move the elements there, keeping their on-screen position.
|
|
254
|
+
const targetBody = document.elementsFromPoint(ev.clientX, ev.clientY).find((n) => n.classList.contains('band-body')) as
|
|
255
|
+
| HTMLElement
|
|
256
|
+
| undefined;
|
|
257
|
+
const targetId = targetBody?.dataset['bandId'];
|
|
258
|
+
const srcTop = sourceBody.getBoundingClientRect().top;
|
|
259
|
+
store.update((r) => {
|
|
260
|
+
const src = r.bands.find((b) => b.id === band.id)!;
|
|
261
|
+
const dst = targetId && targetId !== band.id ? r.bands.find((b) => b.id === targetId) : undefined;
|
|
262
|
+
for (const s of targets) {
|
|
263
|
+
const i = src.elements.findIndex((x) => x.id === s.id);
|
|
264
|
+
if (i < 0) continue;
|
|
265
|
+
const t = src.elements[i];
|
|
266
|
+
if (dst && targetBody) {
|
|
267
|
+
const dstTop = targetBody.getBoundingClientRect().top;
|
|
268
|
+
src.elements.splice(i, 1);
|
|
269
|
+
t.y = Math.max(0, Math.min(store.snapValue((srcTop + t.y * zoom - dstTop) / zoom), Math.max(0, dst.height - t.h)));
|
|
270
|
+
dst.elements.push(t);
|
|
271
|
+
} else {
|
|
272
|
+
t.y = Math.max(0, Math.min(t.y, Math.max(0, src.height - t.h)));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}, false);
|
|
276
|
+
if (targetId && targetId !== band.id) store.select({ elementIds: targets.map((t) => t.id), bandId: targetId });
|
|
277
|
+
};
|
|
278
|
+
window.addEventListener('pointermove', move);
|
|
279
|
+
window.addEventListener('pointerup', up);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, output, signal } from '@angular/core';
|
|
2
|
+
import { FormsModule } from '@angular/forms';
|
|
3
|
+
import type { DataSourceDef } from '../../model/types';
|
|
4
|
+
import { buildODataUrl, detectDataSource, loadData, loadODataEntitySets, type LoadResult } from '../../engine/dataSources';
|
|
5
|
+
import { DesignerStore } from '../designer.store';
|
|
6
|
+
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'rpt-data-source-dialog',
|
|
9
|
+
standalone: true,
|
|
10
|
+
imports: [FormsModule],
|
|
11
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
12
|
+
template: `
|
|
13
|
+
<div class="modal-backdrop" (pointerdown)="onBackdropDown($event)">
|
|
14
|
+
<div class="modal">
|
|
15
|
+
<header>
|
|
16
|
+
<h2>Data source</h2>
|
|
17
|
+
<button class="link" (click)="closed.emit()">✕</button>
|
|
18
|
+
</header>
|
|
19
|
+
<div class="modal-body">
|
|
20
|
+
<label class="field">
|
|
21
|
+
URL — paste a JSON API, OData service or OData entity-set URL to configure everything automatically
|
|
22
|
+
<div class="inline">
|
|
23
|
+
<input
|
|
24
|
+
[(ngModel)]="ds.url"
|
|
25
|
+
(paste)="onPaste($event)"
|
|
26
|
+
(keydown.enter)="detect()"
|
|
27
|
+
placeholder="https://jsonplaceholder.typicode.com/posts · https://services.odata.org/V4/Northwind/Northwind.svc/Orders"
|
|
28
|
+
/>
|
|
29
|
+
<button [disabled]="detecting() || !ds.url.trim()" (click)="detect()">{{ detecting() ? 'Detecting…' : 'Detect' }}</button>
|
|
30
|
+
</div>
|
|
31
|
+
@if (detectMsg(); as m) {
|
|
32
|
+
<span [class.ok]="!m.error" [class.error]="m.error">{{ m.text }}</span>
|
|
33
|
+
}
|
|
34
|
+
</label>
|
|
35
|
+
|
|
36
|
+
<div class="tabs">
|
|
37
|
+
<span class="muted tabs-label">Type:</span>
|
|
38
|
+
<button [class.on]="ds.type === 'json'" (click)="ds.type = 'json'">JSON</button>
|
|
39
|
+
<button [class.on]="ds.type === 'odata'" (click)="ds.type = 'odata'">OData</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
@if (ds.type === 'json') {
|
|
43
|
+
<label class="field">
|
|
44
|
+
Or paste JSON (used instead of the URL when filled)
|
|
45
|
+
<textarea rows="6" [(ngModel)]="ds.inline" placeholder='[{"Name":"A","Amount":10}]'></textarea>
|
|
46
|
+
</label>
|
|
47
|
+
<label class="field">
|
|
48
|
+
Array path (optional)
|
|
49
|
+
<input [(ngModel)]="ds.dataPath" placeholder="e.g. data.items — blank = auto-detect" />
|
|
50
|
+
</label>
|
|
51
|
+
} @else {
|
|
52
|
+
<label class="field">
|
|
53
|
+
Entity set (leave empty if the URL already points to one)
|
|
54
|
+
<div class="inline">
|
|
55
|
+
<input list="entity-sets" [(ngModel)]="ds.entitySet" placeholder="e.g. Orders" />
|
|
56
|
+
<button [disabled]="!ds.url.trim()" (click)="fetchSets()">Load entity sets</button>
|
|
57
|
+
</div>
|
|
58
|
+
@if (setsMsg()) {
|
|
59
|
+
<span class="muted">{{ setsMsg() }}</span>
|
|
60
|
+
}
|
|
61
|
+
<datalist id="entity-sets">
|
|
62
|
+
@for (s of sets(); track s) {
|
|
63
|
+
<option [value]="s"></option>
|
|
64
|
+
}
|
|
65
|
+
</datalist>
|
|
66
|
+
</label>
|
|
67
|
+
<div class="grid2">
|
|
68
|
+
<label class="field">$select <input [(ngModel)]="ds.select" placeholder="OrderID,ShipCountry,Freight" /></label>
|
|
69
|
+
<label class="field">$filter <input [(ngModel)]="ds.filter" placeholder="Freight gt 50" /></label>
|
|
70
|
+
<label class="field">$orderby <input [(ngModel)]="ds.orderby" placeholder="OrderDate desc" /></label>
|
|
71
|
+
<label class="field">$expand <input [(ngModel)]="ds.expand" placeholder="Customer" /></label>
|
|
72
|
+
<label class="field">$top <input type="number" min="1" [(ngModel)]="ds.top" /></label>
|
|
73
|
+
<label class="field">Max rows (paging cap) <input type="number" min="1" [(ngModel)]="ds.maxRows" /></label>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="muted url-preview">{{ odataUrl() }}</div>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
<label class="field">
|
|
79
|
+
Request headers (one per line)
|
|
80
|
+
<textarea rows="2" [(ngModel)]="ds.headers" placeholder="Authorization: Bearer …"></textarea>
|
|
81
|
+
</label>
|
|
82
|
+
|
|
83
|
+
<div class="test-area">
|
|
84
|
+
<button [disabled]="busy()" (click)="runTest()">{{ busy() ? 'Testing…' : 'Test connection' }}</button>
|
|
85
|
+
@if (error()) {
|
|
86
|
+
<div class="error">{{ error() }}</div>
|
|
87
|
+
}
|
|
88
|
+
@if (result(); as res) {
|
|
89
|
+
<div class="ok">{{ res.rows.length }} rows · fields: {{ fieldSummary(res) }}</div>
|
|
90
|
+
@if (res.warning) {
|
|
91
|
+
<div class="error">{{ res.warning }}</div>
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</div>
|
|
95
|
+
<p class="hint">The browser fetches the data directly, so the API must allow CORS from this page.</p>
|
|
96
|
+
</div>
|
|
97
|
+
<footer>
|
|
98
|
+
<button (click)="closed.emit()">Cancel</button>
|
|
99
|
+
<button class="primary" (click)="apply()">Apply</button>
|
|
100
|
+
</footer>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
`,
|
|
104
|
+
})
|
|
105
|
+
export class DataSourceDialogComponent {
|
|
106
|
+
private readonly store = inject(DesignerStore);
|
|
107
|
+
readonly closed = output<void>();
|
|
108
|
+
|
|
109
|
+
/** Working copy, edited with two-way binding and applied on "Apply". */
|
|
110
|
+
protected ds: DataSourceDef = structuredClone(this.store.report().dataSource);
|
|
111
|
+
protected readonly busy = signal(false);
|
|
112
|
+
protected readonly result = signal<LoadResult | null>(null);
|
|
113
|
+
protected readonly error = signal('');
|
|
114
|
+
protected readonly sets = signal<string[]>([]);
|
|
115
|
+
protected readonly setsMsg = signal('');
|
|
116
|
+
|
|
117
|
+
private readonly cdr = inject(ChangeDetectorRef);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Closes when clicking outside the dialog. Must return nothing: an Angular event handler that
|
|
121
|
+
* returns `false` calls preventDefault(), which would stop clicks from focusing the inputs.
|
|
122
|
+
*/
|
|
123
|
+
protected onBackdropDown(e: PointerEvent): void {
|
|
124
|
+
if (e.target === e.currentTarget) this.closed.emit();
|
|
125
|
+
}
|
|
126
|
+
protected readonly detecting = signal(false);
|
|
127
|
+
protected readonly detectMsg = signal<{ text: string; error: boolean } | null>(null);
|
|
128
|
+
|
|
129
|
+
/** Pasting a URL into the URL field configures the source straight away. */
|
|
130
|
+
protected onPaste(e: ClipboardEvent): void {
|
|
131
|
+
const text = e.clipboardData?.getData('text').trim() ?? '';
|
|
132
|
+
if (!/^(https?:\/\/|\/)\S+$/i.test(text)) return;
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
this.ds = { ...this.ds, url: text };
|
|
135
|
+
this.detect();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Fetches the URL, decides JSON vs OData, fills in the whole form and tests it. */
|
|
139
|
+
protected async detect(): Promise<void> {
|
|
140
|
+
const url = this.ds.url.trim();
|
|
141
|
+
if (!url || this.detecting()) return;
|
|
142
|
+
this.detecting.set(true);
|
|
143
|
+
this.detectMsg.set(null);
|
|
144
|
+
this.result.set(null);
|
|
145
|
+
this.error.set('');
|
|
146
|
+
try {
|
|
147
|
+
const { dataSource, note } = await detectDataSource(url, this.ds.headers);
|
|
148
|
+
this.ds = dataSource;
|
|
149
|
+
this.cdr.markForCheck();
|
|
150
|
+
this.detectMsg.set({ text: note, error: false });
|
|
151
|
+
if (dataSource.type === 'odata' && dataSource.entitySet) this.fetchSets();
|
|
152
|
+
await this.runTest();
|
|
153
|
+
} catch (e) {
|
|
154
|
+
this.detectMsg.set({ text: String((e as Error).message ?? e), error: true });
|
|
155
|
+
} finally {
|
|
156
|
+
this.detecting.set(false);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
protected odataUrl(): string {
|
|
161
|
+
return buildODataUrl(this.normalized());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
protected fieldSummary(res: LoadResult): string {
|
|
165
|
+
return res.fields.map((f) => `${f.name} (${f.type})`).join(', ');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private normalized(): DataSourceDef {
|
|
169
|
+
return { ...this.ds, top: this.ds.top ? Number(this.ds.top) : null, maxRows: Number(this.ds.maxRows) || 5000 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
protected async runTest(): Promise<void> {
|
|
173
|
+
this.busy.set(true);
|
|
174
|
+
this.error.set('');
|
|
175
|
+
this.result.set(null);
|
|
176
|
+
try {
|
|
177
|
+
this.result.set(await loadData(this.normalized()));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
this.error.set(String((e as Error).message ?? e));
|
|
180
|
+
} finally {
|
|
181
|
+
this.busy.set(false);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected async fetchSets(): Promise<void> {
|
|
186
|
+
this.setsMsg.set('Loading…');
|
|
187
|
+
try {
|
|
188
|
+
const list = await loadODataEntitySets(this.ds.url, this.ds.headers);
|
|
189
|
+
this.sets.set(list);
|
|
190
|
+
this.setsMsg.set(list.length ? '' : 'No entity sets found');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
this.setsMsg.set(String((e as Error).message ?? e));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
protected apply(): void {
|
|
197
|
+
const ds = this.normalized();
|
|
198
|
+
this.store.update((r) => (r.dataSource = ds));
|
|
199
|
+
this.closed.emit();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { renderTemplate, type Row } from '../../engine/expressions';
|
|
3
|
+
import { crosstabAsTable } from '../../engine/crosstab';
|
|
4
|
+
import { DesignerStore } from '../designer.store';
|
|
5
|
+
import { BarcodeViewComponent, ChartViewComponent, QrViewComponent } from './graphics.components';
|
|
6
|
+
import { SafeImagePipe } from './safe-image.pipe';
|
|
7
|
+
import type { ReportElement } from '../../model/types';
|
|
8
|
+
import { LINE_HEIGHT } from '../../engine/measure';
|
|
9
|
+
import { css } from '../css';
|
|
10
|
+
import { TableViewComponent, designTableData, type TableRenderData } from './table-view.component';
|
|
11
|
+
|
|
12
|
+
const JUSTIFY = { left: 'flex-start', center: 'center', right: 'flex-end' } as const;
|
|
13
|
+
const ALIGN_ITEMS = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const;
|
|
14
|
+
|
|
15
|
+
/** Draws one report element. Used by both the designer canvas and the preview. */
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'rpt-element',
|
|
18
|
+
standalone: true,
|
|
19
|
+
imports: [TableViewComponent, ChartViewComponent, BarcodeViewComponent, QrViewComponent, SafeImagePipe],
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
host: { '[style]': 'hostStyle()' },
|
|
22
|
+
template: `
|
|
23
|
+
@switch (el().kind) {
|
|
24
|
+
@case ('table') {
|
|
25
|
+
@if (el().table) {
|
|
26
|
+
<rpt-table-view [el]="el()" [data]="tableData() ?? designData()" />
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
@case ('crosstab') {
|
|
30
|
+
@if (crosstab(); as x) {
|
|
31
|
+
<rpt-table-view [el]="x.table" [data]="tableData() ?? x.render" />
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
@case ('chart') {
|
|
35
|
+
<rpt-chart-view [el]="el()" [rows]="chartRows()" />
|
|
36
|
+
}
|
|
37
|
+
@case ('barcode') {
|
|
38
|
+
<rpt-barcode-view [el]="el()" [value]="codeValue()" />
|
|
39
|
+
}
|
|
40
|
+
@case ('qrcode') {
|
|
41
|
+
<rpt-qr-view [el]="el()" [value]="codeValue()" />
|
|
42
|
+
}
|
|
43
|
+
@case ('line') {
|
|
44
|
+
<div [style]="lineStyle()"></div>
|
|
45
|
+
}
|
|
46
|
+
@case ('rect') {}
|
|
47
|
+
@case ('image') {
|
|
48
|
+
@if (content()) {
|
|
49
|
+
<img [src]="content() | safeImage" alt="" draggable="false" style="width:100%;height:100%;display:block" [style.object-fit]="el().imageFit ?? 'contain'" />
|
|
50
|
+
} @else if (design()) {
|
|
51
|
+
<div class="image-placeholder">Image</div>
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
@default {
|
|
55
|
+
<span style="width:100%">{{ content() }}</span>
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
<ng-content />
|
|
59
|
+
`,
|
|
60
|
+
})
|
|
61
|
+
export class ElementBoxComponent {
|
|
62
|
+
readonly el = input.required<ReportElement>();
|
|
63
|
+
readonly content = input('');
|
|
64
|
+
readonly design = input(false);
|
|
65
|
+
/** Overrides for grown/shifted elements in the preview. */
|
|
66
|
+
readonly top = input<number | undefined>(undefined);
|
|
67
|
+
readonly height = input<number | undefined>(undefined);
|
|
68
|
+
/** Rows to draw for a table; omitted in the designer (templates are shown). */
|
|
69
|
+
readonly tableData = input<TableRenderData | undefined>(undefined);
|
|
70
|
+
|
|
71
|
+
/** Rows for a chart / cross table in the preview (its band's scope). */
|
|
72
|
+
readonly rows = input<Row[] | undefined>(undefined);
|
|
73
|
+
|
|
74
|
+
private readonly store = inject(DesignerStore);
|
|
75
|
+
readonly designData = computed(() => designTableData(this.el()));
|
|
76
|
+
|
|
77
|
+
/** In the designer, charts and cross tables show the loaded data. */
|
|
78
|
+
readonly chartRows = computed(() => this.rows() ?? this.store.data().rows);
|
|
79
|
+
readonly crosstab = computed(() => (this.el().crosstab ? crosstabAsTable(this.el(), this.chartRows()) : null));
|
|
80
|
+
|
|
81
|
+
/** Barcode/QR value: the evaluated text in the preview; in the designer, evaluated on the first data row. */
|
|
82
|
+
readonly codeValue = computed(() => {
|
|
83
|
+
if (!this.design()) return this.content();
|
|
84
|
+
const rows = this.store.data().rows;
|
|
85
|
+
const ctx = { row: rows[0], rows, allRows: rows, pageRows: rows, rowNumber: 1, pageNumber: 1, totalPages: 1, reportName: this.store.report().name, params: this.store.params() };
|
|
86
|
+
return renderTemplate(this.el().text, ctx, this.el().format);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
readonly hostStyle = computed(() => {
|
|
90
|
+
const el = this.el();
|
|
91
|
+
const s = el.style;
|
|
92
|
+
const box = {
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
left: el.x,
|
|
95
|
+
top: this.top() ?? el.y,
|
|
96
|
+
width: el.w,
|
|
97
|
+
height: this.height() ?? el.h,
|
|
98
|
+
boxSizing: 'border-box',
|
|
99
|
+
};
|
|
100
|
+
if (el.kind === 'rect') {
|
|
101
|
+
return css({ ...box, display: 'block', border: `${s.borderWidth}px ${el.dash ?? 'solid'} ${s.borderColor}`, background: s.background });
|
|
102
|
+
}
|
|
103
|
+
if (el.kind !== 'text' && el.kind !== 'field') return css({ ...box, display: 'block' });
|
|
104
|
+
return css({
|
|
105
|
+
...box,
|
|
106
|
+
display: 'flex',
|
|
107
|
+
justifyContent: JUSTIFY[s.align],
|
|
108
|
+
alignItems: ALIGN_ITEMS[s.vAlign],
|
|
109
|
+
textAlign: s.align,
|
|
110
|
+
fontFamily: s.fontFamily,
|
|
111
|
+
fontSize: s.fontSize,
|
|
112
|
+
fontWeight: s.bold ? 700 : 400,
|
|
113
|
+
fontStyle: s.italic ? 'italic' : 'normal',
|
|
114
|
+
textDecoration: s.underline ? 'underline' : 'none',
|
|
115
|
+
color: s.color,
|
|
116
|
+
background: s.background,
|
|
117
|
+
padding: s.padding,
|
|
118
|
+
overflow: 'hidden',
|
|
119
|
+
whiteSpace: 'pre-wrap',
|
|
120
|
+
wordBreak: 'break-word',
|
|
121
|
+
lineHeight: LINE_HEIGHT,
|
|
122
|
+
border: s.borderWidth > 0 ? `${s.borderWidth}px solid ${s.borderColor}` : undefined,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
readonly lineStyle = computed(() => {
|
|
127
|
+
const el = this.el();
|
|
128
|
+
const t = Math.max(1, el.style.borderWidth);
|
|
129
|
+
const horizontal = el.w >= el.h;
|
|
130
|
+
const stroke = `${t}px ${el.dash ?? 'solid'} ${el.style.borderColor}`;
|
|
131
|
+
return css({
|
|
132
|
+
position: 'absolute',
|
|
133
|
+
...(horizontal
|
|
134
|
+
? { left: 0, right: 0, top: `calc(50% - ${t / 2}px)`, height: 0, borderTop: stroke }
|
|
135
|
+
: { top: 0, bottom: 0, left: `calc(50% - ${t / 2}px)`, width: 0, borderLeft: stroke }),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|