pgkiln 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/db/migrations/100_generated_labels.sql +381 -0
  2. package/db/migrations/101_theme_choice.sql +15 -0
  3. package/db/migrations/102_app_computations.sql +345 -0
  4. package/db/migrations/103_shortcuts.sql +341 -0
  5. package/db/migrations/104_report_queries.sql +413 -0
  6. package/db/migrations/105_component_settings_groups.sql +367 -0
  7. package/docs/apex-feature-parity.md +12 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +48 -9
  10. package/docs/guide/04-pages-and-regions.md +20 -4
  11. package/docs/guide/06-processing.md +22 -0
  12. package/docs/guide/09-reference.md +14 -0
  13. package/docs/guide/12-development.md +2 -1
  14. package/docs/guide/14-globalization.md +5 -1
  15. package/docs/guide/16-files.md +23 -0
  16. package/docs/guide/18-cli.md +4 -0
  17. package/package.json +1 -1
  18. package/public/app.css +40 -1
  19. package/public/app.js +69 -1
  20. package/src/appfiles.ts +18 -2
  21. package/src/builder/advisor.ts +7 -2
  22. package/src/builder/appsheets.ts +53 -13
  23. package/src/builder/appwizard.ts +5 -3
  24. package/src/builder/code-editor.ts +6 -1
  25. package/src/builder/components.ts +85 -3
  26. package/src/builder/forms.ts +1 -1
  27. package/src/builder/shared.ts +39 -6
  28. package/src/builder/subscriptions.ts +30 -4
  29. package/src/cli/replace.ts +6 -2
  30. package/src/i18n/ar.ts +2 -0
  31. package/src/i18n/cs.ts +2 -0
  32. package/src/i18n/da.ts +2 -0
  33. package/src/i18n/de.ts +2 -0
  34. package/src/i18n/el.ts +2 -0
  35. package/src/i18n/es.ts +2 -0
  36. package/src/i18n/fi.ts +2 -0
  37. package/src/i18n/fr.ts +2 -0
  38. package/src/i18n/he.ts +2 -0
  39. package/src/i18n/it.ts +2 -0
  40. package/src/i18n/ja.ts +2 -0
  41. package/src/i18n/ko.ts +2 -0
  42. package/src/i18n/nb.ts +2 -0
  43. package/src/i18n/pl.ts +2 -0
  44. package/src/i18n/pt.ts +2 -0
  45. package/src/i18n/ru.ts +2 -0
  46. package/src/i18n/sv.ts +2 -0
  47. package/src/i18n/tr.ts +2 -0
  48. package/src/i18n/uk.ts +2 -0
  49. package/src/i18n/zh.ts +2 -0
  50. package/src/i18n.ts +4 -0
  51. package/src/metadata.ts +14 -1
  52. package/src/runtime/account.ts +5 -5
  53. package/src/runtime/component-settings.ts +42 -0
  54. package/src/runtime/context.ts +4 -0
  55. package/src/runtime/devbar.ts +16 -13
  56. package/src/runtime/documents.ts +29 -3
  57. package/src/runtime/engine.ts +2 -2
  58. package/src/runtime/facets.ts +34 -3
  59. package/src/runtime/grid.ts +22 -4
  60. package/src/runtime/items.ts +14 -11
  61. package/src/runtime/locale.ts +4 -1
  62. package/src/runtime/logic.ts +12 -7
  63. package/src/runtime/regions.ts +4 -3
  64. package/src/runtime/render.ts +8 -2
  65. package/src/runtime/report.ts +16 -10
  66. package/src/runtime/routes.ts +9 -6
  67. package/src/runtime/shortcuts.ts +43 -0
  68. package/src/security.ts +2 -1
  69. package/src/subscriptions.ts +67 -0
@@ -274,6 +274,45 @@ interface Catalog {
274
274
  fk_table: string | null;
275
275
  }
276
276
 
277
+ /** (sprint 79) A number column that holds an amount: a dashboard sums it (Salary by Department). */
278
+ const AMOUNT = /(^|_)(sal|salary|salaries|amount|amt|total|price|cost|costs|revenue|value|budget|balance|fee|fees|pay|wage|wages)(_|$)/i;
279
+
280
+ interface ChartPlan {
281
+ table: string;
282
+ group: string;
283
+ /** count, or sum of `value` */
284
+ fn: 'count' | 'sum';
285
+ value: string | null;
286
+ kind: 'donut' | 'line' | 'bar';
287
+ }
288
+
289
+ /**
290
+ * (sprint 79, GEN-6) How a table is charted: by its first foreign key that says something, else a text,
291
+ * yes/no or date column whose values repeat; the sum of an amount column when there is one, else the
292
+ * number of rows; a donut for six groups or fewer, a line for dates, else a sorted bar. A grouping that
293
+ * says nothing (one group, or every group the same) is skipped.
294
+ */
295
+ async function chartPlan(c: Client, table: string, catalog: Catalog[], rows: number): Promise<ChartPlan | null> {
296
+ const amount = catalog.find((x) => x.kind === 'number' && !x.is_pk && !x.fk_table && AMOUNT.test(x.column_name))?.column_name ?? null;
297
+ const groups = [...catalog.filter((x) => x.fk_table && !x.is_pk).map((x) => x.column_name), groupColumn(catalog, rows)].filter(
298
+ (g, i, all): g is string => !!g && all.indexOf(g) === i,
299
+ );
300
+ for (const group of groups) {
301
+ for (const value of amount ? [amount, null] : [null]) {
302
+ const measure = value ? `sum(${pg.escapeIdentifier(value)})` : 'count(*)';
303
+ const spread = (
304
+ await c.query<{ groups: number; lo: string | null; hi: string | null }>(
305
+ `select count(*)::int as groups, min(v)::text as lo, max(v)::text as hi from (select ${pg.escapeIdentifier(group)} as g, ${measure} as v from ${table} group by 1) x`,
306
+ )
307
+ ).rows[0];
308
+ if (spread.groups < 2 || spread.lo === spread.hi) continue;
309
+ const kind = catalog.find((x) => x.column_name === group)?.kind === 'date' ? 'line' : spread.groups <= 6 ? 'donut' : 'bar';
310
+ return { table, group, fn: value ? 'sum' : 'count', value, kind };
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
277
316
  /**
278
317
  * A dashboard page: a chart per table (at most six) with the number of rows per
279
318
  * parent row (the first foreign key), or per value of a text, yes/no or date
@@ -283,7 +322,7 @@ interface Catalog {
283
322
  * Manager", and the Dashboard comes right after Home in the navigation menu.
284
323
  */
285
324
  export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
286
- const picks: { table: string; group: string }[] = [];
325
+ const picks: ChartPlan[] = [];
287
326
  for (const t of tables) {
288
327
  if (picks.length >= MAX_CHARTS) break;
289
328
  const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
@@ -292,24 +331,25 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
292
331
  continue;
293
332
  }
294
333
  const catalog = (await c.query<Catalog>('select column_name, kind, is_pk, distinct_values, fk_table::text from meta.wizard_catalog($1::regclass)', [t])).rows;
295
- const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
296
- if (group) picks.push({ table: t, group });
334
+ const plan = await chartPlan(c, t, catalog, Number(rel.n));
335
+ if (plan) picks.push(plan);
297
336
  }
298
337
  if (!picks.length) return null;
299
- const generate = (table: string, group: string, no: number, nav: boolean) =>
338
+ const generate = (p: ChartPlan, no: number, nav: boolean) =>
300
339
  c.query<{ id: number }>('select meta.generate_page($1, \'chart\', $2::regclass, $3, $4::jsonb) as id', [
301
- alias, table, no,
302
- JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
340
+ alias, p.table, no,
341
+ JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: p.group, function: p.fn, value_column: p.value, chart: p.kind }),
303
342
  ]);
304
- const title = async (table: string, group: string) =>
305
- (await c.query(`select meta.wizard_table_label($1::regclass) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [table, group])).rows[0].t as string;
306
- const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
307
- await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
343
+ // Employees by Manager; a sum: Salary by Department
344
+ const title = async (p: ChartPlan) =>
345
+ (await c.query(`select coalesce(meta.wizard_column_label($1::regclass, $3), meta.wizard_table_label($1::regclass)) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [p.table, p.group, p.value])).rows[0].t as string;
346
+ const dash = (await generate(picks[0], page, true)).rows[0].id;
347
+ await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0])]);
308
348
  for (const [n, p] of picks.slice(1).entries()) {
309
349
  // generated on a page of its own, then moved onto the dashboard
310
350
  const spare = (await c.query('select max(page_no) + 1000 as n from meta.page where app_id = (select id from meta.app where alias = $1)', [alias])).rows[0].n as number;
311
- const tmp = (await generate(p.table, p.group, spare, false)).rows[0].id;
312
- await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p.table, p.group)]);
351
+ const tmp = (await generate(p, spare, false)).rows[0].id;
352
+ await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p)]);
313
353
  await c.query('delete from meta.page where id = $1', [tmp]);
314
354
  }
315
355
  if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
@@ -319,7 +359,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
319
359
  from meta.app a where a.alias = $1 and e.app_id = a.id and e.parent_id is null and e.target_page = $2`,
320
360
  [alias, page],
321
361
  );
322
- return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
362
+ return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p)))).join(', ')})` };
323
363
  }
324
364
 
325
365
  /** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
@@ -40,6 +40,8 @@ export const pastedName = (title: string) =>
40
40
 
41
41
  interface Relation {
42
42
  relname: string;
43
+ /** (sprint 78) the friendly name of its pages: Employees */
44
+ label: string;
43
45
  kind: 'table' | 'view';
44
46
  pk: string | null;
45
47
  /** the planner's estimate (null: never analyzed) */
@@ -54,7 +56,7 @@ interface Relation {
54
56
  export async function schemaRelations(schema: string): Promise<Relation[]> {
55
57
  return (
56
58
  await owner.query<Relation>(
57
- `select c.relname, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
59
+ `select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
58
60
  meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
59
61
  meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
60
62
  meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
@@ -350,9 +352,9 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
350
352
  ${known
351
353
  ? relations.length
352
354
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
353
- <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.relname}</label>
355
+ <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
354
356
  <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
355
- <small class="muted">${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
357
+ <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
356
358
  : html`<li class="muted">${schema} has no tables or views.</li>`
357
359
  : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
358
360
  </ul>
@@ -32,7 +32,7 @@ const CHECKS: Record<string, SqlShape> = {
32
32
  'region.source': 'select', 'region.condition': 'boolean', 'item.lov': 'select', 'item.readonly_condition': 'boolean',
33
33
  'button.condition': 'boolean', 'validation.expression': 'boolean', 'process.code': 'statements', 'authz_scheme.value': 'boolean',
34
34
  'lov.query': 'select', 'app_process.code': 'statements', 'automation.query': 'select', 'automation_action.code': 'statements', 'automation_action.condition': 'boolean',
35
- 'document_template.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
35
+ 'document_template.query': 'select', 'report_query.query': 'select', 'task_definition.action_code': 'statements', 'dynamic_action.code': 'statements',
36
36
  'button.badge_query': 'select', 'list.query': 'select', 'list_entry.condition': 'boolean',
37
37
  };
38
38
  /** Fields whose check follows the component's type select, like SWITCH ("none": no check). */
@@ -41,7 +41,9 @@ const CHECK_SWITCH: Record<string, string> = {
41
41
  'process.code': 'type:sql=statements,*=none',
42
42
  'region.source': 'type:static=none,form=none,facets=none,smart_filters=none,display_selector=none,tasks=none,workflows=none,list=none,data_reporter=none,ai_assistant=none,*=select',
43
43
  'computation.expression': 'type:sql_query=select,*=none',
44
+ 'app_computation.expression': 'type:sql_query=select,*=none',
44
45
  'computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
46
+ 'app_computation.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
45
47
  'branch.condition_expr': 'condition_type:sql=boolean,exists=select,not_exists=select,*=none',
46
48
  };
47
49
  /** Fields whose language follows the component's type select: "type:value=lang,…" (other values: SQL). */
@@ -51,7 +53,10 @@ const SWITCH: Record<string, string> = {
51
53
  'authz_scheme.value': 'type:role=text',
52
54
  'dynamic_action.code': 'action:set_value=sql',
53
55
  'computation.expression': 'type:static=text,item=text,function_body=plpgsql',
56
+ 'app_computation.expression': 'type:static=text,item=text,function_body=plpgsql',
57
+ 'shortcut.value': 'type:text=text,html=html,function_body=plpgsql',
54
58
  'computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
59
+ 'app_computation.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
55
60
  'branch.condition_expr': 'condition_type:item_null=text,item_not_null=text,item_equals=text,item_not_equals=text',
56
61
  };
57
62
  /** Language when no SWITCH value matches. */
@@ -20,6 +20,7 @@ import { aiProblems } from '../runtime/ai.ts';
20
20
  import { DATA_LOAD_DEF_SPEC } from './dataload.ts';
21
21
  import { processProblems } from '../runtime/processes.ts';
22
22
  import { BUTTON_OPTIONS, ITEM_OPTIONS, REGION_OPTIONS } from '../runtime/template-options.ts';
23
+ import { checkGroupMembers } from '../subscriptions.ts';
23
24
 
24
25
  export type FieldKind =
25
26
  | 'text' | 'int' | 'bool' | 'code' | 'json' | 'select' | 'upper'
@@ -68,8 +69,8 @@ export interface ComponentSpec {
68
69
  defaults?: Record<string, unknown>;
69
70
  /** checks the parsed values before saving; returns a message when they're not valid */
70
71
  validate?: (values: Record<string, any>) => string | null;
71
- /** adjusts the parsed values before they are saved (cid: the row being edited); throws when they can't be */
72
- beforeSave?: (values: Record<string, unknown>, cid: string | undefined) => Promise<void>;
72
+ /** adjusts the parsed values before they are saved (cid: the row being edited; parentId: its page or application); throws when they can't be */
73
+ beforeSave?: (values: Record<string, unknown>, cid: string | undefined, parentId: string) => Promise<void>;
73
74
  }
74
75
 
75
76
  const AUTHZ_HELP = 'Authorization scheme; prefix with ! to negate. MUST_NOT_BE_PUBLIC_USER is built in.';
@@ -169,7 +170,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
169
170
  { name: 'name', common: true, label: 'Name (request)', kind: 'upper', group: 'Identification' },
170
171
  { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
171
172
  { name: 'action', common: true, label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
172
- { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template (Shared Components → Document templates).' },
173
+ { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template or a report query (Shared Components → Document templates, Report queries).' },
173
174
  { name: 'target_page', common: true, label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
174
175
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
175
176
  { name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
@@ -454,6 +455,66 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
454
455
  buildOption(),
455
456
  ],
456
457
  },
458
+ // (102) application computations (APEX: Shared Components → Application Computations)
459
+ app_computation: {
460
+ table: 'meta.app_computation',
461
+ scope: 'app',
462
+ label: 'Application computation',
463
+ plural: 'Application computations',
464
+ icon: 'activity',
465
+ summary: (c) => `${c.item_name} (${c.point}, ${c.type})`,
466
+ defaults: { point: 'before_header', type: 'static' },
467
+ fields: [
468
+ { name: 'item_name', label: 'Item', kind: 'upper', group: 'Identification', help: 'An application item, e.g. AI_REGION (a page item is computed on its own page only)' },
469
+ { name: 'point', label: 'Point', kind: 'select', options: ['after_authentication', 'before_header', 'after_submit'], group: 'Identification',
470
+ help: 'after_authentication: once, when a user signs in · before_header: every page shown, before the page\'s own computations · after_submit: every page submitted, before the page\'s own computations' },
471
+ { name: 'seq', label: 'Sequence', kind: 'int', group: 'Identification' },
472
+ { name: 'type', label: 'Type', kind: 'select', options: ['static', 'item', 'sql_query', 'sql_expression', 'function_body'], group: 'Computation' },
473
+ { name: 'expression', label: 'Expression', kind: 'code', wide: true, group: 'Computation',
474
+ help: 'static: a value, &ITEM. allowed · item: an item name · sql_query: a SELECT (first column of the first row), e.g. select region from sales.rep where lower(username) = lower(:APP_USER) · sql_expression · function_body: PL/pgSQL. Runs as the application\'s role.' },
475
+ ...conditionFields('Condition'),
476
+ { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
477
+ buildOption('Security'),
478
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
479
+ ],
480
+ },
481
+ // (105) component groups (APEX: Shared Components → Component Groups)
482
+ component_group: {
483
+ table: 'meta.component_group',
484
+ scope: 'app',
485
+ label: 'Component group',
486
+ plural: 'Component groups',
487
+ icon: 'layers',
488
+ summary: (g) => `${g.name} (${(g.members ?? []).length})`,
489
+ defaults: {},
490
+ beforeSave: async (v, _cid, appId) => {
491
+ const { members, problems } = await checkGroupMembers(Number(appId), (v.members as string[] | null) ?? []);
492
+ if (problems.length) throw new Error(problems.join(' '));
493
+ v.members = members;
494
+ },
495
+ fields: [
496
+ { name: 'name', label: 'Name', kind: 'text', help: 'e.g. Sales lookups. Other applications subscribe to all of its components at once (Subscriptions), when this is a library or theme application.' },
497
+ { name: 'description', label: 'Description', kind: 'text', wide: true },
498
+ { name: 'members', label: 'Components', kind: 'list', wide: true,
499
+ help: 'Comma separated kind:name, e.g. lov:JOBS, authz_scheme:MANAGER, build_option:FEATURE_EXPORT, template_component:status_badge, list:SHORTCUTS.' },
500
+ ],
501
+ },
502
+ // (103) shortcuts (APEX: Shared Components → Shortcuts)
503
+ shortcut: {
504
+ table: 'meta.shortcut',
505
+ scope: 'app',
506
+ label: 'Shortcut',
507
+ plural: 'Shortcuts',
508
+ icon: 'link',
509
+ summary: (s) => `"${s.name}" (${s.type})`,
510
+ defaults: { type: 'text' },
511
+ fields: [
512
+ { name: 'name', label: 'Name', kind: 'upper', help: 'e.g. DELETE_CONFIRM_MSG: "DELETE_CONFIRM_MSG" (in double quotes) in a static region\'s content, an item\'s help text or a button\'s confirm message is replaced by the shortcut.' },
513
+ { name: 'type', label: 'Type', kind: 'select', options: ['text', 'html', 'function_body'], help: 'text: the value (escaped in HTML) · html: the value as HTML · function_body: PL/pgSQL that returns the text, e.g. begin return \'Signed in as \' || :APP_USER; end. Runs as the application\'s role, once per request.' },
514
+ { name: 'value', label: 'Value', kind: 'code', wide: true },
515
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true },
516
+ ],
517
+ },
457
518
  page_group: {
458
519
  table: 'meta.page_group',
459
520
  scope: 'app',
@@ -578,6 +639,27 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
578
639
  web_credential: WEB_CREDENTIAL_SPEC,
579
640
  rest_source: REST_SOURCE_SPEC,
580
641
  data_load_def: DATA_LOAD_DEF_SPEC,
642
+ // (104) report queries (APEX: Shared Components → Report Queries)
643
+ report_query: {
644
+ table: 'meta.report_query',
645
+ scope: 'app',
646
+ label: 'Report query',
647
+ plural: 'Report queries',
648
+ icon: 'file',
649
+ summary: (q) => `${q.name} (${q.format})`,
650
+ defaults: { format: 'pdf', query: 'select empno, ename, job from sales.staff order by ename' },
651
+ fields: [
652
+ { name: 'name', label: 'Name', kind: 'upper', group: 'Identification', help: 'Pages download it with ?doc=NAME (a document template of that name wins); buttons with action "document" name it.' },
653
+ { name: 'description', label: 'Description', kind: 'text', wide: true, group: 'Identification' },
654
+ { name: 'query', label: 'Query (SQL)', kind: 'code', wide: true, group: 'Source', help: 'A SELECT with :ITEM binds (the page\'s session state), run as the application\'s role. Its columns\' names are the headings.' },
655
+ { name: 'format', label: 'Output format', kind: 'select', options: ['pdf', 'xlsx', 'csv', 'json', 'xml', 'html'], group: 'Output' },
656
+ { name: 'layout', label: 'Report layout', kind: 'upper', group: 'Output', help: 'PDF: paper, margins, font size, colours, logo and footer (Shared Components → Report layouts). Empty: the default layout.' },
657
+ { name: 'file_name', label: 'File name', kind: 'text', group: 'Output', help: 'Without extension, e.g. staff-&P3_DEPTNO. Empty: the name in lower case.' },
658
+ { name: 'title', label: 'Title', kind: 'text', group: 'Output', help: 'Of a PDF or HTML file (&ITEM. allowed). Empty: the description or the name.' },
659
+ { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
660
+ { name: 'comments', label: 'Comments', kind: 'textarea', wide: true, group: 'Comments' },
661
+ ],
662
+ },
581
663
  document_template: {
582
664
  table: 'meta.document_template',
583
665
  scope: 'app',
@@ -179,7 +179,7 @@ export async function saveComponent(kind: string, parentCol: 'page_id' | 'app_id
179
179
  const values = parseFields(spec, body);
180
180
  const problem = spec.validate?.(values);
181
181
  if (problem) throw new Error(problem);
182
- await spec.beforeSave?.(values, cid);
182
+ await spec.beforeSave?.(values, cid, parentId);
183
183
  // region fields only take regions of the same page
184
184
  if (parentCol === 'page_id')
185
185
  for (const f of spec.fields)
@@ -17,6 +17,7 @@ import { appHeader, back, BASE, csrf, developer, flash, input, region, select, s
17
17
  import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
18
18
  import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
19
19
  import { catalogPicker } from './rest-catalogs.ts';
20
+ import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type ComponentSettings } from '../runtime/component-settings.ts';
20
21
 
21
22
  // Shared Components: navigation, authorization schemes, LOVs, application
22
23
  // items and processes, report layouts, and access control.
@@ -61,12 +62,12 @@ export function sharedGroups(appId: number): { key: string; title: string; icon:
61
62
  const settings = (tab: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/settings?tab=${tab}` });
62
63
  const page = (path: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/${path}` });
63
64
  return [
64
- { key: 'logic', title: 'Application Logic', icon: 'code', items: [t('app_item', 'Application Items'), t('app_process', 'Application Processes'), t('app_setting', 'Application Settings'), t('build_option', 'Build Options'), page('settings?tab=definition', 'Application Definition')] },
65
+ { key: 'logic', title: 'Application Logic', icon: 'code', items: [t('app_item', 'Application Items'), t('app_process', 'Application Processes'), t('app_computation', 'Application Computations'), t('app_setting', 'Application Settings'), t('build_option', 'Build Options'), page('settings?tab=definition', 'Application Definition')] },
65
66
  { key: 'security', title: 'Security', icon: 'shield', items: [{ label: 'Access Control', href: `${BASE}/apps/${appId}/shared?view=tree`, kind: 'access' }, settings('security', 'Authentication'), t('authz_scheme', 'Authorization Schemes'), settings('security', 'Security Attributes'), settings('signin', 'Sign-in and Session Management')] },
66
- { key: 'other', title: 'Other Components', icon: 'layers', items: [t('lov', 'Lists of Values'), t('list', 'Lists'), t('list_entry', 'List Entries'), t('email_template', 'Email Templates'), t('supporting_script', 'Supporting Objects'), page('subscriptions', 'Subscriptions')] },
67
+ { key: 'other', title: 'Other Components', icon: 'layers', items: [t('lov', 'Lists of Values'), t('list', 'Lists'), t('list_entry', 'List Entries'), t('email_template', 'Email Templates'), t('shortcut', 'Shortcuts'), t('component_group', 'Component Groups'), t('supporting_script', 'Supporting Objects'), page('subscriptions', 'Subscriptions')] },
67
68
  { key: 'navigation', title: 'Navigation and Search', icon: 'compass', items: [t('nav_entry', 'Navigation Menu'), settings('ui', 'Navigation Bar List'), page('breadcrumbs', 'Breadcrumbs'), t('page_group', 'Page Groups'), t('search_config', 'Search Configurations')] },
68
- { key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), settings('pwa', 'Progressive Web App')] },
69
- { key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('document_template', 'Document Templates')] },
69
+ { key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), page('component-settings', 'Component Settings'), settings('pwa', 'Progressive Web App')] },
70
+ { key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('report_query', 'Report Queries'), t('document_template', 'Document Templates')] },
70
71
  { key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
71
72
  { key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
72
73
  { key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
@@ -96,12 +97,44 @@ export async function sharedRoutes(app: FastifyInstance) {
96
97
  return send(reply, s, shell(s, 'Breadcrumbs', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Breadcrumbs']], main));
97
98
  });
98
99
 
100
+ // (105) Component Settings (APEX): the application's defaults of its built-in component types
101
+ app.get(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
102
+ const s = await developer(req, reply);
103
+ if (!s) return;
104
+ const a = await appOr404(req.params.id);
105
+ if (!a) return reply.code(404).send('Not found');
106
+ const cur = (a.component_settings ?? {}) as ComponentSettings;
107
+ const groups = [...new Set(COMPONENT_SETTINGS.map((x) => x.group))];
108
+ const main = html`${appHeader(a, 'shared')}
109
+ ${region('Component Settings', html`<p class="muted u-mt0">Defaults for the application's built-in component types, used where a component sets nothing itself. Empty: pgkiln's default.</p>
110
+ <form method="post" action="${BASE}/apps/${a.id}/component-settings">${csrf(s)}
111
+ ${groups.map((g) => html`<fieldset class="u-mb1"><legend>${g}</legend>${COMPONENT_SETTINGS.filter((x) => x.group === g).map((x) =>
112
+ input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help: `${x.help} ${x.min}–${x.max.toLocaleString('en')}; now ${componentSetting(cur, x.key).toLocaleString('en')}.` }))}</fieldset>`)}
113
+ <div class="buttons"><button class="btn btn-hot">Save</button></div>
114
+ </form>`)}`;
115
+ return send(reply, s, shell(s, 'Component Settings', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Component Settings']], main));
116
+ });
117
+
118
+ app.post(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
119
+ const s = await developer(req, reply);
120
+ if (!s) return;
121
+ const a = await appOr404(req.params.id);
122
+ if (!a) return reply.code(404).send('Not found');
123
+ const { settings, problems } = componentSettingsFrom(req.body ?? {});
124
+ if (problems.length) flash(s, problems.join(' '), 'error');
125
+ else {
126
+ await owner.query('update meta.app set component_settings = $2 where id = $1', [a.id, JSON.stringify(settings)]);
127
+ flash(s, 'Component settings saved.');
128
+ }
129
+ return back(reply, s, `${BASE}/apps/${a.id}/component-settings`);
130
+ });
131
+
99
132
  // ---------------------------------------------------------------- shared components
100
- const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
133
+ const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'app_computation', 'shortcut', 'report_query', 'component_group', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
101
134
 
102
135
  const ORDER: Record<string, string> = {
103
136
  search_config: 'seq, name', dynamic_translation: 'language, source',
104
- nav_entry: 'parent_id nulls first, seq, id', app_process: 'seq, id', list_entry: 'list_name, parent_id nulls first, seq, id', automation_action: 'automation_name, seq, name', supporting_script: 'kind, seq, name',
137
+ nav_entry: 'parent_id nulls first, seq, id', app_process: 'seq, id', app_computation: 'seq, id', list_entry: 'list_name, parent_id nulls first, seq, id', automation_action: 'automation_name, seq, name', supporting_script: 'kind, seq, name',
105
138
  };
106
139
 
107
140
  app.get(`${BASE}/apps/:id/shared`, async (req: Req, reply) => {
@@ -4,8 +4,8 @@ import { html, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
6
6
  import {
7
- APP_TYPE_LABELS, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
8
- SubscriptionError, unsubscribe, type AppType, type Kind,
7
+ APP_TYPE_LABELS, groupsOffered, inSync, isKind, KINDS, offers, publish, refresh, subscribe, subscribersOf, subscriptionOf, subscriptionsOf,
8
+ SubscriptionError, subscribeGroup, unsubscribe, type AppType, type Kind,
9
9
  } from '../subscriptions.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
11
11
 
@@ -59,7 +59,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
59
59
  if (!s) return;
60
60
  const a = await appRow(req.params.id);
61
61
  if (!a) return reply.code(404).send('Not found');
62
- const [subs, subscribers, offered] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id)]);
62
+ const [subs, subscribers, offered, groupList] = await Promise.all([subscriptionsOf(a.id), subscribersOf(a.id), offers(a.id), groupsOffered(a.id)]);
63
63
  const sync = await Promise.all(subs.map((x) => inSync(x)));
64
64
  const subSync = await Promise.all(subscribers.map((x) => inSync(x)));
65
65
  const hidden = (kind: string, name: string) => html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
@@ -93,7 +93,17 @@ export async function subscriptionRoutes(app: FastifyInstance) {
93
93
  </select>
94
94
  <small class="help">The component is copied now. A component of this application with the same name is replaced.</small></div>
95
95
  <div class="buttons"><button class="btn btn-hot">Subscribe</button></div></form>`
96
- : html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}`)}
96
+ : html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}
97
+ ${groupList.length
98
+ ? html`<h3>Subscribe to a component group</h3>
99
+ <form method="post" action="${BASE}/apps/${a.id}/subscriptions/group">${csrf(s)}
100
+ <div class="field"><label class="label" for="f_group">Component group</label>
101
+ <select id="f_group" name="group" required><option value="">- choose -</option>
102
+ ${groupList.map((g) => html`<option value="${g.master_id}|${g.name}">${g.master_name}: ${g.name} (${g.members.length})</option>`)}
103
+ </select>
104
+ <small class="help">Every component of the group is copied now and subscribed (Shared Components → Component Groups of a theme or library application).</small></div>
105
+ <div class="buttons"><button class="btn btn-hot">Subscribe to the group</button></div></form>`
106
+ : ''}`)}
97
107
  ${a.app_type === 'theme' || a.app_type === 'library' || subscribers.length
98
108
  ? region('Subscribers', subscribers.length
99
109
  ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Component</th><th>Application</th><th>Refreshed</th><th>State</th></tr></thead><tbody>
@@ -138,6 +148,22 @@ export async function subscriptionRoutes(app: FastifyInstance) {
138
148
  });
139
149
  });
140
150
 
151
+ // (105) all the components of a master's component group
152
+ app.post(`${BASE}/apps/:id/subscriptions/group`, async (req: Req, reply) => {
153
+ const s = await developer(req, reply);
154
+ if (!s) return;
155
+ const a = await appRow(req.params.id);
156
+ if (!a) return reply.code(404).send('Not found');
157
+ const m = /^(\d{1,9})\|(.{1,60})$/s.exec(String(req.body?.group ?? ''));
158
+ return handle(req, reply, s, a.id, async () => {
159
+ if (!m) throw new SubscriptionError('Choose a component group.');
160
+ if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError('Choose a component group.');
161
+ const { done, skipped } = await subscribeGroup(a.id, Number(m[1]), m[2], s.username!);
162
+ await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed component group ${m[2]} from application ${m[1]}: ${done.join(', ')}` });
163
+ return `Subscribed to ${m[2]}: ${done.length} component${done.length === 1 ? '' : 's'} copied${skipped.length ? `; skipped ${skipped.join(', ')}` : ''}.`;
164
+ });
165
+ });
166
+
141
167
  app.post(`${BASE}/apps/:id/subscriptions/refresh`, async (req: Req, reply) => {
142
168
  const s = await developer(req, reply);
143
169
  if (!s) return;
@@ -26,8 +26,12 @@ export const REPLACED = [
26
26
  'search_config', 'email_template',
27
27
  // (086) application settings (installed values kept, see below), dynamic translations
28
28
  'app_setting', 'dynamic_translation',
29
- // (089) page groups
30
- 'page_group',
29
+ // (089) page groups, (102) application computations
30
+ 'page_group', 'app_computation',
31
+ // (103) shortcuts, (104) report queries
32
+ 'shortcut', 'report_query',
33
+ // (105) component groups
34
+ 'component_group',
31
35
  ];
32
36
  /** Tables of an application that belong to the installation: kept. */
33
37
  export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'saved_report', 'persistent_login', 'task', 'workflow', 'process_job',
package/src/i18n/ar.ts CHANGED
@@ -390,6 +390,8 @@ export const ar: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} نجوم فأكثر",
391
391
  'facets.star_up': "نجمة واحدة فأكثر",
392
392
  'facets.clear_short': "مسح",
393
+ 'facets.show_all': "عرض الكل ({n})",
394
+ 'facets.total': "إجمالي عدد الصفوف {n}",
393
395
  'smart.placeholder': "بحث أو تصفية…",
394
396
  'smart.active': "عوامل التصفية المستخدمة",
395
397
  'smart.remove': "إزالة عامل التصفية {filter}",
package/src/i18n/cs.ts CHANGED
@@ -390,6 +390,8 @@ export const cs: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} hvězdičky a více",
391
391
  'facets.star_up': "1 hvězdička a více",
392
392
  'facets.clear_short': "Vymazat",
393
+ 'facets.show_all': "Zobrazit vše ({n})",
394
+ 'facets.total': "Celkový počet řádků {n}",
393
395
  'smart.placeholder': "Hledat nebo filtrovat…",
394
396
  'smart.active': "Použité filtry",
395
397
  'smart.remove': "Odebrat filtr {filter}",
package/src/i18n/da.ts CHANGED
@@ -390,6 +390,8 @@ export const da: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stjerner og op",
391
391
  'facets.star_up': "1 stjerne og op",
392
392
  'facets.clear_short': "Ryd",
393
+ 'facets.show_all': "Vis alle ({n})",
394
+ 'facets.total': "Antal rækker i alt {n}",
393
395
  'smart.placeholder': "Søg eller filtrér…",
394
396
  'smart.active': "Filtre i brug",
395
397
  'smart.remove': "Fjern filteret {filter}",
package/src/i18n/de.ts CHANGED
@@ -390,6 +390,8 @@ export const de: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} Sterne und mehr',
391
391
  'facets.star_up': '1 Stern und mehr',
392
392
  'facets.clear_short': 'Löschen',
393
+ 'facets.show_all': 'Alle anzeigen ({n})',
394
+ 'facets.total': 'Zeilen insgesamt {n}',
393
395
  'smart.placeholder': 'Suchen oder filtern…',
394
396
  'smart.active': 'Aktive Filter',
395
397
  'smart.remove': 'Filter {filter} entfernen',
package/src/i18n/el.ts CHANGED
@@ -390,6 +390,8 @@ export const el: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} αστέρια και πάνω",
391
391
  'facets.star_up': "1 αστέρι και πάνω",
392
392
  'facets.clear_short': "Απαλοιφή",
393
+ 'facets.show_all': "Εμφάνιση όλων ({n})",
394
+ 'facets.total': "Συνολικός αριθμός γραμμών {n}",
393
395
  'smart.placeholder': "Αναζήτηση ή φιλτράρισμα…",
394
396
  'smart.active': "Φίλτρα σε χρήση",
395
397
  'smart.remove': "Αφαίρεση του φίλτρου {filter}",
package/src/i18n/es.ts CHANGED
@@ -390,6 +390,8 @@ export const es: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} estrellas o más',
391
391
  'facets.star_up': '1 estrella o más',
392
392
  'facets.clear_short': 'Borrar',
393
+ 'facets.show_all': 'Mostrar todo ({n})',
394
+ 'facets.total': 'Total de filas {n}',
393
395
  'smart.placeholder': 'Buscar o filtrar…',
394
396
  'smart.active': 'Filtros en uso',
395
397
  'smart.remove': 'Quitar el filtro {filter}',
package/src/i18n/fi.ts CHANGED
@@ -390,6 +390,8 @@ export const fi: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} tähteä tai enemmän",
391
391
  'facets.star_up': "1 tähti tai enemmän",
392
392
  'facets.clear_short': "Tyhjennä",
393
+ 'facets.show_all': "Näytä kaikki ({n})",
394
+ 'facets.total': "Rivejä yhteensä {n}",
393
395
  'smart.placeholder': "Hae tai suodata…",
394
396
  'smart.active': "Käytössä olevat suodattimet",
395
397
  'smart.remove': "Poista suodatin {filter}",
package/src/i18n/fr.ts CHANGED
@@ -390,6 +390,8 @@ export const fr: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} étoiles et plus',
391
391
  'facets.star_up': '1 étoile et plus',
392
392
  'facets.clear_short': 'Effacer',
393
+ 'facets.show_all': 'Tout afficher ({n})',
394
+ 'facets.total': 'Nombre total de lignes {n}',
393
395
  'smart.placeholder': 'Rechercher ou filtrer…',
394
396
  'smart.active': 'Filtres actifs',
395
397
  'smart.remove': 'Supprimer le filtre {filter}',
package/src/i18n/he.ts CHANGED
@@ -390,6 +390,8 @@ export const he: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} כוכבים ומעלה",
391
391
  'facets.star_up': "כוכב אחד ומעלה",
392
392
  'facets.clear_short': "ניקוי",
393
+ 'facets.show_all': "הצג הכול ({n})",
394
+ 'facets.total': "סך כל השורות {n}",
393
395
  'smart.placeholder': "חיפוש או סינון…",
394
396
  'smart.active': "מסננים בשימוש",
395
397
  'smart.remove': "הסרת המסנן {filter}",
package/src/i18n/it.ts CHANGED
@@ -390,6 +390,8 @@ export const it: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stelle e più",
391
391
  'facets.star_up': "1 stella e più",
392
392
  'facets.clear_short': "Cancella",
393
+ 'facets.show_all': "Mostra tutto ({n})",
394
+ 'facets.total': "Numero totale di righe {n}",
393
395
  'smart.placeholder': "Cerca o filtra…",
394
396
  'smart.active': "Filtri attivi",
395
397
  'smart.remove': "Rimuovi il filtro {filter}",
package/src/i18n/ja.ts CHANGED
@@ -390,6 +390,8 @@ export const ja: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "星 {n} つ以上",
391
391
  'facets.star_up': "星 1 つ以上",
392
392
  'facets.clear_short': "クリア",
393
+ 'facets.show_all': "すべて表示 ({n})",
394
+ 'facets.total': "合計行数 {n}",
393
395
  'smart.placeholder': "検索またはフィルター…",
394
396
  'smart.active': "使用中のフィルター",
395
397
  'smart.remove': "フィルター {filter} を削除",
package/src/i18n/ko.ts CHANGED
@@ -390,6 +390,8 @@ export const ko: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "별 {n}개 이상",
391
391
  'facets.star_up': "별 1개 이상",
392
392
  'facets.clear_short': "지우기",
393
+ 'facets.show_all': "모두 표시 ({n})",
394
+ 'facets.total': "전체 행 수 {n}",
393
395
  'smart.placeholder': "검색 또는 필터…",
394
396
  'smart.active': "사용 중인 필터",
395
397
  'smart.remove': "{filter} 필터 제거",
package/src/i18n/nb.ts CHANGED
@@ -390,6 +390,8 @@ export const nb: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stjerner og opp",
391
391
  'facets.star_up': "1 stjerne og opp",
392
392
  'facets.clear_short': "Fjern",
393
+ 'facets.show_all': "Vis alle ({n})",
394
+ 'facets.total': "Totalt antall rader {n}",
393
395
  'smart.placeholder': "Søk eller filtrer…",
394
396
  'smart.active': "Filtre i bruk",
395
397
  'smart.remove': "Fjern filteret {filter}",
package/src/i18n/pl.ts CHANGED
@@ -390,6 +390,8 @@ export const pl: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} gwiazdki i więcej",
391
391
  'facets.star_up': "1 gwiazdka i więcej",
392
392
  'facets.clear_short': "Wyczyść",
393
+ 'facets.show_all': "Pokaż wszystkie ({n})",
394
+ 'facets.total': "Łączna liczba wierszy {n}",
393
395
  'smart.placeholder': "Szukaj lub filtruj…",
394
396
  'smart.active': "Używane filtry",
395
397
  'smart.remove': "Usuń filtr {filter}",
package/src/i18n/pt.ts CHANGED
@@ -390,6 +390,8 @@ export const pt: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} estrelas ou mais",
391
391
  'facets.star_up': "1 estrela ou mais",
392
392
  'facets.clear_short': "Limpar",
393
+ 'facets.show_all': "Mostrar tudo ({n})",
394
+ 'facets.total': "Total de linhas {n}",
393
395
  'smart.placeholder': "Pesquisar ou filtrar…",
394
396
  'smart.active': "Filtros em uso",
395
397
  'smart.remove': "Remover o filtro {filter}",