pgkiln 0.74.0 → 0.76.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/db/migrations/142_json_sources.sql +426 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/03-builder.md +15 -0
- package/docs/guide/04-pages-and-regions.md +24 -2
- package/docs/guide/12-development.md +11 -0
- package/docs/guide/19-rest-data-sources.md +108 -1
- package/examples/hr/hr_77_json_sources.sql +174 -0
- package/package.json +1 -1
- package/public/app.css +18 -0
- package/public/app.js +141 -0
- package/src/app.ts +5 -0
- package/src/builder/document-view.ts +117 -0
- package/src/builder/i18n/de.ts +1 -0
- package/src/builder/i18n/en.ts +1 -0
- package/src/builder/i18n/es.ts +1 -0
- package/src/builder/i18n/fr.ts +1 -0
- package/src/builder/i18n/it.ts +1 -0
- package/src/builder/i18n/ja.ts +1 -0
- package/src/builder/i18n/ko.ts +1 -0
- package/src/builder/i18n/nl.ts +1 -0
- package/src/builder/i18n/pt.ts +1 -0
- package/src/builder/i18n/zh-cn.ts +1 -0
- package/src/builder/i18n/zh-tw.ts +1 -0
- package/src/builder/json-sources.ts +395 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/report-settings.ts +9 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/websources.ts +6 -3
- package/src/i18n/ar.ts +24 -0
- package/src/i18n/cs.ts +24 -0
- package/src/i18n/da.ts +24 -0
- package/src/i18n/de.ts +24 -0
- package/src/i18n/el.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/fi.ts +24 -0
- package/src/i18n/fr.ts +24 -0
- package/src/i18n/he.ts +24 -0
- package/src/i18n/it.ts +24 -0
- package/src/i18n/ja.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/i18n/nb.ts +24 -0
- package/src/i18n/pl.ts +24 -0
- package/src/i18n/pt.ts +24 -0
- package/src/i18n/ru.ts +24 -0
- package/src/i18n/sv.ts +24 -0
- package/src/i18n/tr.ts +24 -0
- package/src/i18n/uk.ts +24 -0
- package/src/i18n/zh.ts +24 -0
- package/src/i18n.ts +48 -0
- package/src/json-sources.ts +365 -0
- package/src/runtime/ai-filter.ts +11 -6
- package/src/runtime/ir-plan.ts +3 -3
- package/src/runtime/pdf.ts +6 -6
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +211 -26
package/src/runtime/report.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withJsonColumns } from '../json-sources.ts';
|
|
1
2
|
import { componentSetting } from './component-settings.ts';
|
|
2
3
|
import { icon } from '../icons.ts';
|
|
3
4
|
import pg from 'pg';
|
|
@@ -8,7 +9,8 @@ import { esc, html, raw, type Raw } from '../html.ts';
|
|
|
8
9
|
import type { Region } from '../metadata.ts';
|
|
9
10
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
10
11
|
import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
11
|
-
import { appDateReader, heading, splitValues } from './items.ts';
|
|
12
|
+
import { appDateMask, appDateReader, heading, splitValues } from './items.ts';
|
|
13
|
+
import { monthNames } from './format.ts';
|
|
12
14
|
import { linkAttrs } from './links.ts';
|
|
13
15
|
import type { Translate } from '../i18n.ts';
|
|
14
16
|
import type { Formatter } from './format.ts';
|
|
@@ -18,7 +20,7 @@ import { ComputeError, computeNameOk, computeSql, type Computation } from './com
|
|
|
18
20
|
import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
|
|
19
21
|
import { columnTemplates } from './template-components.ts';
|
|
20
22
|
import { textSearchConfig } from './search.ts';
|
|
21
|
-
import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
|
|
23
|
+
import { DATE_OIDS, facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
|
|
22
24
|
import { resolveRestRegion } from './rest-sources.ts';
|
|
23
25
|
import { checksumValid, signText } from '../security.ts';
|
|
24
26
|
import { parseArea, parseNear, postgis, spatialConditions, type MapArea, type Near } from './spatial.ts';
|
|
@@ -141,7 +143,65 @@ export function pageWindow(r: Region, st: { page: number; size: number }) {
|
|
|
141
143
|
return { page, offset, limit: max ? Math.max(1, Math.min(st.size + 1, max - offset)) : st.size + 1, max };
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
/** (143) how a filter's value is read: on a date column, a date in the application's format becomes ISO */
|
|
147
|
+
export interface OpOptions { date?: boolean; readDate?: (text: string) => string | null }
|
|
148
|
+
|
|
149
|
+
/** (143) the most values an "is one of" filter takes, and the longest value */
|
|
150
|
+
export const MAX_LIST_VALUES = 100;
|
|
151
|
+
const MAX_VALUE_LENGTH = 500;
|
|
152
|
+
|
|
153
|
+
/** a filter value written as a JSON array by the dialog, or null for text */
|
|
154
|
+
function jsonArray(v: string): unknown[] | null {
|
|
155
|
+
if (!v.trimStart().startsWith('[')) return null;
|
|
156
|
+
try {
|
|
157
|
+
const j = JSON.parse(v);
|
|
158
|
+
return Array.isArray(j) ? j : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* (143; APEX 26.2: multi-select filters) The values of an "is one of" / "is not one of" filter: a JSON array as the
|
|
166
|
+
* filter dialog writes it (["CLERK","ANALYST"]: a value may hold a comma), else text separated by commas (typed,
|
|
167
|
+
* or from Ask / an AI answer).
|
|
168
|
+
*/
|
|
169
|
+
export function listValues(v: string): string[] {
|
|
170
|
+
const out = (jsonArray(v) ?? v.split(',')).flatMap((x) => (typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean' ? [String(x).trim()] : []));
|
|
171
|
+
return [...new Set(out.filter((x) => x !== '' && x.length <= MAX_VALUE_LENGTH))].slice(0, MAX_LIST_VALUES);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** (143; APEX 26.2: date-range filters) The two bounds of "is between": ["from","to"], either may be empty (open). */
|
|
175
|
+
export function rangeValues(v: string): [string, string] {
|
|
176
|
+
const [a, b] = (jsonArray(v) ?? v.split(',')).map((x) => (typeof x === 'string' || typeof x === 'number' ? String(x).trim().slice(0, MAX_VALUE_LENGTH) : ''));
|
|
177
|
+
return [a ?? '', b ?? ''];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** (143) Relative periods of "is in" on a date column: [start, end) as SQL on current_date, so a saved report rolls on. */
|
|
181
|
+
export const PERIODS: Record<string, [string, string]> = {
|
|
182
|
+
today: ['current_date', 'current_date + 1'],
|
|
183
|
+
yesterday: ['current_date - 1', 'current_date'],
|
|
184
|
+
last_7_days: ['current_date - 6', 'current_date + 1'],
|
|
185
|
+
last_30_days: ['current_date - 29', 'current_date + 1'],
|
|
186
|
+
next_7_days: ['current_date', 'current_date + 7'],
|
|
187
|
+
this_month: [`date_trunc('month', current_date)`, `date_trunc('month', current_date) + interval '1 month'`],
|
|
188
|
+
last_month: [`date_trunc('month', current_date) - interval '1 month'`, `date_trunc('month', current_date)`],
|
|
189
|
+
this_year: [`date_trunc('year', current_date)`, `date_trunc('year', current_date) + interval '1 year'`],
|
|
190
|
+
last_year: [`date_trunc('year', current_date) - interval '1 year'`, `date_trunc('year', current_date)`],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** a bound typed in the application's date format, as ISO for a date column (as typed when it doesn't read as one) */
|
|
194
|
+
const dateBound = (v: string, o?: OpOptions) => (o?.date && o.readDate ? (o.readDate(v) ?? v) : v);
|
|
195
|
+
|
|
196
|
+
export const OPERATORS: Record<string, {
|
|
197
|
+
label: string;
|
|
198
|
+
sql: (col: string, v: string, o?: OpOptions) => string;
|
|
199
|
+
noValue?: boolean;
|
|
200
|
+
/** (143) the value is a list (is one of), two bounds (between) or a period name */
|
|
201
|
+
kind?: 'list' | 'range' | 'period';
|
|
202
|
+
/** (143) whether a value makes a filter at all */
|
|
203
|
+
ok?: (v: string) => boolean;
|
|
204
|
+
}> = {
|
|
145
205
|
eq: { label: '=', sql: (c, v) => `${c} = ${literal(v)}` },
|
|
146
206
|
ne: { label: '≠', sql: (c, v) => `${c} is distinct from ${literal(v)}` },
|
|
147
207
|
contains: { label: 'contains', sql: (c, v) => `${c}::text ilike ${literal(`%${escapeLike(v)}%`)}` },
|
|
@@ -152,13 +212,56 @@ export const OPERATORS: Record<string, { label: string; sql: (col: string, v: st
|
|
|
152
212
|
le: { label: '≤', sql: (c, v) => `${c} <= ${literal(v)}` },
|
|
153
213
|
null: { label: 'is empty', sql: (c) => `${c} is null`, noValue: true },
|
|
154
214
|
not_null: { label: 'is not empty', sql: (c) => `${c} is not null`, noValue: true },
|
|
215
|
+
// (143) several values at once; on a date column each may be in the application's date format
|
|
216
|
+
in: { label: 'is one of', kind: 'list', ok: (v) => listValues(v).length > 0,
|
|
217
|
+
sql: (c, v, o) => {
|
|
218
|
+
const xs = listValues(v);
|
|
219
|
+
return xs.length ? `${c} in (${xs.map((x) => literal(dateBound(x, o))).join(', ')})` : 'false';
|
|
220
|
+
} },
|
|
221
|
+
not_in: { label: 'is not one of', kind: 'list', ok: (v) => listValues(v).length > 0,
|
|
222
|
+
sql: (c, v, o) => {
|
|
223
|
+
const xs = listValues(v);
|
|
224
|
+
return xs.length ? `(${c} is null or ${c} not in (${xs.map((x) => literal(dateBound(x, o))).join(', ')}))` : 'true';
|
|
225
|
+
} },
|
|
226
|
+
// (143) from and to, both included; on a date or timestamp column the whole last day counts
|
|
227
|
+
between: { label: 'is between', kind: 'range', ok: (v) => rangeValues(v).some(Boolean),
|
|
228
|
+
sql: (c, v, o) => {
|
|
229
|
+
const [a, b] = rangeValues(v).map((x) => (x ? dateBound(x, o) : x));
|
|
230
|
+
const parts = [
|
|
231
|
+
a ? (o?.date ? `${c} >= ${literal(a)}::date` : `${c} >= ${literal(a)}`) : '',
|
|
232
|
+
b ? (o?.date ? `${c} < ${literal(b)}::date + 1` : `${c} <= ${literal(b)}`) : '',
|
|
233
|
+
].filter(Boolean);
|
|
234
|
+
return parts.length ? `(${parts.join(' and ')})` : 'true';
|
|
235
|
+
} },
|
|
236
|
+
period: { label: 'is in', kind: 'period', ok: (v) => Object.hasOwn(PERIODS, v),
|
|
237
|
+
sql: (c, v) => (Object.hasOwn(PERIODS, v) ? `(${c} >= ${PERIODS[v][0]} and ${c} < ${PERIODS[v][1]})` : 'false') },
|
|
155
238
|
};
|
|
156
239
|
|
|
240
|
+
/** (143) whether column|op|value makes a filter (a known operator with a usable value) */
|
|
241
|
+
export const filterOk = (op: string, v: string) => Object.hasOwn(OPERATORS, op) && (OPERATORS[op].ok?.(v) ?? true);
|
|
242
|
+
|
|
243
|
+
/** (143) A filter's text for its chip, the PDF's header and Ask: "Job is one of CLERK, ANALYST", "Hire date is between … and …". */
|
|
244
|
+
export function describeFilter(t: Translate, label: string, op: string, v: string) {
|
|
245
|
+
const o = OPERATORS[op];
|
|
246
|
+
if (!o || o.noValue) return `${label} ${opLabel(t, op)}`;
|
|
247
|
+
if (o.kind === 'list') return `${label} ${opLabel(t, op)} ${listValues(v).join(', ')}`;
|
|
248
|
+
if (o.kind === 'range') {
|
|
249
|
+
const [a, b] = rangeValues(v);
|
|
250
|
+
return a && b ? `${label} ${opLabel(t, op)} ${a} ${t('report.and')} ${b}` : a ? `${label} ≥ ${a}` : `${label} ≤ ${b}`;
|
|
251
|
+
}
|
|
252
|
+
if (o.kind === 'period') return `${label} ${opLabel(t, op)} ${t(`period.${v}`)}`;
|
|
253
|
+
return `${label} ${opLabel(t, op)} ${v}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** the operators a highlight offers: one value or none (a list, a range or a period is a filter's) */
|
|
257
|
+
export const HIGHLIGHT_OPERATORS = Object.keys(OPERATORS).filter((k) => !OPERATORS[k].kind);
|
|
258
|
+
|
|
157
259
|
const escapeLike = (v: string) => v.replace(/[\\%_]/g, '\\$&');
|
|
158
260
|
|
|
159
261
|
/** Operator names in the user's language (symbols stay as they are). */
|
|
160
262
|
export const opLabel = (t: Translate, op: string) =>
|
|
161
|
-
({ contains: t('op.contains'), not_contains: t('op.not_contains'), null: t('op.null'), not_null: t('op.not_null')
|
|
263
|
+
({ contains: t('op.contains'), not_contains: t('op.not_contains'), null: t('op.null'), not_null: t('op.not_null'),
|
|
264
|
+
in: t('op.in'), not_in: t('op.not_in'), between: t('op.between'), period: t('op.period') } as Record<string, string>)[op] ?? OPERATORS[op]?.label ?? op;
|
|
162
265
|
|
|
163
266
|
interface Filter {
|
|
164
267
|
column: string;
|
|
@@ -192,6 +295,8 @@ export interface ReportState {
|
|
|
192
295
|
searchCol: string | null;
|
|
193
296
|
/** (sprint 76) columns the user hid (a column heading's menu → Hide) */
|
|
194
297
|
hideCols: string[];
|
|
298
|
+
/** (143; APEX 26.2: drag-and-drop columns) the order the user put the columns in (Actions → Columns); the rest follow */
|
|
299
|
+
order: string[];
|
|
195
300
|
sort: number;
|
|
196
301
|
desc: boolean;
|
|
197
302
|
page: number;
|
|
@@ -247,13 +352,14 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
247
352
|
search: (p.get(key(r, 'q')) ?? '').trim(),
|
|
248
353
|
searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
|
|
249
354
|
hideCols: p.getAll(key(r, 'x')).filter(Boolean).slice(0, 100),
|
|
355
|
+
order: [...new Set(p.getAll(key(r, 'o')).filter(Boolean))].slice(0, 200),
|
|
250
356
|
sort: r.config.sortable === false ? 0 : Math.max(0, Math.min(1000, parseInt(p.get(key(r, 's')) ?? '0', 10) || 0)),
|
|
251
357
|
desc: p.get(key(r, 'd')) === 'desc',
|
|
252
358
|
page: Math.max(1, Math.min(MAX_PAGE, parseInt(p.get(key(r, 'p')) ?? '1', 10) || 1)),
|
|
253
359
|
size: Math.max(1, Math.min(500, size)),
|
|
254
360
|
filters: p.getAll(key(r, 'f')).flatMap((raw) => {
|
|
255
361
|
const [column, op, ...rest] = raw.split('|');
|
|
256
|
-
return column &&
|
|
362
|
+
return column && filterOk(op, rest.join('|')) ? [{ column, op, value: rest.join('|'), raw }] : [];
|
|
257
363
|
}),
|
|
258
364
|
breakCol: p.get(key(r, 'b')) || null,
|
|
259
365
|
aggregates: p.getAll(key(r, 'a')).slice(0, 20).flatMap((raw) => {
|
|
@@ -263,7 +369,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
263
369
|
// column|operator|color|value (the value last: it may contain "|")
|
|
264
370
|
highlights: p.getAll(key(r, 'h')).slice(0, 10).flatMap((raw) => {
|
|
265
371
|
const [column, op, color, ...rest] = raw.split('|');
|
|
266
|
-
return column &&
|
|
372
|
+
return column && HIGHLIGHT_OPERATORS.includes(op) && HIGHLIGHT_COLORS.includes(color) ? [{ column, op, color, value: rest.join('|'), raw }] : [];
|
|
267
373
|
}),
|
|
268
374
|
// name|expression (the expression last: it may contain "|")
|
|
269
375
|
computations: p.getAll(key(r, 'c')).slice(0, MAX_COMPUTATIONS).flatMap((raw) => {
|
|
@@ -293,7 +399,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
|
|
|
293
399
|
st.seek = parseSeek(ctx, r, st);
|
|
294
400
|
// (140) a classic report has none of an interactive report's settings: sorting, paging and (when searchable) the search only
|
|
295
401
|
if (classic) {
|
|
296
|
-
Object.assign(st, { searchCol: null, hideCols: [], filters: [], breakCol: null, aggregates: [], highlights: [], computations: [], view: 'report',
|
|
402
|
+
Object.assign(st, { searchCol: null, hideCols: [], order: [], filters: [], breakCol: null, aggregates: [], highlights: [], computations: [], view: 'report',
|
|
297
403
|
groupBy: { columns: [], functions: [] }, pivot: null, chart: null, area: null, near: null });
|
|
298
404
|
if (r.config.searchable !== true) st.search = '';
|
|
299
405
|
if (r.config.pagination === 'none') st.page = 1;
|
|
@@ -316,9 +422,30 @@ export function normaliseReportParams(params: URLSearchParams): string | null {
|
|
|
316
422
|
for (const k of [...params.keys()]) {
|
|
317
423
|
let m: RegExpExecArray | null;
|
|
318
424
|
if ((m = /^r(\d+)_fc$/.exec(k))) {
|
|
319
|
-
// filter: column, operator, value
|
|
320
|
-
const
|
|
321
|
-
|
|
425
|
+
// filter: column, operator, value; (143) the values ticked in the dialog's list (fl), a second bound (fv2), a period (fpr)
|
|
426
|
+
const ticked = params.getAll(`r${m[1]}_fl`);
|
|
427
|
+
const [col, op0, val, val2, per] = take(m[1], ['fc', 'fo', 'fv', 'fv2', 'fpr', 'fl']);
|
|
428
|
+
const op = op0 || 'eq';
|
|
429
|
+
const kind = OPERATORS[op]?.kind;
|
|
430
|
+
const value = kind === 'list' ? JSON.stringify([...new Set([...ticked.filter((x) => x !== ''), ...listValues(val)])].slice(0, MAX_LIST_VALUES))
|
|
431
|
+
: kind === 'range' ? JSON.stringify([val.trim(), val2.trim()])
|
|
432
|
+
: kind === 'period' ? per : val;
|
|
433
|
+
if (col && filterOk(op, value)) params.append(`r${m[1]}_f`, `${col}|${op}|${value}`);
|
|
434
|
+
} else if ((m = /^r(\d+)_od$/.exec(k))) {
|
|
435
|
+
// (143) Actions → Columns: the columns in their new order (o, as the dialog lists them), a move (mv: u|d + name,
|
|
436
|
+
// the arrow buttons without JavaScript) and the default order (od, JSON); the default order needs no parameter
|
|
437
|
+
const id = m[1];
|
|
438
|
+
const order = [...new Set(params.getAll(`r${id}_o`).filter(Boolean))].slice(0, 200);
|
|
439
|
+
const mv = params.get(`r${id}_mv`) ?? '';
|
|
440
|
+
let fallback: unknown = [];
|
|
441
|
+
try { fallback = JSON.parse(params.get(`r${id}_od`) ?? '[]'); } catch { /* none */ }
|
|
442
|
+
const at = order.indexOf(mv.slice(1));
|
|
443
|
+
const to = mv[0] === 'u' ? at - 1 : at + 1;
|
|
444
|
+
if (at >= 0 && to >= 0 && to < order.length) [order[at], order[to]] = [order[to], order[at]];
|
|
445
|
+
for (const n of ['o', 'mv', 'od', 'p']) params.delete(`r${id}_${n}`);
|
|
446
|
+
changed = true;
|
|
447
|
+
if (!(Array.isArray(fallback) && fallback.length === order.length && fallback.every((x, i) => x === order[i])))
|
|
448
|
+
for (const n of order) params.append(`r${id}_o`, n);
|
|
322
449
|
} else if ((m = /^r(\d+)_bc$/.exec(k))) {
|
|
323
450
|
// control break
|
|
324
451
|
const [col] = take(m[1], ['bc']);
|
|
@@ -399,7 +526,8 @@ export const q = (col: string) => `"__q".${pg.escapeIdentifier(col)}`;
|
|
|
399
526
|
* A computation that doesn't parse (or whose name is taken) is left out and
|
|
400
527
|
* reported, so the report keeps working and the user can remove it.
|
|
401
528
|
*/
|
|
402
|
-
export async function withComputations(ctx: PageContext, r: Region,
|
|
529
|
+
export async function withComputations(ctx: PageContext, r: Region, src0: string, st: ReportState) {
|
|
530
|
+
const src = withJsonColumns(r, src0);
|
|
403
531
|
if (!st.computations.length) return { src, errors: [] as { c: Computation; message: string }[] };
|
|
404
532
|
const columns = await columnsOf(ctx, src);
|
|
405
533
|
// a computation may only use the shown columns: a hidden one would be shown through it
|
|
@@ -464,7 +592,9 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
|
|
|
464
592
|
}
|
|
465
593
|
where.push(cond);
|
|
466
594
|
}
|
|
467
|
-
|
|
595
|
+
// (143) a date column's filter values may be in the application's date format
|
|
596
|
+
const readDate = st.filters.length ? appDateReader(ctx) : undefined;
|
|
597
|
+
for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value, { date: DATE_OIDS.has(userCols.get(f.column)!), readDate }));
|
|
468
598
|
for (const f of facets) {
|
|
469
599
|
const cond = facetFilterSql(f, cols, p);
|
|
470
600
|
if (cond) where.push(cond);
|
|
@@ -697,6 +827,18 @@ export const visibleColumns = (r: Region, fields: pg.FieldDef[]) => {
|
|
|
697
827
|
return fields.map((f, i) => ({ f, i })).filter(({ f }) => !hidden.has(f.name.toLowerCase()) && !f.name.startsWith('__'));
|
|
698
828
|
};
|
|
699
829
|
|
|
830
|
+
/**
|
|
831
|
+
* (143) The columns a report shows, in the user's order (Actions → Columns: dragged, or moved with the arrows) and
|
|
832
|
+
* without the ones they hid; columns the order doesn't name follow in the source's order.
|
|
833
|
+
*/
|
|
834
|
+
export function orderedColumns(r: Region, fields: pg.FieldDef[], st: { hideCols: string[]; order: string[] }) {
|
|
835
|
+
const hidden = new Set(st.hideCols.map((c) => c.toLowerCase()));
|
|
836
|
+
const cols = visibleColumns(r, fields).filter(({ f }) => !hidden.has(f.name.toLowerCase()));
|
|
837
|
+
if (!st.order.length) return cols;
|
|
838
|
+
const rank = new Map(st.order.map((n, i) => [n, i]));
|
|
839
|
+
return cols.map((c, j) => ({ c, k: rank.get(c.f.name) ?? st.order.length + j })).sort((a, b) => a.k - b.k).map((x) => x.c);
|
|
840
|
+
}
|
|
841
|
+
|
|
700
842
|
export const headingOf = (r: Region, name: string, tr: (s: string) => string = (s) => s) => tr(r.config.headings?.[name] ?? heading(name));
|
|
701
843
|
|
|
702
844
|
/** Rows fetched from the download cursor at a time. */
|
|
@@ -728,7 +870,8 @@ export async function openDownload(ctx: PageContext, r: Region, format: 'csv' |
|
|
|
728
870
|
await savepoint(c, () => c.query({ text: `declare ${cursor} no scroll cursor for ${query.text}`, values: query.values }));
|
|
729
871
|
const next = () => savepoint(c, () => c.query({ text: `fetch ${DOWNLOAD_BATCH} from ${cursor}`, rowMode: 'array' }));
|
|
730
872
|
const first = await next();
|
|
731
|
-
|
|
873
|
+
// (143) the columns as shown: the user's order, without the ones they hid
|
|
874
|
+
const cols = orderedColumns(r, first.fields, st);
|
|
732
875
|
const headings = cols.map(({ f }) => headingOf(r, f.name, ctx.locale.tr));
|
|
733
876
|
return async (write: (chunk: string | Uint8Array) => Promise<void>) => {
|
|
734
877
|
let batch = first.rows;
|
|
@@ -874,7 +1017,8 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
874
1017
|
const hlIdx = st.highlights.map((_, i) => res.fields.findIndex((f) => f.name === `__h${i}`));
|
|
875
1018
|
// (sprint 76) without the columns the user hid (a heading's menu → Hide)
|
|
876
1019
|
const userHidden = new Set(st.hideCols.map((c) => c.toLowerCase()));
|
|
877
|
-
|
|
1020
|
+
// (143) in the user's order
|
|
1021
|
+
const allCols = orderedColumns(r, fields, st);
|
|
878
1022
|
// a control break on a shown column only (its values head the groups)
|
|
879
1023
|
const breakIdx = st.breakCol ? (allCols.find(({ f }) => f.name === st.breakCol)?.i ?? -1) : -1;
|
|
880
1024
|
const cols = allCols.filter(({ i }) => i !== breakIdx);
|
|
@@ -1021,7 +1165,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1021
1165
|
p.delete(key(r, 'p'));
|
|
1022
1166
|
});
|
|
1023
1167
|
const op = OPERATORS[f.op];
|
|
1024
|
-
|
|
1168
|
+
const label = headingOf(r, f.column, ctx.locale.tr);
|
|
1169
|
+
// (143) a list's values, a range's bounds and a period in words: "Job is one of CLERK, ANALYST"
|
|
1170
|
+
return html`<span class="chip">${op.kind ? describeFilter(t, label, f.op, f.value) : html`${label} ${opLabel(t, f.op)}${op.noValue ? '' : html` <b>${f.value}</b>`}`}
|
|
1025
1171
|
<a href="${href}" aria-label="${t('report.remove_filter')}">×</a></span>`;
|
|
1026
1172
|
});
|
|
1027
1173
|
const removeValue = (k: string, raw: string) =>
|
|
@@ -1068,6 +1214,10 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1068
1214
|
if (st.area) chips.push(chip(t('report.map_area'), 'bb'));
|
|
1069
1215
|
if (st.near) chips.push(chip(t('report.near', { km: ctx.locale.number.format(st.near.km) }), 'near'));
|
|
1070
1216
|
}
|
|
1217
|
+
// (143) a column order of the user's own: one chip puts the columns back in the report's order
|
|
1218
|
+
if (st.order.length && allCols.some(({ i }, j) => j > 0 && i < allCols[j - 1].i))
|
|
1219
|
+
chips.push(html`<span class="chip">${t('report.order_chip')}
|
|
1220
|
+
<a href="${regionUrl(ctx, r, (p) => p.delete(key(r, 'o')))}" aria-label="${t('report.order_reset')}" title="${t('report.order_reset')}">×</a></span>`);
|
|
1071
1221
|
for (const c of st.hideCols)
|
|
1072
1222
|
chips.push(html`<span class="chip">${t('report.hidden_column')}: <b>${headingOf(r, c, ctx.locale.tr)}</b>
|
|
1073
1223
|
<a href="${regionUrl(ctx, r, (p) => { const rest = p.getAll(key(r, 'x')).filter((x) => x !== c); p.delete(key(r, 'x')); for (const x of rest) p.append(key(r, 'x'), x); })}" aria-label="${t('report.show_column')}">×</a></span>`);
|
|
@@ -1079,7 +1229,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1079
1229
|
// (sprint 76) Actions is a short menu like APEX's; each entry opens a dialog (app.js), or unfolds in place without JavaScript
|
|
1080
1230
|
const columnsForm = `rx${r.id}`;
|
|
1081
1231
|
if (interactive)
|
|
1082
|
-
ctx.detached.push(html`<form id="${columnsForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hiddenInputs([key(r, 'x'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
1232
|
+
ctx.detached.push(html`<form id="${columnsForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hiddenInputs([key(r, 'x'), key(r, 'o'), key(r, 'p')])}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
|
|
1083
1233
|
const item = (k: string, ic: string, label: string, body: Raw | '') =>
|
|
1084
1234
|
html`<li><details class="ir-item" data-ir-dialog="${k}"><summary>${icon(ic)}<span>${label}</span></summary><div class="ir-body" role="group" aria-label="${label}">${body}</div></details></li>`;
|
|
1085
1235
|
const sub = (ic: string, label: string, items: (Raw | '')[]) =>
|
|
@@ -1098,19 +1248,54 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1098
1248
|
});
|
|
1099
1249
|
return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
|
|
1100
1250
|
})}</div>`;
|
|
1251
|
+
// (143; APEX 26.2: multi-select and date-range filters) Actions → Filter: a column and an operator; "is one of"
|
|
1252
|
+
// takes values separated by commas, or ticked in a list of the column's values with counts (app.js fills it from
|
|
1253
|
+
// the values endpoint); "is between" two values (dates in the application's format, with the date picker on a date
|
|
1254
|
+
// column); "is in" a period (today … last year) on a date column. Without JavaScript every field shows.
|
|
1255
|
+
const filterDialog = () => {
|
|
1256
|
+
const mask = appDateMask(ctx);
|
|
1257
|
+
const months = JSON.stringify(monthNames(ctx.locale.lang));
|
|
1258
|
+
const kindOf = (oid: number) => (DATE_OIDS.has(oid) ? 'date' : NUMERIC_OIDS.has(oid) ? 'number' : 'text');
|
|
1259
|
+
const valueField = (name: string, id: string, label: string) =>
|
|
1260
|
+
html`<span class="input-with-button date-field"><input id="${id}" name="${key(r, name)}" form="${filterForm}" aria-label="${label}" placeholder="${label}" autocomplete="off" data-mask-off="${mask}" data-months="${months}"><button type="button" class="btn" data-date-pick="${id}" aria-label="${t('item.choose_date')}" title="${t('item.choose_date')}" aria-haspopup="dialog" hidden>${icon('calendar')}</button></span>`;
|
|
1261
|
+
return html`<div class="filter-row filter-dialog" data-filter-dialog data-values="${`${ctx.base}/${ctx.page.page_no}/report/${r.id}/values`}" data-list="${key(r, 'fl')}" data-form="${filterForm}">
|
|
1262
|
+
<select name="${key(r, 'fc')}" form="${filterForm}" aria-label="${t('report.column')}" data-filter-column>${cols.map(({ f }) => html`<option value="${f.name}" data-kind="${kindOf(f.dataTypeID)}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1263
|
+
<select name="${key(r, 'fo')}" form="${filterForm}" aria-label="${t('report.operator')}" data-filter-op>${Object.keys(OPERATORS).map((k) => html`<option value="${k}" data-op-kind="${OPERATORS[k].kind ?? (OPERATORS[k].noValue ? 'none' : 'one')}"${k === 'contains' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
|
|
1264
|
+
<span class="filter-field" data-show="one list range">${valueField('fv', `r${r.id}_fv`, t('report.value'))}</span>
|
|
1265
|
+
<span class="filter-field" data-show="range">${valueField('fv2', `r${r.id}_fv2`, t('report.value_to'))}</span>
|
|
1266
|
+
<select class="filter-field" name="${key(r, 'fpr')}" form="${filterForm}" aria-label="${t('report.period')}" data-show="period"><option value="">${t('report.period')}</option>${Object.keys(PERIODS).map((k) => html`<option value="${k}">${t(`period.${k}`)}</option>`)}</select>
|
|
1267
|
+
<div class="filter-values filter-field" data-show="list" hidden role="group" aria-label="${t('report.values')}"></div>
|
|
1268
|
+
<small class="help filter-field" data-show="list range period">${t('report.filter_help')}</small>
|
|
1269
|
+
<button class="btn btn-hot" form="${filterForm}">${t('report.apply')}</button>
|
|
1270
|
+
</div>`;
|
|
1271
|
+
};
|
|
1272
|
+
// (143; APEX 26.2: drag-and-drop columns) Actions → Columns: the columns in their order, a checkbox to hide each,
|
|
1273
|
+
// dragged into another order (app.js) or moved with the arrow buttons (which also work without JavaScript); the
|
|
1274
|
+
// form sends the order as r<id>_o, the default order as r<id>_od so a report back in it needs no parameter.
|
|
1275
|
+
const columnsDialog = () => {
|
|
1276
|
+
const shown = orderedColumns(r, fields, { hideCols: [], order: st.order });
|
|
1277
|
+
const natural = visibleColumns(r, fields).map(({ f }) => f.name);
|
|
1278
|
+
return html`<fieldset class="ir-columns"><legend>${t('report.hide_columns')}</legend>
|
|
1279
|
+
<input type="hidden" name="${key(r, 'od')}" value="${JSON.stringify(natural)}" form="${columnsForm}">
|
|
1280
|
+
<ol class="ir-col-order" data-col-order>
|
|
1281
|
+
${shown.map(({ f }, n) => {
|
|
1282
|
+
const label = headingOf(r, f.name, ctx.locale.tr);
|
|
1283
|
+
return html`<li draggable="true" data-col-name="${f.name}"><span class="drag-handle" aria-hidden="true">${icon('grip-vertical')}</span>
|
|
1284
|
+
<input type="hidden" name="${key(r, 'o')}" value="${f.name}" form="${columnsForm}">
|
|
1285
|
+
<label class="check"><input type="checkbox" name="${key(r, 'x')}" value="${f.name}" form="${columnsForm}"${userHidden.has(f.name.toLowerCase()) ? raw(' checked') : ''}> ${label}</label>
|
|
1286
|
+
<span class="ir-col-move"><button class="btn btn-sm" name="${key(r, 'mv')}" value="u${f.name}" form="${columnsForm}" aria-label="${t('report.move_up', { column: label })}" title="${t('report.move_up', { column: label })}"${n === 0 ? raw(' disabled') : ''}>↑</button><button class="btn btn-sm" name="${key(r, 'mv')}" value="d${f.name}" form="${columnsForm}" aria-label="${t('report.move_down', { column: label })}" title="${t('report.move_down', { column: label })}"${n === shown.length - 1 ? raw(' disabled') : ''}>↓</button></span></li>`;
|
|
1287
|
+
})}
|
|
1288
|
+
</ol></fieldset>
|
|
1289
|
+
<small class="help">${t('report.order_help')}</small>
|
|
1290
|
+
<p class="sr-only" aria-live="polite" data-col-status></p>
|
|
1291
|
+
<button class="btn btn-hot" form="${columnsForm}">${t('report.apply')}</button>`;
|
|
1292
|
+
};
|
|
1101
1293
|
const actionsMenu = interactive
|
|
1102
1294
|
? html`<details class="menu actions-menu">
|
|
1103
1295
|
<summary class="btn">${t('report.actions')} <span aria-hidden="true">▾</span></summary>
|
|
1104
1296
|
<div class="menu-panel"><ul class="ir-menu">
|
|
1105
|
-
${item('columns', 'eye-off', t('report.columns'),
|
|
1106
|
-
|
|
1107
|
-
<button class="btn btn-hot" form="${columnsForm}">${t('report.apply')}</button>`)}
|
|
1108
|
-
${item('filter', 'filter', t('report.filter'), html`<div class="filter-row">
|
|
1109
|
-
<select name="${key(r, 'fc')}" form="${filterForm}" aria-label="${t('report.column')}">${cols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1110
|
-
<select name="${key(r, 'fo')}" form="${filterForm}" aria-label="${t('report.operator')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'contains' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
|
|
1111
|
-
<input name="${key(r, 'fv')}" form="${filterForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
|
|
1112
|
-
<button class="btn btn-hot" form="${filterForm}">${t('report.apply')}</button>
|
|
1113
|
-
</div>`)}
|
|
1297
|
+
${item('columns', 'eye-off', t('report.columns'), columnsDialog())}
|
|
1298
|
+
${item('filter', 'filter', t('report.filter'), filterDialog())}
|
|
1114
1299
|
${sub('table', t('report.data'), [
|
|
1115
1300
|
r.config.sortable === false ? '' : item('sort', 'arrow-down', t('report.sort'), sortLinks),
|
|
1116
1301
|
item('aggregate', 'calculator', t('report.aggregate'), html`<div class="filter-row">
|
|
@@ -1132,7 +1317,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1132
1317
|
${sub('sliders', t('report.format'), [
|
|
1133
1318
|
item('highlight', 'tag', t('report.highlight'), html`<div class="filter-row">
|
|
1134
1319
|
<select name="${key(r, 'hc')}" form="${hlForm}" aria-label="${t('report.column')}">${allCols.map(({ f }) => html`<option value="${f.name}">${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
|
|
1135
|
-
<select name="${key(r, 'ho')}" form="${hlForm}" aria-label="${t('report.operator')}">${
|
|
1320
|
+
<select name="${key(r, 'ho')}" form="${hlForm}" aria-label="${t('report.operator')}">${HIGHLIGHT_OPERATORS.map((k) => html`<option value="${k}"${k === 'eq' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
|
|
1136
1321
|
<input name="${key(r, 'hv')}" form="${hlForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
|
|
1137
1322
|
<select name="${key(r, 'hk')}" form="${hlForm}" aria-label="${t('report.color')}">${HIGHLIGHT_COLORS.map((c) => html`<option value="${c}">${t(`color.${c}`)}</option>`)}</select>
|
|
1138
1323
|
<button class="btn btn-hot" form="${hlForm}">${t('report.apply')}</button></div>`),
|