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.
Files changed (46) hide show
  1. package/README.md +99 -2
  2. package/ng-package.json +7 -0
  3. package/package.json +23 -3
  4. package/src/lib/engine/barcode.ts +118 -0
  5. package/src/lib/engine/chart.ts +85 -0
  6. package/src/lib/engine/chartLayout.ts +265 -0
  7. package/src/lib/engine/crosstab.ts +96 -0
  8. package/src/lib/engine/dataSources.ts +204 -0
  9. package/src/lib/engine/expressions.ts +734 -0
  10. package/src/lib/engine/measure.ts +62 -0
  11. package/src/lib/engine/parameters.ts +96 -0
  12. package/src/lib/engine/qr.ts +26 -0
  13. package/src/lib/engine/renderer.ts +324 -0
  14. package/src/lib/engine/table.ts +58 -0
  15. package/src/lib/model/defaults.ts +249 -0
  16. package/src/lib/model/factories.ts +324 -0
  17. package/src/lib/model/types.ts +281 -0
  18. package/src/lib/ui/css.ts +12 -0
  19. package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
  20. package/src/lib/ui/designer/canvas.component.ts +281 -0
  21. package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
  22. package/src/lib/ui/designer/element-box.component.ts +138 -0
  23. package/src/lib/ui/designer/graphics.components.ts +140 -0
  24. package/src/lib/ui/designer/left-panel.component.ts +189 -0
  25. package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
  26. package/src/lib/ui/designer/table-view.component.ts +120 -0
  27. package/src/lib/ui/designer.component.ts +296 -0
  28. package/src/lib/ui/designer.store.ts +230 -0
  29. package/src/lib/ui/inputs.ts +34 -0
  30. package/src/lib/ui/myreport.css +267 -0
  31. package/src/lib/ui/properties/band-props.component.ts +86 -0
  32. package/src/lib/ui/properties/element-props.component.ts +326 -0
  33. package/src/lib/ui/properties/expr-input.component.ts +191 -0
  34. package/src/lib/ui/properties/graphics-props.component.ts +260 -0
  35. package/src/lib/ui/properties/image-picker.component.ts +62 -0
  36. package/src/lib/ui/properties/properties-panel.component.ts +37 -0
  37. package/src/lib/ui/properties/report-extras.component.ts +198 -0
  38. package/src/lib/ui/properties/report-props.component.ts +211 -0
  39. package/src/lib/ui/properties/table-props.component.ts +197 -0
  40. package/src/lib/ui/viewer/page-view.component.ts +73 -0
  41. package/src/lib/ui/viewer/render.ts +161 -0
  42. package/src/lib/ui/viewer/viewer.component.ts +555 -0
  43. package/src/lib/ui/viewer.component.ts +107 -0
  44. package/src/public-api.ts +12 -0
  45. package/tsconfig.lib.json +12 -0
  46. package/tsconfig.lib.prod.json +5 -0
@@ -0,0 +1,96 @@
1
+ import type { ReportElement, TableColumn } from '../model/types';
2
+ import { aggregate, formatValue, getPath, type Row } from './expressions';
3
+ import type { TableRenderData } from './table';
4
+
5
+ export interface Pivot {
6
+ rowKeys: string[];
7
+ colKeys: string[];
8
+ /** cells[r][c] = aggregate, null when no rows fall in the cell. */
9
+ cells: (number | null)[][];
10
+ rowTotals: (number | null)[];
11
+ colTotals: (number | null)[];
12
+ grandTotal: number | null;
13
+ }
14
+
15
+ const num = (v: unknown): number | null => (v == null || Number.isNaN(Number(v)) ? null : Number(v));
16
+ const byName = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
17
+ // Braces would be read as expressions when the cell text is rendered.
18
+ const literal = (s: string) => s.replace(/[{}]/g, '');
19
+
20
+ export function pivot(el: ReportElement, rows: Row[]): Pivot {
21
+ const x = el.crosstab!;
22
+ const key = (r: Row, f: string) => (f ? String(getPath(r, f) ?? '(blank)') : 'All');
23
+ const rowKeys = [...new Set(rows.map((r) => key(r, x.rowField)))].sort(byName);
24
+ const colKeys = [...new Set(rows.map((r) => key(r, x.columnField)))].sort(byName);
25
+ const bucket = new Map<string, Row[]>();
26
+ for (const r of rows) {
27
+ const k = key(r, x.rowField) + '\u0000' + key(r, x.columnField);
28
+ let b = bucket.get(k);
29
+ if (!b) bucket.set(k, (b = []));
30
+ b.push(r);
31
+ }
32
+ const agg = (set: Row[]) => (set.length ? num(aggregate(x.aggregate, x.valueField, set)) : null);
33
+ const cells = rowKeys.map((rk) => colKeys.map((ck) => agg(bucket.get(rk + '\u0000' + ck) ?? [])));
34
+ // Totals aggregate the underlying rows (so avg/min/max stay correct, not sums of averages).
35
+ const rowTotals = rowKeys.map((rk) => agg(rows.filter((r) => key(r, x.rowField) === rk)));
36
+ const colTotals = colKeys.map((ck) => agg(rows.filter((r) => key(r, x.columnField) === ck)));
37
+ return { rowKeys, colKeys, cells, rowTotals, colTotals, grandTotal: agg(rows) };
38
+ }
39
+
40
+ /**
41
+ * Turns a cross table into an ordinary table element + data rows (cells pre-formatted), so the
42
+ * renderer can paginate it with the table code: header repeats, rows flow, totals form the footer.
43
+ */
44
+ export function crosstabAsTable(el: ReportElement, rows: Row[]): { table: ReportElement; data: Row[]; render: TableRenderData } {
45
+ const x = el.crosstab!;
46
+ const p = pivot(el, rows);
47
+ const fmt = (v: number | null) => (v == null ? '' : formatValue(v, x.valueFormat));
48
+ const totalLabel = x.aggregate === 'count' ? 'Count' : x.aggregate === 'avg' ? 'Average' : 'Total';
49
+
50
+ const col = (id: string, header: string, footer: string, width: number, align: TableColumn['align']): TableColumn => ({
51
+ id,
52
+ header: literal(header),
53
+ text: `{${id}}`,
54
+ footer: literal(footer),
55
+ width,
56
+ align,
57
+ format: { kind: 'none', decimals: 0, currency: '' },
58
+ });
59
+ const columns: TableColumn[] = [
60
+ col('c0', x.cornerText || `${x.rowField || ''} / ${x.columnField || ''}`, totalLabel, 1.6, 'left'),
61
+ ...p.colKeys.map((k, i) => col(`c${i + 1}`, k, fmt(p.colTotals[i]), 1, 'right')),
62
+ ];
63
+ if (x.showRowTotals) columns.push(col(`c${p.colKeys.length + 1}`, totalLabel, fmt(p.grandTotal), 1, 'right'));
64
+
65
+ const data: Row[] = p.rowKeys.map((rk, r) => {
66
+ const o: Row = { c0: literal(rk) };
67
+ p.colKeys.forEach((_, c) => (o[`c${c + 1}`] = fmt(p.cells[r][c])));
68
+ if (x.showRowTotals) o[`c${p.colKeys.length + 1}`] = fmt(p.rowTotals[r]);
69
+ return o;
70
+ });
71
+
72
+ const table: ReportElement = {
73
+ ...el,
74
+ kind: 'table',
75
+ table: {
76
+ columns,
77
+ showHeader: true,
78
+ repeatHeader: x.repeatHeader,
79
+ showFooter: x.showColumnTotals,
80
+ headerHeight: x.headerHeight,
81
+ rowHeight: x.rowHeight,
82
+ headerBackground: x.headerBackground,
83
+ headerColor: x.headerColor,
84
+ stripeBackground: x.stripeBackground,
85
+ gridColor: x.gridColor,
86
+ },
87
+ };
88
+ const render: TableRenderData = {
89
+ header: columns.map((c) => c.header),
90
+ headerHeight: x.headerHeight,
91
+ rows: data.map((d, i) => ({ cells: columns.map((c) => String(d[c.id] ?? '')), height: x.rowHeight, index: i })),
92
+ footer: x.showColumnTotals ? columns.map((c) => c.footer) : null,
93
+ footerHeight: x.rowHeight,
94
+ };
95
+ return { table, data, render };
96
+ }
@@ -0,0 +1,204 @@
1
+ import type { DataSourceDef } from '../model/types';
2
+ import { emptyDataSource } from '../model/defaults';
3
+ import { getPath, type Row } from './expressions';
4
+
5
+ export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object';
6
+ export interface FieldInfo {
7
+ name: string;
8
+ type: FieldType;
9
+ }
10
+ export interface LoadResult {
11
+ rows: Row[];
12
+ fields: FieldInfo[];
13
+ requestUrl: string;
14
+ /** Non-fatal note, e.g. the array path was not found and auto-detect was used. */
15
+ warning?: string;
16
+ /** The whole JSON document (first page for OData), readable in formulas as root.x. */
17
+ root?: unknown;
18
+ }
19
+
20
+ export function parseHeaders(text: string): Record<string, string> {
21
+ const h: Record<string, string> = {};
22
+ for (const line of text.split(/\r?\n/)) {
23
+ const i = line.indexOf(':');
24
+ if (i > 0) h[line.slice(0, i).trim()] = line.slice(i + 1).trim();
25
+ }
26
+ return h;
27
+ }
28
+
29
+ export const hasSource = (ds: DataSourceDef) => !!(ds.url.trim() || (ds.type === 'json' && ds.inline.trim()));
30
+
31
+ export function buildODataUrl(ds: DataSourceDef): string {
32
+ let base = ds.url.trim().replace(/\/+$/, '');
33
+ if (ds.entitySet.trim()) base += '/' + ds.entitySet.trim();
34
+ const q: string[] = [];
35
+ const add = (k: string, v: string) => v.trim() && q.push(`${k}=${encodeURIComponent(v.trim())}`);
36
+ add('$select', ds.select);
37
+ add('$filter', ds.filter);
38
+ add('$orderby', ds.orderby);
39
+ add('$expand', ds.expand);
40
+ if (ds.top) q.push(`$top=${ds.top}`);
41
+ return q.length ? base + (base.includes('?') ? '&' : '?') + q.join('&') : base;
42
+ }
43
+
44
+ async function fetchJson(url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<any> {
45
+ const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal });
46
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}`);
47
+ const text = await res.text();
48
+ try {
49
+ return JSON.parse(text);
50
+ } catch {
51
+ throw new Error(`Response is not valid JSON — ${url}`);
52
+ }
53
+ }
54
+
55
+ function extractArray(data: unknown, path: string, onWarn: (w: string) => void = () => {}): Row[] {
56
+ let cur: any = path.trim() ? getPath(data, path.trim()) : data;
57
+ // A path that doesn't exist in this response (e.g. left over from another URL) falls back to auto-detect.
58
+ if (cur === undefined && path.trim()) {
59
+ onWarn(`Array path "${path.trim()}" not found in the response; auto-detected the data instead. Clear the Array path field.`);
60
+ cur = data;
61
+ path = '';
62
+ }
63
+ if (Array.isArray(cur)) return cur;
64
+ if (cur && typeof cur === 'object') {
65
+ for (const key of ['value', 'items', 'data', 'results', 'rows', 'records']) {
66
+ if (Array.isArray(cur[key])) return cur[key];
67
+ }
68
+ const firstArray = Object.values(cur).find(Array.isArray);
69
+ if (firstArray) return firstArray as Row[];
70
+ return [cur];
71
+ }
72
+ throw new Error(path ? `No array found at "${path}"` : 'Data is not an array or object');
73
+ }
74
+
75
+ export async function loadData(ds: DataSourceDef, signal?: AbortSignal): Promise<LoadResult> {
76
+ const headers = parseHeaders(ds.headers);
77
+ let rows: Row[];
78
+ let requestUrl = '';
79
+ let warning: string | undefined;
80
+ let root: unknown;
81
+
82
+ if (ds.type === 'json') {
83
+ let data: unknown;
84
+ if (ds.inline.trim()) {
85
+ try {
86
+ data = JSON.parse(ds.inline);
87
+ } catch (e) {
88
+ throw new Error('Pasted JSON is invalid: ' + (e as Error).message);
89
+ }
90
+ } else {
91
+ requestUrl = ds.url.trim();
92
+ data = await fetchJson(requestUrl, headers, signal);
93
+ }
94
+ root = data;
95
+ rows = extractArray(data, ds.dataPath, (w) => (warning = w));
96
+ } else {
97
+ requestUrl = buildODataUrl(ds);
98
+ rows = [];
99
+ let next: string | undefined = requestUrl;
100
+ const max = ds.maxRows || 5000;
101
+ while (next && rows.length < max) {
102
+ const json: any = await fetchJson(next, headers, signal);
103
+ root ??= json;
104
+ const page: Row[] = json.value ?? json.d?.results ?? (Array.isArray(json.d) ? json.d : json.d ? [json.d] : []);
105
+ rows.push(...page);
106
+ const link: string | undefined = json['@odata.nextLink'] ?? json['odata.nextLink'] ?? json.d?.__next;
107
+ next = link && !ds.top ? new URL(link, next).toString() : undefined;
108
+ }
109
+ rows = rows.slice(0, max);
110
+ }
111
+ return { rows, fields: inferFields(rows), requestUrl, warning, root };
112
+ }
113
+
114
+ function typeOf(v: unknown): FieldType | null {
115
+ if (v == null) return null;
116
+ if (typeof v === 'number') return 'number';
117
+ if (typeof v === 'boolean') return 'boolean';
118
+ if (typeof v === 'string') {
119
+ if (/^\d{4}-\d{2}-\d{2}([T ][\d:.]+)?(Z|[+-]\d{2}:?\d{2})?$/.test(v) || /^\/Date\(-?\d+/.test(v)) return 'date';
120
+ return 'string';
121
+ }
122
+ return 'object';
123
+ }
124
+
125
+ /** Infers field names (flattening nested objects into dot paths) from a sample of rows. */
126
+ export function inferFields(rows: Row[]): FieldInfo[] {
127
+ const types = new Map<string, FieldType | null>();
128
+ const visit = (obj: Record<string, unknown>, prefix: string, depth: number) => {
129
+ for (const [k, v] of Object.entries(obj)) {
130
+ if (k.startsWith('@') || k.startsWith('__')) continue;
131
+ const name = prefix + k;
132
+ if (v && typeof v === 'object' && !Array.isArray(v) && depth < 2) {
133
+ visit(v as Record<string, unknown>, name + '.', depth + 1);
134
+ continue;
135
+ }
136
+ if (Array.isArray(v)) continue;
137
+ const t = typeOf(v);
138
+ if (!types.has(name) || (types.get(name) === null && t)) types.set(name, t);
139
+ }
140
+ };
141
+ for (const r of rows.slice(0, 50)) if (r && typeof r === 'object') visit(r, '', 0);
142
+ return [...types].map(([name, type]) => ({ name, type: type ?? 'string' }));
143
+ }
144
+
145
+ /** Lists entity sets from an OData service ($metadata XML, falling back to the JSON service document). */
146
+ export async function loadODataEntitySets(serviceUrl: string, headersText: string): Promise<string[]> {
147
+ const base = serviceUrl.trim().replace(/\/+$/, '');
148
+ const headers = parseHeaders(headersText);
149
+ try {
150
+ const res = await fetch(base + '/$metadata', { headers: { Accept: 'application/xml', ...headers } });
151
+ if (res.ok) {
152
+ const xml = new DOMParser().parseFromString(await res.text(), 'application/xml');
153
+ const sets = [...xml.getElementsByTagNameNS('*', 'EntitySet')].map((n) => n.getAttribute('Name') ?? '').filter(Boolean);
154
+ if (sets.length) return sets;
155
+ }
156
+ } catch {
157
+ /* fall through */
158
+ }
159
+ const doc = await fetchJson(base, headers);
160
+ const list: any[] = doc.value ?? doc.d?.EntitySets ?? [];
161
+ return list.map((x) => (typeof x === 'string' ? x : x.name ?? x.url)).filter(Boolean);
162
+ }
163
+
164
+ export interface DetectedSource {
165
+ dataSource: DataSourceDef;
166
+ /** Human-readable summary of what was detected. */
167
+ note: string;
168
+ }
169
+
170
+ /**
171
+ * Builds a data source from a pasted URL: fetches it once and decides between plain JSON and OData
172
+ * (OData responses carry @odata.context / odata.metadata / a "d" wrapper). An OData service root
173
+ * is resolved to its first entity set. Previous settings (array path, query options) are reset.
174
+ */
175
+ export async function detectDataSource(rawUrl: string, headers = ''): Promise<DetectedSource> {
176
+ let url = rawUrl.trim();
177
+ if (!url) throw new Error('Enter a URL');
178
+ url = url.replace(/\/\$metadata\/?$/i, ''); // a $metadata link means the service root
179
+ const base = { ...emptyDataSource(), headers };
180
+ const json: any = await fetchJson(url, parseHeaders(headers));
181
+
182
+ const isOData = json && typeof json === 'object' && !Array.isArray(json) && ('@odata.context' in json || 'odata.metadata' in json || 'd' in json);
183
+ if (!isOData) {
184
+ const rows = extractArray(json, '');
185
+ return { dataSource: { ...base, type: 'json', url }, note: `JSON · ${rows.length} rows` };
186
+ }
187
+
188
+ // Service document: lists entity sets instead of data rows.
189
+ const ctx: string = json['@odata.context'] ?? json['odata.metadata'] ?? '';
190
+ const v4Sets = /\$metadata$/.test(ctx) && Array.isArray(json.value) ? json.value.filter((x: any) => !x.kind || x.kind === 'EntitySet') : null;
191
+ const v2Sets: string[] | null = Array.isArray(json.d?.EntitySets) ? json.d.EntitySets : null;
192
+ const sets: string[] = v4Sets ? v4Sets.map((x: any) => x.url ?? x.name) : v2Sets ?? [];
193
+ if (sets.length) {
194
+ return {
195
+ dataSource: { ...base, type: 'odata', url: url.replace(/\/+$/, ''), entitySet: sets[0], top: 100 },
196
+ note: `OData service · using entity set "${sets[0]}" (${sets.length} available, first 100 rows) — pick another under Entity set`,
197
+ };
198
+ }
199
+ const rows = json.value ?? json.d?.results ?? json.d;
200
+ return {
201
+ dataSource: { ...base, type: 'odata', url },
202
+ note: `OData · ${Array.isArray(rows) ? rows.length : 1} rows${json['@odata.nextLink'] ? ' (more pages will be fetched)' : ''}`,
203
+ };
204
+ }