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/docs/apex-feature-parity.md +6 -4
- package/docs/guide/03-builder.md +9 -1
- package/docs/guide/04-pages-and-regions.md +23 -2
- package/docs/guide/12-development.md +4 -1
- package/docs/guide/18-cli.md +74 -0
- package/docs/guide/20-ai-agents.md +3 -0
- package/package.json +1 -1
- package/public/app.css +18 -0
- package/public/app.js +141 -0
- package/src/builder/advisor.ts +50 -1
- package/src/builder/workingcopies.ts +1 -11
- package/src/cli/advise.ts +177 -0
- package/src/cli/apps.ts +1 -1
- package/src/cli/main.ts +149 -0
- package/src/cli/mcp.ts +93 -0
- package/src/cli/working-copy.ts +121 -0
- package/src/i18n/ar.ts +24 -0
- package/src/i18n/cs.ts +24 -0
- package/src/i18n/da.ts +24 -0
- package/src/i18n/de.ts +24 -0
- package/src/i18n/el.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/fi.ts +24 -0
- package/src/i18n/fr.ts +24 -0
- package/src/i18n/he.ts +24 -0
- package/src/i18n/it.ts +24 -0
- package/src/i18n/ja.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/i18n/nb.ts +24 -0
- package/src/i18n/pl.ts +24 -0
- package/src/i18n/pt.ts +24 -0
- package/src/i18n/ru.ts +24 -0
- package/src/i18n/sv.ts +24 -0
- package/src/i18n/tr.ts +24 -0
- package/src/i18n/uk.ts +24 -0
- package/src/i18n/zh.ts +24 -0
- package/src/i18n.ts +48 -0
- package/src/instance.ts +6 -0
- package/src/runtime/ai-filter.ts +11 -6
- package/src/runtime/ir-plan.ts +3 -3
- package/src/runtime/pdf.ts +6 -6
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +208 -25
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;
|
package/src/runtime/ai-filter.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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);
|
package/src/runtime/ir-plan.ts
CHANGED
|
@@ -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' &&
|
|
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),
|
package/src/runtime/pdf.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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) =>
|
|
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)));
|
package/src/runtime/render.ts
CHANGED
|
@@ -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)]));
|
package/src/runtime/report.ts
CHANGED
|
@@ -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
|
-
|
|
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')
|
|
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 &&
|
|
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 &&
|
|
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
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'),
|
|
1108
|
-
|
|
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')}">${
|
|
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>`),
|