pgkiln 0.75.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.
@@ -9,7 +9,8 @@ import { esc, html, raw, type Raw } from '../html.ts';
9
9
  import type { Region } from '../metadata.ts';
10
10
  import { isAuthorized, pageAllowed } from './authz.ts';
11
11
  import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
12
- import { appDateReader, heading, splitValues } from './items.ts';
12
+ import { appDateMask, appDateReader, heading, splitValues } from './items.ts';
13
+ import { monthNames } from './format.ts';
13
14
  import { linkAttrs } from './links.ts';
14
15
  import type { Translate } from '../i18n.ts';
15
16
  import type { Formatter } from './format.ts';
@@ -19,7 +20,7 @@ import { ComputeError, computeNameOk, computeSql, type Computation } from './com
19
20
  import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
20
21
  import { columnTemplates } from './template-components.ts';
21
22
  import { textSearchConfig } from './search.ts';
22
- 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';
23
24
  import { resolveRestRegion } from './rest-sources.ts';
24
25
  import { checksumValid, signText } from '../security.ts';
25
26
  import { parseArea, parseNear, postgis, spatialConditions, type MapArea, type Near } from './spatial.ts';
@@ -142,7 +143,65 @@ export function pageWindow(r: Region, st: { page: number; size: number }) {
142
143
  return { page, offset, limit: max ? Math.max(1, Math.min(st.size + 1, max - offset)) : st.size + 1, max };
143
144
  }
144
145
 
145
- 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
+ }> = {
146
205
  eq: { label: '=', sql: (c, v) => `${c} = ${literal(v)}` },
147
206
  ne: { label: '≠', sql: (c, v) => `${c} is distinct from ${literal(v)}` },
148
207
  contains: { label: 'contains', sql: (c, v) => `${c}::text ilike ${literal(`%${escapeLike(v)}%`)}` },
@@ -153,13 +212,56 @@ export const OPERATORS: Record<string, { label: string; sql: (col: string, v: st
153
212
  le: { label: '≤', sql: (c, v) => `${c} <= ${literal(v)}` },
154
213
  null: { label: 'is empty', sql: (c) => `${c} is null`, noValue: true },
155
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') },
156
238
  };
157
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
+
158
259
  const escapeLike = (v: string) => v.replace(/[\\%_]/g, '\\$&');
159
260
 
160
261
  /** Operator names in the user's language (symbols stay as they are). */
161
262
  export const opLabel = (t: Translate, op: string) =>
162
- ({ 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;
163
265
 
164
266
  interface Filter {
165
267
  column: string;
@@ -193,6 +295,8 @@ export interface ReportState {
193
295
  searchCol: string | null;
194
296
  /** (sprint 76) columns the user hid (a column heading's menu → Hide) */
195
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[];
196
300
  sort: number;
197
301
  desc: boolean;
198
302
  page: number;
@@ -248,13 +352,14 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
248
352
  search: (p.get(key(r, 'q')) ?? '').trim(),
249
353
  searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
250
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),
251
356
  sort: r.config.sortable === false ? 0 : Math.max(0, Math.min(1000, parseInt(p.get(key(r, 's')) ?? '0', 10) || 0)),
252
357
  desc: p.get(key(r, 'd')) === 'desc',
253
358
  page: Math.max(1, Math.min(MAX_PAGE, parseInt(p.get(key(r, 'p')) ?? '1', 10) || 1)),
254
359
  size: Math.max(1, Math.min(500, size)),
255
360
  filters: p.getAll(key(r, 'f')).flatMap((raw) => {
256
361
  const [column, op, ...rest] = raw.split('|');
257
- return column && OPERATORS[op] ? [{ column, op, value: rest.join('|'), raw }] : [];
362
+ return column && filterOk(op, rest.join('|')) ? [{ column, op, value: rest.join('|'), raw }] : [];
258
363
  }),
259
364
  breakCol: p.get(key(r, 'b')) || null,
260
365
  aggregates: p.getAll(key(r, 'a')).slice(0, 20).flatMap((raw) => {
@@ -264,7 +369,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
264
369
  // column|operator|color|value (the value last: it may contain "|")
265
370
  highlights: p.getAll(key(r, 'h')).slice(0, 10).flatMap((raw) => {
266
371
  const [column, op, color, ...rest] = raw.split('|');
267
- 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 }] : [];
268
373
  }),
269
374
  // name|expression (the expression last: it may contain "|")
270
375
  computations: p.getAll(key(r, 'c')).slice(0, MAX_COMPUTATIONS).flatMap((raw) => {
@@ -294,7 +399,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
294
399
  st.seek = parseSeek(ctx, r, st);
295
400
  // (140) a classic report has none of an interactive report's settings: sorting, paging and (when searchable) the search only
296
401
  if (classic) {
297
- 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',
298
403
  groupBy: { columns: [], functions: [] }, pivot: null, chart: null, area: null, near: null });
299
404
  if (r.config.searchable !== true) st.search = '';
300
405
  if (r.config.pagination === 'none') st.page = 1;
@@ -317,9 +422,30 @@ export function normaliseReportParams(params: URLSearchParams): string | null {
317
422
  for (const k of [...params.keys()]) {
318
423
  let m: RegExpExecArray | null;
319
424
  if ((m = /^r(\d+)_fc$/.exec(k))) {
320
- // filter: column, operator, value
321
- const [col, op, val] = take(m[1], ['fc', 'fo', 'fv']);
322
- 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);
323
449
  } else if ((m = /^r(\d+)_bc$/.exec(k))) {
324
450
  // control break
325
451
  const [col] = take(m[1], ['bc']);
@@ -466,7 +592,9 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
466
592
  }
467
593
  where.push(cond);
468
594
  }
469
- 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 }));
470
598
  for (const f of facets) {
471
599
  const cond = facetFilterSql(f, cols, p);
472
600
  if (cond) where.push(cond);
@@ -699,6 +827,18 @@ export const visibleColumns = (r: Region, fields: pg.FieldDef[]) => {
699
827
  return fields.map((f, i) => ({ f, i })).filter(({ f }) => !hidden.has(f.name.toLowerCase()) && !f.name.startsWith('__'));
700
828
  };
701
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
+
702
842
  export const headingOf = (r: Region, name: string, tr: (s: string) => string = (s) => s) => tr(r.config.headings?.[name] ?? heading(name));
703
843
 
704
844
  /** Rows fetched from the download cursor at a time. */
@@ -730,7 +870,8 @@ export async function openDownload(ctx: PageContext, r: Region, format: 'csv' |
730
870
  await savepoint(c, () => c.query({ text: `declare ${cursor} no scroll cursor for ${query.text}`, values: query.values }));
731
871
  const next = () => savepoint(c, () => c.query({ text: `fetch ${DOWNLOAD_BATCH} from ${cursor}`, rowMode: 'array' }));
732
872
  const first = await next();
733
- 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);
734
875
  const headings = cols.map(({ f }) => headingOf(r, f.name, ctx.locale.tr));
735
876
  return async (write: (chunk: string | Uint8Array) => Promise<void>) => {
736
877
  let batch = first.rows;
@@ -876,7 +1017,8 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
876
1017
  const hlIdx = st.highlights.map((_, i) => res.fields.findIndex((f) => f.name === `__h${i}`));
877
1018
  // (sprint 76) without the columns the user hid (a heading's menu → Hide)
878
1019
  const userHidden = new Set(st.hideCols.map((c) => c.toLowerCase()));
879
- 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);
880
1022
  // a control break on a shown column only (its values head the groups)
881
1023
  const breakIdx = st.breakCol ? (allCols.find(({ f }) => f.name === st.breakCol)?.i ?? -1) : -1;
882
1024
  const cols = allCols.filter(({ i }) => i !== breakIdx);
@@ -1023,7 +1165,9 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1023
1165
  p.delete(key(r, 'p'));
1024
1166
  });
1025
1167
  const op = OPERATORS[f.op];
1026
- 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>`}`}
1027
1171
  <a href="${href}" aria-label="${t('report.remove_filter')}">×</a></span>`;
1028
1172
  });
1029
1173
  const removeValue = (k: string, raw: string) =>
@@ -1070,6 +1214,10 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1070
1214
  if (st.area) chips.push(chip(t('report.map_area'), 'bb'));
1071
1215
  if (st.near) chips.push(chip(t('report.near', { km: ctx.locale.number.format(st.near.km) }), 'near'));
1072
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>`);
1073
1221
  for (const c of st.hideCols)
1074
1222
  chips.push(html`<span class="chip">${t('report.hidden_column')}: <b>${headingOf(r, c, ctx.locale.tr)}</b>
1075
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>`);
@@ -1081,7 +1229,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1081
1229
  // (sprint 76) Actions is a short menu like APEX's; each entry opens a dialog (app.js), or unfolds in place without JavaScript
1082
1230
  const columnsForm = `rx${r.id}`;
1083
1231
  if (interactive)
1084
- 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>`);
1085
1233
  const item = (k: string, ic: string, label: string, body: Raw | '') =>
1086
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>`;
1087
1235
  const sub = (ic: string, label: string, items: (Raw | '')[]) =>
@@ -1100,19 +1248,54 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1100
1248
  });
1101
1249
  return html`<a href="${href}"${active ? raw(' aria-current="true"') : ''}>${headingOf(r, f.name, ctx.locale.tr)}${active ? (st.desc ? ' ▼' : ' ▲') : ''}</a>`;
1102
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
+ };
1103
1293
  const actionsMenu = interactive
1104
1294
  ? html`<details class="menu actions-menu">
1105
1295
  <summary class="btn">${t('report.actions')} <span aria-hidden="true">▾</span></summary>
1106
1296
  <div class="menu-panel"><ul class="ir-menu">
1107
- ${item('columns', 'eye-off', t('report.columns'), html`<fieldset class="ir-columns"><legend>${t('report.hide_columns')}</legend>
1108
- ${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>
1109
- <button class="btn btn-hot" form="${columnsForm}">${t('report.apply')}</button>`)}
1110
- ${item('filter', 'filter', t('report.filter'), html`<div class="filter-row">
1111
- <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>
1112
- <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>
1113
- <input name="${key(r, 'fv')}" form="${filterForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
1114
- <button class="btn btn-hot" form="${filterForm}">${t('report.apply')}</button>
1115
- </div>`)}
1297
+ ${item('columns', 'eye-off', t('report.columns'), columnsDialog())}
1298
+ ${item('filter', 'filter', t('report.filter'), filterDialog())}
1116
1299
  ${sub('table', t('report.data'), [
1117
1300
  r.config.sortable === false ? '' : item('sort', 'arrow-down', t('report.sort'), sortLinks),
1118
1301
  item('aggregate', 'calculator', t('report.aggregate'), html`<div class="filter-row">
@@ -1134,7 +1317,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1134
1317
  ${sub('sliders', t('report.format'), [
1135
1318
  item('highlight', 'tag', t('report.highlight'), html`<div class="filter-row">
1136
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>
1137
- <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>
1138
1321
  <input name="${key(r, 'hv')}" form="${hlForm}" aria-label="${t('report.value')}" placeholder="${t('report.value')}">
1139
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>
1140
1323
  <button class="btn btn-hot" form="${hlForm}">${t('report.apply')}</button></div>`),