pgkiln 0.75.0 → 0.77.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/src/instance.ts CHANGED
@@ -54,6 +54,12 @@ function valid(s: InstanceSetting, v: string | undefined): number | null {
54
54
  return Number.isInteger(n) && n >= s.min && n <= s.max ? n : null;
55
55
  }
56
56
 
57
+ /** (sprint 144) A setting's value in effect from rows of meta.setting read elsewhere (the Advisor's own connection). */
58
+ export function settingFrom(key: InstanceKey, rows: { name: string; value: string }[]): number {
59
+ const s = INSTANCE_SETTINGS.find((x) => x.key === key)!;
60
+ return valid(s, rows.find((r) => r.name === key)?.value) ?? fromEnv(s);
61
+ }
62
+
57
63
  /** Read meta.setting again when the cache is older than 30 s (callers that can wait). */
58
64
  export async function refreshInstanceSettings(force = false) {
59
65
  if (!force && Date.now() - loadedAt < REFRESH_MS) return;
@@ -13,7 +13,7 @@ import { applyPlan, checkPlan, chipsOf, ChipRefused, editChip, planIsEmpty, remo
13
13
  import { parseQuestion, type Lookup } from './ir-phrases.ts';
14
14
  import { applyFacetSettings, clearFacets, facetSettings } from './ask-facets.ts';
15
15
  import { describeFacetFilter, reportFacetDefs, type FacetFilter } from './facet-state.ts';
16
- import { AGGREGATES, fieldsOf, headingOf, HIGHLIGHT_COLORS, isNumeric, opLabel, OPERATORS, q, reportState, visibleColumns, withComputations } from './report.ts';
16
+ import { AGGREGATES, describeFilter, fieldsOf, listValues, rangeValues, headingOf, HIGHLIGHT_COLORS, HIGHLIGHT_OPERATORS, isNumeric, opLabel, OPERATORS, PERIODS, q, reportState, visibleColumns, withComputations } from './report.ts';
17
17
  import { resolveRestRegion } from './rest-sources.ts';
18
18
  import { loadContext, simplePage, txContext, type Req } from './routes.ts';
19
19
 
@@ -152,7 +152,7 @@ export function planSchema(columns: Column[]) {
152
152
  properties: {
153
153
  column: { type: 'string', enum: names },
154
154
  operator: { type: 'string', enum: ops },
155
- value: { type: 'string', description: 'The value to compare with (a date as YYYY-MM-DD, a number without thousands separators); empty for null and not_null.' },
155
+ value: { type: 'string', description: `The value to compare with (a date as YYYY-MM-DD, a number without thousands separators); empty for null and not_null; for in and not_in the values separated by commas; for between "from,to" (either may be empty); for period one of ${Object.keys(PERIODS).join(', ')} (date columns only).` },
156
156
  },
157
157
  required: ['column', 'operator', 'value'],
158
158
  additionalProperties: false,
@@ -163,7 +163,7 @@ export function planSchema(columns: Column[]) {
163
163
  description: 'Rows to color (not to filter), at most 5.',
164
164
  items: {
165
165
  type: 'object',
166
- properties: { column: { type: 'string', enum: names }, operator: { type: 'string', enum: ops }, value: { type: 'string' }, color: { type: 'string', enum: HIGHLIGHT_COLORS } },
166
+ properties: { column: { type: 'string', enum: names }, operator: { type: 'string', enum: HIGHLIGHT_OPERATORS }, value: { type: 'string' }, color: { type: 'string', enum: HIGHLIGHT_COLORS } },
167
167
  required: ['column', 'operator', 'value', 'color'],
168
168
  additionalProperties: false,
169
169
  },
@@ -246,7 +246,7 @@ const pageFacets = (ctx: PageContext, rid: number) => reportFacetDefs(ctx.page.r
246
246
  function describePlan(p: Plan, t: T, label: (n: string) => string, facets: FacetFilter[] = []) {
247
247
  return [
248
248
  ...(p.reset ? [t('report.reset')] : []),
249
- ...p.filters.map((f) => `${label(f.column)} ${opLabel(t, f.operator)}${f.value ? ` ${f.value}` : ''}`),
249
+ ...p.filters.map((f) => describeFilter(t, label(f.column), f.operator, f.value)),
250
250
  ...facets.map((f) => describeFacetFilter(f, label(f.column), t)),
251
251
  ...(p.search ? [`"${p.search}"`] : []),
252
252
  ...(p.sort ? [t('ai_filter.sort', { column: `${p.sort.label}${p.desc ? ' ↓' : ''}` })] : []),
@@ -261,9 +261,14 @@ function describePlan(p: Plan, t: T, label: (n: string) => string, facets: Facet
261
261
  ].join('; ');
262
262
  }
263
263
 
264
+ /** (143) a filter's value as its Edit field shows it: a list's values and a range's bounds separated by commas */
265
+ const editValue = (op: string, v: string) =>
266
+ OPERATORS[op]?.kind === 'list' ? listValues(v).join(', ') : OPERATORS[op]?.kind === 'range' ? rangeValues(v).join(', ') : v;
267
+
264
268
  /** A chip's text (plain: it is escaped where it is used). */
265
269
  function chipText(chip: Chip, t: T, label: (n: string) => string, byPos: (pos: number) => string) {
266
- const cond = (column: string, op: string, value: string) => `${label(column)} ${opLabel(t, op)}${OPERATORS[op]?.noValue ? '' : ` ${value}`}`;
270
+ // (143) a list's values, a range's bounds and a period in words
271
+ const cond = (column: string, op: string, value: string) => describeFilter(t, label(column), op, value);
267
272
  switch (chip.kind) {
268
273
  case 'search': return `${t('report.search_chip')}${chip.column ? ` ${label(chip.column)}:` : ''} ${chip.value}`;
269
274
  case 'filter': return cond(chip.column, chip.op, chip.value);
@@ -332,7 +337,7 @@ export async function renderAiFilter(ctx: PageContext, r: Region): Promise<Raw |
332
337
  if (!columns.length) return '';
333
338
  switch (ch.kind) {
334
339
  case 'search': return input(i, 'a', t('report.search'), ch.value);
335
- case 'filter': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), ch.value)}`;
340
+ case 'filter': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), editValue(ch.op, ch.value))}`;
336
341
  case 'highlight': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), ch.value)}${listSelect(i, 'd', t('report.color'), ch.color, HIGHLIGHT_COLORS.map((x) => [x, t(`color.${x}`)]))}`;
337
342
  case 'sort': return html`${colSelect(i, 'a', t('report.column'), columns.find((x) => x.pos === ch.pos)?.name ?? '', (x) => x.pos > 0)}${listSelect(i, 'b', t('report.sort'), ch.desc ? 'desc' : 'asc', [['asc', t('report.sort_asc')], ['desc', t('report.sort_desc')]])}`;
338
343
  case 'break': case 'group': return colSelect(i, 'a', t('report.column'), ch.column);
@@ -1,7 +1,7 @@
1
1
  import { REPORT_CHART_KINDS } from './charts.ts';
2
2
  import { ComputeError, computeNameOk, computeSql } from './compute.ts';
3
3
  import { facetFilters, facetKeys, facetParamNames, type FacetDef } from './facet-state.ts';
4
- import { AGGREGATES, HIGHLIGHT_COLORS, MAX_COMPUTATIONS, OPERATORS } from './report.ts';
4
+ import { AGGREGATES, filterOk, HIGHLIGHT_COLORS, HIGHLIGHT_OPERATORS, MAX_COMPUTATIONS, OPERATORS } from './report.ts';
5
5
 
6
6
  // (sprint 112) What "Ask in your own words" may do to an interactive report
7
7
  // (APEX 26.1/26.2: AI interactive reports). An answer, from the AI service's
@@ -122,9 +122,9 @@ export function checkPlan(json: unknown, columns: Column[], fieldCount = columns
122
122
  .filter((f) => col(f.column) && typeof f.operator === 'string' && Object.hasOwn(OPERATORS, f.operator) && typeof f.value === 'string')
123
123
  .slice(0, MAX_FILTERS)
124
124
  .map((f) => ({ column: f.column as string, operator: f.operator as string, value: OPERATORS[f.operator as string].noValue ? '' : text(f.value) }))
125
- .filter((f) => OPERATORS[f.operator].noValue || f.value !== '');
125
+ .filter((f) => OPERATORS[f.operator].noValue || (f.value !== '' && filterOk(f.operator, f.value)));
126
126
  const highlights = list<{ column: unknown; operator: unknown; value: unknown; color: unknown }>(a.highlights)
127
- .filter((h) => col(h.column) && typeof h.operator === 'string' && Object.hasOwn(OPERATORS, h.operator) && typeof h.value === 'string')
127
+ .filter((h) => col(h.column) && typeof h.operator === 'string' && HIGHLIGHT_OPERATORS.includes(h.operator) && typeof h.value === 'string')
128
128
  .slice(0, MAX_HIGHLIGHTS)
129
129
  .map((h) => ({
130
130
  column: h.column as string, operator: h.operator as string, value: OPERATORS[h.operator as string].noValue ? '' : text(h.value),
@@ -6,7 +6,7 @@ import type { Region } from '../metadata.ts';
6
6
  import { runtime, savepoint } from '../db.ts';
7
7
  import { substitute, type PageContext } from './context.ts';
8
8
  import { describeFacetFilter, facetFilters, reportFacetDefs } from './facet-state.ts';
9
- import { buildSql, cell, columnFormats, headingOf, isNumeric, maxRows, OPERATORS, PDF_MAX_ROWS, reportState, visibleColumns } from './report.ts';
9
+ import { buildSql, cell, columnFormats, describeFilter, headingOf, isNumeric, maxRows, orderedColumns, PDF_MAX_ROWS, reportState } from './report.ts';
10
10
 
11
11
  // Report printing: Actions → Download PDF. The same query, filters, sort
12
12
  // and visibility as the report on screen (and as the CSV download), drawn
@@ -134,10 +134,10 @@ export function layoutText(text: string, extra: Record<string, string>, ctx?: Pa
134
134
  return ctx ? substitute(replaced, ctx, (v) => v) : replaced;
135
135
  }
136
136
 
137
- /** The columns a report prints, in order: its "pdf.columns", or those on screen. */
138
- function printColumns(r: Region, fields: pg.FieldDef[]) {
137
+ /** The columns a report prints, in order: its "pdf.columns", or those on screen (143: in the user's order, without the ones they hid). */
138
+ function printColumns(r: Region, fields: pg.FieldDef[], st: { hideCols: string[]; order: string[] }) {
139
139
  const wanted = (r.config.pdf as RegionPdf | undefined)?.columns;
140
- if (!Array.isArray(wanted) || !wanted.length) return visibleColumns(r, fields);
140
+ if (!Array.isArray(wanted) || !wanted.length) return orderedColumns(r, fields, st);
141
141
  const byName = new Map(fields.map((f, i) => [f.name.toLowerCase(), { f, i }]));
142
142
  return wanted.flatMap((n) => {
143
143
  const c = typeof n === 'string' ? byName.get(n.toLowerCase()) : undefined;
@@ -158,7 +158,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
158
158
  await savepoint(c, () => c.query({ text: `declare ${cursor} no scroll cursor for ${query.text}`, values: query.values }));
159
159
  const fetch = () => savepoint(c, () => c.query({ text: `fetch ${PDF_BATCH} from ${cursor}`, rowMode: 'array' }));
160
160
  const first = await fetch();
161
- const cols = printColumns(r, first.fields);
161
+ const cols = printColumns(r, first.fields, st);
162
162
  const limit = Math.min(PDF_MAX_ROWS, maxRows(r, null) ?? PDF_MAX_ROWS);
163
163
  let truncated = false;
164
164
  const fmtOf = columnFormats(ctx, r);
@@ -190,7 +190,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
190
190
  const title = text(layout.title ?? '&REPORT_TITLE.');
191
191
  const filters = layout.show_filters
192
192
  ? [
193
- ...st.filters.map((f) => `${headingOf(r, f.column, ctx.locale.tr)} ${OPERATORS[f.op]?.label ?? f.op} ${OPERATORS[f.op]?.noValue ? '' : f.value}`.trim()),
193
+ ...st.filters.map((f) => describeFilter(t, headingOf(r, f.column, ctx.locale.tr), f.op, f.value)),
194
194
  ...(() => {
195
195
  const defs = reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions);
196
196
  return facetFilters(ctx.params, r.id, defs, undefined, appDateReader(ctx)).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
@@ -327,7 +327,7 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
327
327
  'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied', 'da.wait',
328
328
  'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
329
329
  // (sprint 76) a report column heading's menu
330
- 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
330
+ 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values', 'report.moved',
331
331
  // (sprint 84) the date picker
332
332
  'item.choose_date', 'calendar.previous', 'calendar.next', 'calendar.today', 'facets.clear_short'] as const;
333
333
  export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
@@ -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>`),