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.
Files changed (56) hide show
  1. package/db/migrations/142_json_sources.sql +426 -0
  2. package/docs/apex-feature-parity.md +7 -6
  3. package/docs/guide/03-builder.md +15 -0
  4. package/docs/guide/04-pages-and-regions.md +24 -2
  5. package/docs/guide/12-development.md +11 -0
  6. package/docs/guide/19-rest-data-sources.md +108 -1
  7. package/examples/hr/hr_77_json_sources.sql +174 -0
  8. package/package.json +1 -1
  9. package/public/app.css +18 -0
  10. package/public/app.js +141 -0
  11. package/src/app.ts +5 -0
  12. package/src/builder/document-view.ts +117 -0
  13. package/src/builder/i18n/de.ts +1 -0
  14. package/src/builder/i18n/en.ts +1 -0
  15. package/src/builder/i18n/es.ts +1 -0
  16. package/src/builder/i18n/fr.ts +1 -0
  17. package/src/builder/i18n/it.ts +1 -0
  18. package/src/builder/i18n/ja.ts +1 -0
  19. package/src/builder/i18n/ko.ts +1 -0
  20. package/src/builder/i18n/nl.ts +1 -0
  21. package/src/builder/i18n/pt.ts +1 -0
  22. package/src/builder/i18n/zh-cn.ts +1 -0
  23. package/src/builder/i18n/zh-tw.ts +1 -0
  24. package/src/builder/json-sources.ts +395 -0
  25. package/src/builder/object-actions.ts +1 -0
  26. package/src/builder/report-settings.ts +9 -0
  27. package/src/builder/shared.ts +1 -1
  28. package/src/builder/table-editor.ts +1 -0
  29. package/src/builder/websources.ts +6 -3
  30. package/src/i18n/ar.ts +24 -0
  31. package/src/i18n/cs.ts +24 -0
  32. package/src/i18n/da.ts +24 -0
  33. package/src/i18n/de.ts +24 -0
  34. package/src/i18n/el.ts +24 -0
  35. package/src/i18n/es.ts +24 -0
  36. package/src/i18n/fi.ts +24 -0
  37. package/src/i18n/fr.ts +24 -0
  38. package/src/i18n/he.ts +24 -0
  39. package/src/i18n/it.ts +24 -0
  40. package/src/i18n/ja.ts +24 -0
  41. package/src/i18n/ko.ts +24 -0
  42. package/src/i18n/nb.ts +24 -0
  43. package/src/i18n/pl.ts +24 -0
  44. package/src/i18n/pt.ts +24 -0
  45. package/src/i18n/ru.ts +24 -0
  46. package/src/i18n/sv.ts +24 -0
  47. package/src/i18n/tr.ts +24 -0
  48. package/src/i18n/uk.ts +24 -0
  49. package/src/i18n/zh.ts +24 -0
  50. package/src/i18n.ts +48 -0
  51. package/src/json-sources.ts +365 -0
  52. package/src/runtime/ai-filter.ts +11 -6
  53. package/src/runtime/ir-plan.ts +3 -3
  54. package/src/runtime/pdf.ts +6 -6
  55. package/src/runtime/render.ts +1 -1
  56. package/src/runtime/report.ts +211 -26
@@ -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
- export const OPERATORS: Record<string, { label: string; sql: (col: string, v: string) => string; noValue?: boolean }> = {
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') } as Record<string, string>)[op] ?? OPERATORS[op]?.label ?? op;
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 && OPERATORS[op] ? [{ column, op, value: rest.join('|'), raw }] : [];
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 && OPERATORS[op] && HIGHLIGHT_COLORS.includes(color) ? [{ column, op, color, value: rest.join('|'), raw }] : [];
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 [col, op, val] = take(m[1], ['fc', 'fo', 'fv']);
321
- if (col && OPERATORS[op || 'eq']) params.append(`r${m[1]}_f`, `${col}|${op || 'eq'}|${val}`);
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, src: string, st: ReportState) {
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
- for (const f of st.filters) if (userCols.has(f.column)) where.push(OPERATORS[f.op].sql(q(f.column), f.value));
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
- const cols = visibleColumns(r, first.fields);
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
- const allCols = visibleColumns(r, fields).filter(({ f }) => !userHidden.has(f.name.toLowerCase()));
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
- return html`<span class="chip">${headingOf(r, f.column, ctx.locale.tr)} ${opLabel(t, f.op)}${op.noValue ? '' : html` <b>${f.value}</b>`}
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'), html`<fieldset class="ir-columns"><legend>${t('report.hide_columns')}</legend>
1106
- ${visibleColumns(r, fields).map(({ f }) => html`<label class="check"><input type="checkbox" name="${key(r, 'x')}" value="${f.name}" form="${columnsForm}"${userHidden.has(f.name.toLowerCase()) ? raw(' checked') : ''}> ${headingOf(r, f.name, ctx.locale.tr)}</label>`)}</fieldset>
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')}">${Object.keys(OPERATORS).map((k) => html`<option value="${k}"${k === 'eq' ? raw(' selected') : ''}>${opLabel(t, k)}</option>`)}</select>
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>`),