pgkiln 0.74.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/142_json_sources.sql +426 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/03-builder.md +15 -0
- package/docs/guide/04-pages-and-regions.md +24 -2
- package/docs/guide/12-development.md +11 -0
- package/docs/guide/19-rest-data-sources.md +108 -1
- package/examples/hr/hr_77_json_sources.sql +174 -0
- package/package.json +1 -1
- package/public/app.css +18 -0
- package/public/app.js +141 -0
- package/src/app.ts +5 -0
- package/src/builder/document-view.ts +117 -0
- package/src/builder/i18n/de.ts +1 -0
- package/src/builder/i18n/en.ts +1 -0
- package/src/builder/i18n/es.ts +1 -0
- package/src/builder/i18n/fr.ts +1 -0
- package/src/builder/i18n/it.ts +1 -0
- package/src/builder/i18n/ja.ts +1 -0
- package/src/builder/i18n/ko.ts +1 -0
- package/src/builder/i18n/nl.ts +1 -0
- package/src/builder/i18n/pt.ts +1 -0
- package/src/builder/i18n/zh-cn.ts +1 -0
- package/src/builder/i18n/zh-tw.ts +1 -0
- package/src/builder/json-sources.ts +395 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/report-settings.ts +9 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/websources.ts +6 -3
- 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/json-sources.ts +365 -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 +211 -26
|
@@ -234,12 +234,32 @@ Features for end users:
|
|
|
234
234
|
search and filters; at most 200), with a box to narrow them; choosing a value filters the report on it. The sort
|
|
235
235
|
arrow of the sorted column sorts at once. Without JavaScript a heading sorts (again for descending).
|
|
236
236
|
- **Hidden columns**: hidden from the heading menu or *Actions → Columns*, shown as chips to bring them back.
|
|
237
|
+
- **Column order** (sprint 143; APEX 26.2: drag-and-drop columns): *Actions → Columns* lists the columns in their
|
|
238
|
+
order with a checkbox to hide each; drag one to another place, or move it with its ↑ / ↓ buttons (each move is
|
|
239
|
+
announced to screen readers; without JavaScript the buttons submit the dialog). *Own column order* is a chip that
|
|
240
|
+
puts the report's order back. The order is kept in saved reports (`r12_o=sal&r12_o=ename`, the columns it names
|
|
241
|
+
first, the rest after them in the report's order), and the CSV, Excel and PDF downloads follow it and leave out
|
|
242
|
+
the columns the user hid.
|
|
237
243
|
- **Pagination**: *1–15 of 107*, first, previous, next and last page, and the **rows per page** list.
|
|
238
244
|
- **Actions menu** (sprint 76: a short menu like APEX's; each entry opens a dialog, or unfolds in place without
|
|
239
245
|
JavaScript): *Columns*, *Filter*, *Data* (sort, aggregate, compute, control break), *Format* (highlight, rows per
|
|
240
246
|
page), *Chart*, *Group by*, *Pivot*, *Report* (saved reports, reset) and *Download* (CSV, Excel, PDF, print):
|
|
241
247
|
- **column filters** (`=`, `≠`, contains, does not contain, `>`, `≥`, `<`, `≤`, is empty, is
|
|
242
|
-
not empty) and **sort
|
|
248
|
+
not empty, and since sprint 143 the 26.2 operators below) and **sort**:
|
|
249
|
+
- **is one of** / **is not one of** (APEX 26.2: multi-select filters): tick values in the column's list (with
|
|
250
|
+
their counts and a search box, as in the heading menu), or type them separated by commas. *Is not one of*
|
|
251
|
+
keeps the rows without a value. In the URL the values are a JSON array: `r12_f=job|in|["CLERK","ANALYST"]`
|
|
252
|
+
(`job|in|CLERK, ANALYST` works too), at most 100 values;
|
|
253
|
+
- **is between** (date-range filters): two values, both included, either may stay empty (*Salary ≥ 1000*). On a
|
|
254
|
+
date or timestamp column the fields get the date picker and take dates in the application's date format, and
|
|
255
|
+
the last day counts as a whole (`hiredate|between|["1981-01-01","1981-12-31"]`);
|
|
256
|
+
- **is in** a period, on a date column: today, yesterday, the last 7 days, the last 30 days, the next 7 days,
|
|
257
|
+
this month, last month, this year or last year (`hiredate|period|last_30_days`). A period is computed
|
|
258
|
+
from today when the report runs, so a saved report stays current.
|
|
259
|
+
|
|
260
|
+
The dialog shows only the fields the chosen operator needs (without JavaScript it shows all of them); a filter
|
|
261
|
+
is a chip in words (*Job is one of CLERK, ANALYST*), also in the PDF's filter list. *Ask in your own words* and
|
|
262
|
+
an AI service may use the same operators; a highlight takes the one-value operators;
|
|
243
263
|
- **control break**: group the rows by a column, with a heading row per group;
|
|
244
264
|
- **aggregates**: sum, average, count, minimum or maximum of a column, over all filtered rows
|
|
245
265
|
(not just the page). A *Total* row closes the table, and with a control break every group
|
|
@@ -267,7 +287,8 @@ Features for end users:
|
|
|
267
287
|
|
|
268
288
|
The report's state lives in the URL (`?r12_q=…&r12_s=3&r12_d=desc&r12_b=job&r12_a=sum|sal&r12_h=sal|gt|green|3000`),
|
|
269
289
|
so it can be bookmarked and shared. Everything the user enters is applied safely: search, filter
|
|
270
|
-
and highlight values become literals
|
|
290
|
+
and highlight values become literals (each value of a list and each bound of a range on its own; a period is one
|
|
291
|
+
of a fixed list of expressions), columns must exist in the result, operators, aggregate
|
|
271
292
|
functions, chart types and colors come from fixed lists, sort positions are integers, and computed
|
|
272
293
|
column expressions are parsed (below), never pasted into SQL.
|
|
273
294
|
|
|
@@ -414,6 +435,7 @@ Attributes (most of them are also in the page designer's **Report settings** for
|
|
|
414
435
|
| `sortable` | `true` | Allow sorting (`false` keeps the query's order, e.g. for trees) |
|
|
415
436
|
| `mobile` | `"reflow"` | `"scroll"` keeps a horizontally scrolling table on phones |
|
|
416
437
|
| `hidden` | `[]` | Column names to leave out of the display (they can still be used in links). Users can't search, filter, sort, compute or aggregate on them either, also not by editing the URL |
|
|
438
|
+
| `json_columns` | none | (sprint 142) Columns read from a json or jsonb column of the source: `[{"name": "title", "column": "profile", "path": "title", "type": "text"}]`. A path is keys separated by dots, a number picks an array element (`skills.0.name`); `type` is `text` (default), `integer`, `bigint`, `numeric`, `boolean`, `date`, `timestamp`, `timestamptz` or `jsonb`, and a value that doesn't read as it is NULL. They are columns like the others (search, filters, sorting, downloads). In the report settings: *JSON path columns*, `name=column:path[:type]; …` ([chapter 19](19-rest-data-sources.md#json-path-columns-in-reports)) |
|
|
417
439
|
| `headings` | `{}` | Column headings, e.g. `{"sal": "Salary"}`; by default `hire_date` becomes "Hire Date" |
|
|
418
440
|
| `formats` | `{}` | Format masks per column, e.g. `{"sal": "FML999G990D00", "hiredate": "DD-MON-YYYY"}` (see [number formats](14-globalization.md#number-formats)) |
|
|
419
441
|
| `link` | none | Makes one column a link: `{"column": "empno", "page": 3, "items": {"P3_EMPNO": "#empno#"}}`. `#col#` is replaced by the row's value; the link carries a checksum and is hidden when the user may not open that page |
|
|
@@ -139,6 +139,10 @@ src/
|
|
|
139
139
|
wins over base_url), joinUrl, resolveSource (a REST source on a server: full URL + the server's default
|
|
140
140
|
credential; used by loadSource/fetchSource/callOperation), catalog and AI service URLs (owner pool,
|
|
141
141
|
the application's own workspace only)
|
|
142
|
+
json-sources.ts (sprint 142, AP-8) JSON sources (meta.app.json_sources): jsonSourceProblems, proposeProfile (types and
|
|
143
|
+
arrays from sample documents), jsonSourceDdl (security_invoker views with the meta.json_source_write /
|
|
144
|
+
json_array_write triggers, the table's grants), documentViewDdl (duality-view analogue, meta.json_document_write),
|
|
145
|
+
withJsonColumns / parseJsonColumns (a report's json_columns), catalog helpers (primaryKey, tableGrants, childTables)
|
|
142
146
|
datapump.ts (sprint 103, DA-16) Schema Export / Import jobs (meta.dump_job): findTools (pg_dump/pg_restore ≥ the
|
|
143
147
|
server's major), startExport (pg_dump, argument array, PGPASSWORD), readToc/parseToc (pg_restore --list),
|
|
144
148
|
startImport (pg_restore -f - run in one transaction between schema renames; data-only in FK order),
|
|
@@ -419,6 +423,11 @@ src/
|
|
|
419
423
|
vector-providers.ts (sprint 136, AP-1) Shared Components → Vector providers (/apps/:id/vector-providers: list, provider form,
|
|
420
424
|
test, delete) and Workspace utilities → Vector providers (/vector-providers: pgvector status, Create
|
|
421
425
|
extension for administrators, the workspace's providers and vector columns); pgvectorNote()
|
|
426
|
+
json-sources.ts (sprint 142, AP-8) Shared Components → JSON sources (/apps/:id/json-sources: list, source form with
|
|
427
|
+
Propose from the data, views: Create views / worksheet (Change Log), page: createSourcePage() = a form
|
|
428
|
+
on the view and a detail grid per array, delete)
|
|
429
|
+
document-view.ts (sprint 142, AP-8) Table Properties › Document view: GET/POST /sql/objects/document?o= (child tables
|
|
430
|
+
by foreign key), Preview / Apply (Change Log) / worksheet
|
|
422
431
|
telemetry.ts (sprint 139, AP-6) Workspace utilities → OpenTelemetry (/telemetry: the collector, signals, values, user,
|
|
423
432
|
browser spans, /metrics and its token, status per collector, Send a test span; administrators) and a
|
|
424
433
|
workspace's own (/workspaces/:id/telemetry: as the instance / off / own collector; its administrators)
|
|
@@ -643,6 +652,8 @@ test/
|
|
|
643
652
|
workspace-transfer.test.ts a workspace's export, import into another workspace (added, kept, replaced, all or nothing), groups, access
|
|
644
653
|
workspace-resources.test.ts (sprint 141) remote servers (checks, env var, HR's CRM moves its sources, credentials, renames, catalogs,
|
|
645
654
|
AI services, the Advisor), static workspace files styling two applications, workspace credentials, the export
|
|
655
|
+
json-sources.test.ts (sprint 142) data profiles, the views' DDL, writing through them (unknown keys stay, arrays by row_key,
|
|
656
|
+
a table of documents, document views), HR page 51 (JSON path columns, form and skills grid), the builder, export
|
|
646
657
|
tree-charts.test.ts treemap and sunburst (the hierarchy, cycles, squarify), timeline (lanes, rows, points and periods)
|
|
647
658
|
gantt-move.test.ts an editable Gantt's markup, the move route, dates, who may drag, the builder's settings
|
|
648
659
|
find-workspace.test.ts developers' addresses, builder mail, the workspaces mail, the same answer, limits
|
|
@@ -15,7 +15,8 @@ Web Credentials and the Invoke API process):
|
|
|
15
15
|
puts values of the response into items; the **`invoke_api` workflow step** does the same on the
|
|
16
16
|
server, with workflow variables ([chapter 6](06-processing.md#invoke-api-steps)).
|
|
17
17
|
|
|
18
|
-
Both are **Shared Components
|
|
18
|
+
Both are **Shared Components**, as are **JSON sources** ([below](#json-sources-and-document-views)): forms,
|
|
19
|
+
reports and grids over the documents in a `jsonb` column of your own tables. The server only calls hosts its administrator allows
|
|
19
20
|
([below](#server-configuration-and-the-allow-list)): out of the box, no outgoing request is made.
|
|
20
21
|
|
|
21
22
|
Try it with the HR example: page 23 "Web services" reads the HR example's own REST module
|
|
@@ -355,6 +356,110 @@ A workflow calls a web service with an **`invoke_api` step** instead: the same k
|
|
|
355
356
|
`variables`, `status_variable` and `response_variable` for the items, `&VAR.` for workflow
|
|
356
357
|
variables and an optional `timeout` ([chapter 6](06-processing.md#invoke-api-steps)).
|
|
357
358
|
|
|
359
|
+
## JSON sources and document views
|
|
360
|
+
|
|
361
|
+
(Sprint 142; APEX: Shared Components → Data Sources → **JSON Sources**, and JSON Relational **Duality Views**.)
|
|
362
|
+
A `jsonb` column is a single text item to a form, and one cell to a report. A **JSON source** describes the
|
|
363
|
+
documents in such a column with a **data profile**, and pgkiln makes updatable views from it, so forms, reports,
|
|
364
|
+
interactive grids, master-detail pages, REST (AutoREST) and MCP read and write the documents' values like columns
|
|
365
|
+
of a table.
|
|
366
|
+
|
|
367
|
+
### A JSON source
|
|
368
|
+
|
|
369
|
+
Shared Components → Data Sources → **JSON Sources** → *Add JSON source*:
|
|
370
|
+
|
|
371
|
+
- **Table and jsonb column**: a column of an ordinary table (`hr.emp.profile`), or the one document column of a
|
|
372
|
+
*table of documents* (`id`, `doc`). The table needs a primary key: the views find their row by it. The list
|
|
373
|
+
shows the json and jsonb columns of the tables your SQL Workshop connection may use (an isolated workspace: its
|
|
374
|
+
own schemas).
|
|
375
|
+
- **View**: the name of the view, made in the table's schema.
|
|
376
|
+
- **The view also adds and deletes rows of the table**: for a table of documents. Off (the default), the view
|
|
377
|
+
only changes the documents of existing rows; an insert or delete through it is refused.
|
|
378
|
+
- **Propose from the data** reads up to 200 documents and proposes the profile: a **column** per path to a value
|
|
379
|
+
(nested objects become `office_city` for `office.city`), typed from the values it found (`integer`, `numeric`,
|
|
380
|
+
`boolean`, `date`, `timestamptz` for ISO date-times, `text`, `jsonb` for mixed or nested values), and an **array**
|
|
381
|
+
per list in the documents: a list of objects gets a column per key, a list of values (`["en", "nl"]`) one column
|
|
382
|
+
for the value itself (an empty path). Names, paths and types stay editable; columns you already named are kept.
|
|
383
|
+
A column can be **read only**.
|
|
384
|
+
|
|
385
|
+
**Create views** (the source's page) makes them in one transaction with your SQL Workshop connection and records
|
|
386
|
+
the statements in the [Change Log](03-builder.md#change-log); *Open in the SQL Worksheet* shows them first. After
|
|
387
|
+
changing the profile, *Make the views again* drops and makes them (a view other objects depend on can't be
|
|
388
|
+
dropped: the error says which). The views are ordinary database objects of your application, like the table:
|
|
389
|
+
put the statements in your own migration scripts for the other environments (the source itself travels with the
|
|
390
|
+
application's export as `json_sources`).
|
|
391
|
+
|
|
392
|
+
For `hr.emp.profile` with `{"title": "Analyst", "since": "1987-04-19", "office": {"city": "Dallas"},
|
|
393
|
+
"skills": [{"name": "SQL", "level": 5}], "badge": {"color": "blue"}}` the HR example's source EMP_PROFILE makes:
|
|
394
|
+
|
|
395
|
+
| View | Columns | One row per |
|
|
396
|
+
| --- | --- | --- |
|
|
397
|
+
| `hr.emp_profile` | `empno`, `title`, `since` (date), `office_city`, `office_floor` (integer), `remote` (boolean) | employee |
|
|
398
|
+
| `hr.emp_profile_skills` | `empno`, `row_key`, `position`, `skill`, `level` (integer) | element of `skills` |
|
|
399
|
+
| `hr.emp_profile_languages` | `empno`, `row_key`, `position`, `language` | element of `languages` |
|
|
400
|
+
|
|
401
|
+
- A value that doesn't read as its column's type (`"floor": "ground"`) shows **NULL** in that column instead of
|
|
402
|
+
failing the whole query (`meta.input_ok`, PostgreSQL's `pg_input_is_valid`).
|
|
403
|
+
- **Writing keeps what the profile doesn't know.** An `UPDATE` of the view writes only the paths whose values
|
|
404
|
+
changed, into the row's document, in one `UPDATE` of the table (`meta.jsonb_patch`): `badge` above, or any key a
|
|
405
|
+
newer version of another program added, stays. Setting a column to NULL removes its key; a path to a nested
|
|
406
|
+
object makes the objects on the way. The key columns are never changed through the view.
|
|
407
|
+
- **Arrays as rows.** An array view has the table's key, `row_key` and `position` (1, 2 …) and the element's
|
|
408
|
+
columns. `row_key` is the element's contents (an md5) and its occurrence among equal elements: it stays the same
|
|
409
|
+
when other elements are added, changed or removed, so deleting two rows of a grid in one save removes the right
|
|
410
|
+
two. An `INSERT` appends an element (the table's key column says to which row), an `UPDATE` patches one
|
|
411
|
+
(unknown keys of the element stay), a `DELETE` removes it. An element that someone changed meanwhile is no
|
|
412
|
+
longer found and the statement fails (*Reload and try again*): nothing is overwritten.
|
|
413
|
+
- **Security.** The views are `security_invoker` and get the table's grants (SELECT, INSERT, UPDATE, DELETE of each
|
|
414
|
+
role that has them on the table). Their INSTEAD OF triggers (`meta.json_source_write`, `meta.json_array_write`)
|
|
415
|
+
are SECURITY INVOKER too: they write the table as the role that writes the view, so its grants and the table's
|
|
416
|
+
row level security apply, exactly as for a direct `UPDATE`.
|
|
417
|
+
|
|
418
|
+
### Pages on a JSON source
|
|
419
|
+
|
|
420
|
+
The views work everywhere a table does: Create Page, a form's or report's source, the interactive grid, AutoREST
|
|
421
|
+
and MCP. **Create page** on the source's page makes a form on the view with a **detail grid per array** (the
|
|
422
|
+
grid's master is the form's key item, `position` read only, `row_key` hidden) and their save processes; open it
|
|
423
|
+
with the key in the URL, for example from a report's link. HR page 51 *Employee profiles* (Administration) is
|
|
424
|
+
such a page with a report on top:
|
|
425
|
+
|
|
426
|
+
```sql
|
|
427
|
+
-- the report's source: hr.emp with its jsonb column
|
|
428
|
+
select empno, ename, job, profile from hr.emp order by profile is null, ename
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### JSON path columns in reports
|
|
432
|
+
|
|
433
|
+
A report region's **JSON path columns** (Page Designer → the report's settings) add columns read from a json or
|
|
434
|
+
jsonb column of its query, without writing the SQL: `name=column:path[:type]`, separated by `;`:
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
title=profile:title; first_skill=profile:skills.0.name; since=profile:since:date
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
A path is keys separated by dots; a number picks an element of an array (`skills.0.name`). They are columns like
|
|
441
|
+
the others: search, filters, sorting, control breaks, aggregates, headings, format masks and downloads. In the
|
|
442
|
+
region's JSON: `"json_columns": [{"name": "title", "column": "profile", "path": "title", "type": "text"}]`.
|
|
443
|
+
|
|
444
|
+
### Document views (duality views)
|
|
445
|
+
|
|
446
|
+
SQL Workshop › Table Properties → **Document view…** (or a table's ⋯ menu) makes a view with the table's key and a
|
|
447
|
+
`jsonb` document per row: the row's columns and, per **child table** you tick (tables whose foreign keys reference
|
|
448
|
+
it), a list of its rows without the foreign key columns:
|
|
449
|
+
|
|
450
|
+
```json
|
|
451
|
+
{"deptno": 10, "dname": "ACCOUNTING", "loc": "NEW YORK",
|
|
452
|
+
"emp": [{"empno": 7782, "ename": "CLARK", "job": "MANAGER", "sal": 2450}, …]}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Writing the document back (`update hr.dept_doc set doc = $1 where deptno = 10`, or a PUT through AutoREST on the
|
|
456
|
+
view) updates the row's columns the document names, **upserts** the children it lists (by their key; one without
|
|
457
|
+
a key is inserted) and **deletes** the children it leaves out. A document without the list leaves the children
|
|
458
|
+
alone. An `INSERT` of a document inserts the row and its children, a `DELETE` removes the children and the row.
|
|
459
|
+
Full-text and vector columns are left out of the documents. Like APEX's duality views this gives REST clients and
|
|
460
|
+
MCP tools one document per business object; the trigger (`meta.json_document_write`) is SECURITY INVOKER, so the
|
|
461
|
+
tables' grants and row level security apply to every row it touches.
|
|
462
|
+
|
|
358
463
|
## Export, import and application files
|
|
359
464
|
|
|
360
465
|
The export ([chapter 3](03-builder.md#export-format)) has two new sections: `web_credentials`
|
|
@@ -363,6 +468,8 @@ synchronisation settings, without the synchronisation's next and last run; an im
|
|
|
363
468
|
the schedule off). In the directory format they are
|
|
364
469
|
`shared/web-credentials/` and `shared/rest-sources/` ([chapter 18](18-cli.md)). Regions, lists of
|
|
365
470
|
values, processes and workflow steps refer to sources and credentials by name, so nothing needs remapping.
|
|
471
|
+
JSON sources are part of the application (`json_sources`, in `app.yaml` of the directory format); their
|
|
472
|
+
views are not: they belong to your schema's migration scripts, like the table.
|
|
366
473
|
|
|
367
474
|
## Security notes
|
|
368
475
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 77: a JSON source (sprint 142, AP-8; migration 142;
|
|
3
|
+
-- docs/guide/19-rest-data-sources.md "JSON sources")
|
|
4
|
+
--
|
|
5
|
+
-- hr.emp gets a jsonb column "profile": a document per employee with a
|
|
6
|
+
-- job title, a start date, an office, skills (objects) and languages
|
|
7
|
+
-- (values), and keys no form knows about (e.g. "badge"). JSON source
|
|
8
|
+
-- EMP_PROFILE (Shared Components → JSON sources) describes it, and the
|
|
9
|
+
-- three views below are what its "Create views" makes:
|
|
10
|
+
--
|
|
11
|
+
-- hr.emp_profile empno and a column per path (title, since,
|
|
12
|
+
-- office_city, office_floor, remote);
|
|
13
|
+
-- hr.emp_profile_skills a row per skill: empno, row_key, position,
|
|
14
|
+
-- skill, level;
|
|
15
|
+
-- hr.emp_profile_languages a row per language.
|
|
16
|
+
--
|
|
17
|
+
-- Saving the form changes only the paths whose values changed, so "badge"
|
|
18
|
+
-- and anything else in the document stay. Page 51 "Employee profiles"
|
|
19
|
+
-- (Administration): a report of the employees with JSON path columns
|
|
20
|
+
-- (title and first skill read from the document), a form on
|
|
21
|
+
-- hr.emp_profile and the skills and languages as detail grids.
|
|
22
|
+
-- =====================================================================
|
|
23
|
+
|
|
24
|
+
alter table hr.emp add column if not exists profile jsonb
|
|
25
|
+
check (profile is null or jsonb_typeof(profile) = 'object');
|
|
26
|
+
comment on column hr.emp.profile is 'Profile document (JSON source EMP_PROFILE): title, since, office {city, floor}, remote, skills [{name, level}], languages []';
|
|
27
|
+
|
|
28
|
+
update hr.emp e set profile = p.doc::jsonb
|
|
29
|
+
from (values
|
|
30
|
+
(7839, '{"title": "Chief executive", "since": "1981-11-17", "office": {"city": "New York", "floor": 30}, "remote": false,
|
|
31
|
+
"skills": [{"name": "Strategy", "level": 5}, {"name": "Negotiation", "level": 4}], "languages": ["en", "fr"],
|
|
32
|
+
"badge": {"color": "gold", "printed": "2024-01-08"}}'),
|
|
33
|
+
(7698, '{"title": "Sales manager", "since": "1981-05-01", "office": {"city": "Chicago", "floor": 12}, "remote": false,
|
|
34
|
+
"skills": [{"name": "Sales", "level": 5}, {"name": "Coaching", "level": 3, "certified": true}], "languages": ["en", "es"]}'),
|
|
35
|
+
(7782, '{"title": "Accounting manager", "since": "1981-06-09", "office": {"city": "New York", "floor": 28},
|
|
36
|
+
"skills": [{"name": "Accounting", "level": 5}, {"name": "SQL", "level": 3}], "languages": ["en"]}'),
|
|
37
|
+
(7566, '{"title": "Research manager", "since": "1981-04-02", "office": {"city": "Dallas", "floor": 4}, "remote": true,
|
|
38
|
+
"skills": [{"name": "PostgreSQL", "level": 5}, {"name": "Statistics", "level": 4}], "languages": ["en", "de", "nl"],
|
|
39
|
+
"badge": {"color": "blue"}}'),
|
|
40
|
+
(7788, '{"title": "Analyst", "since": "1987-04-19", "office": {"city": "Dallas"}, "remote": true,
|
|
41
|
+
"skills": [{"name": "SQL", "level": 5}, {"name": "TypeScript", "level": 3}], "languages": ["en"]}')
|
|
42
|
+
) as p (empno, doc)
|
|
43
|
+
where e.empno = p.empno and e.profile is null;
|
|
44
|
+
|
|
45
|
+
-- the JSON source (the builder writes this list; it travels with the application's export)
|
|
46
|
+
update meta.app set json_sources = json_sources || $j$[{"name": "EMP_PROFILE",
|
|
47
|
+
"description": "The employee profile (hr.emp.profile): job title, office, skills and languages",
|
|
48
|
+
"schema": "hr", "table": "emp", "key": ["empno"], "column": "profile", "view": "emp_profile",
|
|
49
|
+
"columns": [{"name": "title", "path": ["title"], "type": "text"}, {"name": "since", "path": ["since"], "type": "date"},
|
|
50
|
+
{"name": "office_city", "path": ["office", "city"], "type": "text"},
|
|
51
|
+
{"name": "office_floor", "path": ["office", "floor"], "type": "integer"},
|
|
52
|
+
{"name": "remote", "path": ["remote"], "type": "boolean"}],
|
|
53
|
+
"arrays": [{"name": "skills", "path": ["skills"], "view": "emp_profile_skills",
|
|
54
|
+
"columns": [{"name": "skill", "path": ["name"], "type": "text"}, {"name": "level", "path": ["level"], "type": "integer"}]},
|
|
55
|
+
{"name": "languages", "path": ["languages"], "view": "emp_profile_languages",
|
|
56
|
+
"columns": [{"name": "language", "path": [], "type": "text"}]}]}]$j$::jsonb
|
|
57
|
+
where alias = 'hr' and not json_sources @> '[{"name": "EMP_PROFILE"}]';
|
|
58
|
+
|
|
59
|
+
-- the views, as Shared Components → JSON sources → Create views makes them
|
|
60
|
+
create view hr.emp_profile with (security_invoker = true) as
|
|
61
|
+
select t.empno,
|
|
62
|
+
t.profile #>> array['title'] as title,
|
|
63
|
+
case when meta.input_ok(t.profile #>> array['since'], 'date') then (t.profile #>> array['since'])::date end as since,
|
|
64
|
+
t.profile #>> array['office', 'city'] as office_city,
|
|
65
|
+
case when meta.input_ok(t.profile #>> array['office', 'floor'], 'integer') then (t.profile #>> array['office', 'floor'])::integer end as office_floor,
|
|
66
|
+
case when meta.input_ok(t.profile #>> array['remote'], 'boolean') then (t.profile #>> array['remote'])::boolean end as remote
|
|
67
|
+
from hr.emp t;
|
|
68
|
+
|
|
69
|
+
comment on view hr.emp_profile is 'JSON source EMP_PROFILE of application hr: hr.emp.profile (made by pgkiln; Shared Components → JSON sources makes it again)';
|
|
70
|
+
|
|
71
|
+
create trigger json_source_write
|
|
72
|
+
instead of insert or update or delete on hr.emp_profile
|
|
73
|
+
for each row execute function meta.json_source_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","rows":false,"columns":[{"name":"title","path":["title"]},{"name":"since","path":["since"]},{"name":"office_city","path":["office","city"]},{"name":"office_floor","path":["office","floor"]},{"name":"remote","path":["remote"]}]}');
|
|
74
|
+
|
|
75
|
+
grant delete, insert, select, update on hr.emp_profile to "hr_app";
|
|
76
|
+
|
|
77
|
+
create view hr.emp_profile_skills with (security_invoker = true) as
|
|
78
|
+
select t.empno,
|
|
79
|
+
k.key as row_key,
|
|
80
|
+
k.idx + 1 as position,
|
|
81
|
+
e.v #>> array['name'] as skill,
|
|
82
|
+
case when meta.input_ok(e.v #>> array['level'], 'integer') then (e.v #>> array['level'])::integer end as level
|
|
83
|
+
from hr.emp t
|
|
84
|
+
cross join lateral meta.jsonb_element_keys(t.profile #> array['skills']) k
|
|
85
|
+
cross join lateral (select (t.profile #> array['skills']) -> k.idx as v) e;
|
|
86
|
+
|
|
87
|
+
comment on view hr.emp_profile_skills is 'JSON source EMP_PROFILE of application hr: the array skills of hr.emp.profile, one row per element (made by pgkiln)';
|
|
88
|
+
|
|
89
|
+
create trigger json_source_write
|
|
90
|
+
instead of insert or update or delete on hr.emp_profile_skills
|
|
91
|
+
for each row execute function meta.json_array_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","array":["skills"],"row_key":"row_key","position":"position","columns":[{"name":"skill","path":["name"]},{"name":"level","path":["level"]}]}');
|
|
92
|
+
|
|
93
|
+
grant delete, insert, select, update on hr.emp_profile_skills to "hr_app";
|
|
94
|
+
|
|
95
|
+
create view hr.emp_profile_languages with (security_invoker = true) as
|
|
96
|
+
select t.empno,
|
|
97
|
+
k.key as row_key,
|
|
98
|
+
k.idx + 1 as position,
|
|
99
|
+
e.v #>> array[]::text[] as language
|
|
100
|
+
from hr.emp t
|
|
101
|
+
cross join lateral meta.jsonb_element_keys(t.profile #> array['languages']) k
|
|
102
|
+
cross join lateral (select (t.profile #> array['languages']) -> k.idx as v) e;
|
|
103
|
+
|
|
104
|
+
comment on view hr.emp_profile_languages is 'JSON source EMP_PROFILE of application hr: the array languages of hr.emp.profile, one row per element (made by pgkiln)';
|
|
105
|
+
|
|
106
|
+
create trigger json_source_write
|
|
107
|
+
instead of insert or update or delete on hr.emp_profile_languages
|
|
108
|
+
for each row execute function meta.json_array_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","array":["languages"],"row_key":"row_key","position":"position","columns":[{"name":"language","path":[]}]}');
|
|
109
|
+
|
|
110
|
+
grant delete, insert, select, update on hr.emp_profile_languages to "hr_app";
|
|
111
|
+
|
|
112
|
+
-- ---------------------------------------------------------------- page 51
|
|
113
|
+
|
|
114
|
+
insert into meta.page (app_id, page_no, name, title, parent_page)
|
|
115
|
+
select id, 51, 'Employee profiles', 'Employee profiles', 1 from meta.app where alias = 'hr';
|
|
116
|
+
|
|
117
|
+
insert into meta.region (page_id, seq, title, type, columns, source, table_name, pk_column, pk_item, condition, config)
|
|
118
|
+
select p.id, r.seq, r.title, r.type, r.cols, r.source, r.table_name, r.pk_column, r.pk_item, r.condition, r.config::jsonb
|
|
119
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
120
|
+
(10, 'Employees', 'report', 12,
|
|
121
|
+
'select empno, ename, job, profile from hr.emp order by profile is null, ename', null, null, null, null,
|
|
122
|
+
'{"hidden": ["profile"], "headings": {"empno": "No.", "ename": "Name", "job": "Job", "title": "Title (profile)", "first_skill": "First skill", "since": "Since"},
|
|
123
|
+
"json_columns": [{"name": "title", "column": "profile", "path": "title", "type": "text"},
|
|
124
|
+
{"name": "first_skill", "column": "profile", "path": "skills.0.name", "type": "text"},
|
|
125
|
+
{"name": "since", "column": "profile", "path": "since", "type": "date"}],
|
|
126
|
+
"link": {"page": 51, "column": "empno", "items": {"P51_EMPNO": "#empno#"}}, "page_size": 5}'),
|
|
127
|
+
(20, 'Profile', 'form', 12, null, 'hr.emp_profile', 'empno', 'P51_EMPNO', ':P51_EMPNO is not null', '{}'),
|
|
128
|
+
(30, 'Skills', 'grid', 8,
|
|
129
|
+
'select empno, row_key, position, skill, level
|
|
130
|
+
from hr.emp_profile_skills
|
|
131
|
+
where empno::text = :P51_EMPNO
|
|
132
|
+
order by position', 'hr.emp_profile_skills', 'row_key', null, ':P51_EMPNO is not null',
|
|
133
|
+
'{"master": {"item": "P51_EMPNO", "column": "empno"}, "readonly": ["position"], "layout": {"hidden": ["empno", "row_key"]},
|
|
134
|
+
"headings": {"position": "#", "skill": "Skill", "level": "Level (1-5)"}, "columns": {"skill": {"required": true}}, "row_actions": {"delete": true}}'),
|
|
135
|
+
(40, 'Languages', 'grid', 4,
|
|
136
|
+
'select empno, row_key, position, language
|
|
137
|
+
from hr.emp_profile_languages
|
|
138
|
+
where empno::text = :P51_EMPNO
|
|
139
|
+
order by position', 'hr.emp_profile_languages', 'row_key', null, ':P51_EMPNO is not null',
|
|
140
|
+
'{"master": {"item": "P51_EMPNO", "column": "empno"}, "readonly": ["position"], "layout": {"hidden": ["empno", "row_key"]},
|
|
141
|
+
"headings": {"position": "#", "language": "Language"}, "row_actions": {"delete": true}}')
|
|
142
|
+
) as r (seq, title, type, cols, source, table_name, pk_column, pk_item, condition, config)
|
|
143
|
+
where a.alias = 'hr' and p.page_no = 51;
|
|
144
|
+
|
|
145
|
+
insert into meta.item (page_id, region_id, seq, name, label, type, source_column)
|
|
146
|
+
select p.id, r.id, i.seq, i.name, i.label, i.type, i.col
|
|
147
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
148
|
+
join meta.region r on r.page_id = p.id and r.title = 'Profile', (values
|
|
149
|
+
(10, 'P51_EMPNO', 'Employee', 'hidden', 'empno'),
|
|
150
|
+
(20, 'P51_TITLE', 'Title', 'text', 'title'),
|
|
151
|
+
(30, 'P51_SINCE', 'Since', 'date', 'since'),
|
|
152
|
+
(40, 'P51_OFFICE_CITY', 'Office city', 'text', 'office_city'),
|
|
153
|
+
(50, 'P51_OFFICE_FLOOR', 'Office floor', 'number', 'office_floor'),
|
|
154
|
+
(60, 'P51_REMOTE', 'Works remotely', 'switch', 'remote')
|
|
155
|
+
) as i (seq, name, label, type, col)
|
|
156
|
+
where a.alias = 'hr' and p.page_no = 51;
|
|
157
|
+
|
|
158
|
+
insert into meta.button (page_id, region_id, seq, name, label, action, hot)
|
|
159
|
+
select p.id, r.id, 10, 'SAVE', 'Apply Changes', 'submit', true
|
|
160
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
161
|
+
join meta.region r on r.page_id = p.id and r.title = 'Profile'
|
|
162
|
+
where a.alias = 'hr' and p.page_no = 51;
|
|
163
|
+
|
|
164
|
+
insert into meta.process (page_id, region_id, seq, name, type, point)
|
|
165
|
+
select p.id, r.id, r.seq, case r.type when 'form' then 'Process form Profile' else 'Save ' || r.title end,
|
|
166
|
+
case r.type when 'form' then 'form_dml' else 'grid_dml' end, 'submit'
|
|
167
|
+
from meta.page p join meta.app a on a.id = p.app_id
|
|
168
|
+
join meta.region r on r.page_id = p.id and r.type in ('form', 'grid')
|
|
169
|
+
where a.alias = 'hr' and p.page_no = 51;
|
|
170
|
+
|
|
171
|
+
insert into meta.nav_entry (app_id, parent_id, seq, label, icon, target_page)
|
|
172
|
+
select a.id, n.id, 80, 'Employee profiles', 'id-card', 51
|
|
173
|
+
from meta.app a join meta.nav_entry n on n.app_id = a.id and n.label = 'Administration' and n.parent_id is null
|
|
174
|
+
where a.alias = 'hr';
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -2020,6 +2020,24 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
|
|
|
2020
2020
|
.ir-dialog-head h2 { margin: 0; font-size: 1.05rem; }
|
|
2021
2021
|
.ir-dialog .ir-body { padding: 1rem; }
|
|
2022
2022
|
.ir-dialog .filter-row { min-width: 0; }
|
|
2023
|
+
.filter-dialog .date-field { display: flex; min-width: 0; }
|
|
2024
|
+
.filter-dialog .date-field input { flex: 1; min-width: 0; }
|
|
2025
|
+
.filter-dialog .filter-values, .filter-dialog .help { grid-column: 1 / -1; }
|
|
2026
|
+
.filter-values { display: grid; gap: 0.3rem; }
|
|
2027
|
+
.filter-values-list { list-style: none; margin: 0; padding: 0.25rem 0.4rem; max-height: 14rem; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); }
|
|
2028
|
+
.filter-values-list .check { display: flex; gap: 0.4rem; align-items: center; padding: 0.15rem 0; }
|
|
2029
|
+
.filter-values-list .muted { margin-left: auto; font-variant-numeric: tabular-nums; }
|
|
2030
|
+
/* (143) Actions → Columns: a row per column, dragged by its handle or moved with its arrows */
|
|
2031
|
+
.ir-col-order { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.15rem; }
|
|
2032
|
+
.ir-col-order > li { display: flex; align-items: center; gap: 0.4rem; padding: 0.15rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius); background: var(--surface); }
|
|
2033
|
+
.ir-col-order > li:hover { border-color: var(--border); }
|
|
2034
|
+
.ir-col-order > li.dragging { opacity: 0.55; border-style: dashed; border-color: var(--accent); }
|
|
2035
|
+
.ir-col-order .check { flex: 1; min-width: 0; }
|
|
2036
|
+
.ir-col-order .drag-handle { color: var(--muted); cursor: grab; display: inline-flex; }
|
|
2037
|
+
.no-js .ir-col-order .drag-handle, html:not(.js) .ir-col-order .drag-handle { display: none; }
|
|
2038
|
+
.ir-col-move { display: inline-flex; gap: 0.15rem; }
|
|
2039
|
+
.ir-col-move .btn { min-width: 2rem; padding: 0.1rem 0.4rem; line-height: 1.2; }
|
|
2040
|
+
.ir-col-move .btn:disabled { opacity: 0.35; }
|
|
2023
2041
|
/* the search field with its column list (APEX: the magnifier's column menu) */
|
|
2024
2042
|
.search-field { display: flex; flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
2025
2043
|
.search-field:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
|
package/public/app.js
CHANGED
|
@@ -4420,6 +4420,147 @@ const reportTexts = (() => {
|
|
|
4420
4420
|
window.addEventListener('resize', shut);
|
|
4421
4421
|
})();
|
|
4422
4422
|
|
|
4423
|
+
// (143; APEX 26.2: multi-select and date-range filters) Actions → Filter shows the fields its operator needs: one
|
|
4424
|
+
// value; "is one of" a list of the column's values with counts and a search (the ticked ones go as r<id>_fl, next to
|
|
4425
|
+
// values typed with commas); "is between" two values; "is in" a period, offered on date columns only. On a date
|
|
4426
|
+
// column the value fields get the date picker in the application's date format.
|
|
4427
|
+
(() => {
|
|
4428
|
+
const t = (k, d) => reportTexts[k] || d;
|
|
4429
|
+
const cache = new Map();
|
|
4430
|
+
function fill(box, column) {
|
|
4431
|
+
const list = box.querySelector('.filter-values');
|
|
4432
|
+
if (!list || list.dataset.column === column) return;
|
|
4433
|
+
list.dataset.column = column;
|
|
4434
|
+
list.replaceChildren();
|
|
4435
|
+
const search = document.createElement('input');
|
|
4436
|
+
search.type = 'search';
|
|
4437
|
+
search.placeholder = t('report.filter_values', 'Filter the values');
|
|
4438
|
+
search.setAttribute('aria-label', search.placeholder);
|
|
4439
|
+
const ul = document.createElement('ul');
|
|
4440
|
+
ul.className = 'filter-values-list';
|
|
4441
|
+
list.append(search, ul);
|
|
4442
|
+
search.addEventListener('input', () => {
|
|
4443
|
+
const q = search.value.trim().toLowerCase();
|
|
4444
|
+
ul.querySelectorAll('li').forEach((li) => { li.hidden = !!q && !li.textContent.toLowerCase().includes(q); });
|
|
4445
|
+
});
|
|
4446
|
+
const url = new URL(box.dataset.values, location.href);
|
|
4447
|
+
new URL(location.href).searchParams.forEach((v, k) => url.searchParams.append(k, v));
|
|
4448
|
+
url.searchParams.set('col', column);
|
|
4449
|
+
const key = url.toString();
|
|
4450
|
+
const got = cache.get(key) ?? fetch(url, { headers: { accept: 'application/json' }, credentials: 'same-origin' }).then((r) => (r.ok ? r.json() : { values: [] })).catch(() => ({ values: [] }));
|
|
4451
|
+
cache.set(key, got);
|
|
4452
|
+
got.then(({ values }) => {
|
|
4453
|
+
if (list.dataset.column !== column) return;
|
|
4454
|
+
const shown = (values || []).filter((x) => x.value !== null);
|
|
4455
|
+
if (!shown.length) {
|
|
4456
|
+
const li = document.createElement('li');
|
|
4457
|
+
li.className = 'muted';
|
|
4458
|
+
li.textContent = t('report.no_values', 'No values');
|
|
4459
|
+
ul.append(li);
|
|
4460
|
+
return;
|
|
4461
|
+
}
|
|
4462
|
+
for (const { value, count } of shown) {
|
|
4463
|
+
const li = document.createElement('li');
|
|
4464
|
+
const label = document.createElement('label');
|
|
4465
|
+
label.className = 'check';
|
|
4466
|
+
const box2 = document.createElement('input');
|
|
4467
|
+
box2.type = 'checkbox';
|
|
4468
|
+
box2.name = box.dataset.list;
|
|
4469
|
+
box2.value = value;
|
|
4470
|
+
box2.setAttribute('form', box.dataset.form);
|
|
4471
|
+
const n = document.createElement('span');
|
|
4472
|
+
n.className = 'muted';
|
|
4473
|
+
n.textContent = ` ${count}`;
|
|
4474
|
+
label.append(box2, ` ${value}`, n);
|
|
4475
|
+
li.append(label);
|
|
4476
|
+
ul.append(li);
|
|
4477
|
+
}
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
4480
|
+
function sync(box) {
|
|
4481
|
+
const col = box.querySelector('[data-filter-column]');
|
|
4482
|
+
const op = box.querySelector('[data-filter-op]');
|
|
4483
|
+
if (!col || !op) return;
|
|
4484
|
+
const date = col.selectedOptions[0]?.dataset.kind === 'date';
|
|
4485
|
+
op.querySelectorAll('option[data-op-kind="period"]').forEach((o) => { o.hidden = !date; o.disabled = !date; });
|
|
4486
|
+
if (!date && op.selectedOptions[0]?.dataset.opKind === 'period') op.value = 'eq';
|
|
4487
|
+
const kind = op.selectedOptions[0]?.dataset.opKind || 'one';
|
|
4488
|
+
box.querySelectorAll('.filter-field[data-show]').forEach((el) => { el.hidden = !el.dataset.show.split(' ').includes(kind); });
|
|
4489
|
+
for (const input of box.querySelectorAll('input[data-mask-off]')) {
|
|
4490
|
+
const picker = date && kind !== 'list';
|
|
4491
|
+
if (picker) input.dataset.dateMask = input.dataset.maskOff;
|
|
4492
|
+
else delete input.dataset.dateMask;
|
|
4493
|
+
input.placeholder = picker ? input.dataset.maskOff : input.getAttribute('aria-label') || '';
|
|
4494
|
+
const btn = input.parentElement?.querySelector('[data-date-pick]');
|
|
4495
|
+
if (btn) btn.hidden = !picker;
|
|
4496
|
+
}
|
|
4497
|
+
if (kind === 'list') fill(box, col.value);
|
|
4498
|
+
}
|
|
4499
|
+
document.querySelectorAll('[data-filter-dialog]').forEach(sync);
|
|
4500
|
+
document.addEventListener('change', (e) => {
|
|
4501
|
+
const box = e.target.closest?.('[data-filter-dialog]');
|
|
4502
|
+
if (box && e.target.matches('[data-filter-column], [data-filter-op]')) sync(box);
|
|
4503
|
+
});
|
|
4504
|
+
})();
|
|
4505
|
+
|
|
4506
|
+
// (143; APEX 26.2: drag-and-drop column ordering) Actions → Columns: drag a column to another place, or move it with
|
|
4507
|
+
// its arrow buttons (Up / Down; without JavaScript they submit the form and the server moves it); the dialog's form
|
|
4508
|
+
// sends the columns in the list's order when applied. Each move is announced.
|
|
4509
|
+
(() => {
|
|
4510
|
+
const t = (k, d) => reportTexts[k] || d;
|
|
4511
|
+
const fmt = (s, p) => s.replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m));
|
|
4512
|
+
function refresh(list, moved) {
|
|
4513
|
+
const items = [...list.children];
|
|
4514
|
+
items.forEach((li, i) => {
|
|
4515
|
+
const [up, down] = li.querySelectorAll('.ir-col-move button');
|
|
4516
|
+
if (up) up.disabled = i === 0;
|
|
4517
|
+
if (down) down.disabled = i === items.length - 1;
|
|
4518
|
+
});
|
|
4519
|
+
if (!moved) return;
|
|
4520
|
+
const status = list.closest('.ir-columns')?.parentElement?.querySelector('[data-col-status]');
|
|
4521
|
+
const name = moved.querySelector('label')?.textContent.trim() || moved.dataset.colName;
|
|
4522
|
+
if (status) status.textContent = fmt(t('report.moved', '{column}: position {n} of {total}'), { column: name, n: items.indexOf(moved) + 1, total: items.length });
|
|
4523
|
+
}
|
|
4524
|
+
document.addEventListener('click', (e) => {
|
|
4525
|
+
const btn = e.target.closest?.('[data-col-order] .ir-col-move button');
|
|
4526
|
+
if (!btn) return;
|
|
4527
|
+
e.preventDefault();
|
|
4528
|
+
const li = btn.closest('li');
|
|
4529
|
+
const list = li.parentElement;
|
|
4530
|
+
const up = btn === li.querySelector('.ir-col-move button');
|
|
4531
|
+
const other = up ? li.previousElementSibling : li.nextElementSibling;
|
|
4532
|
+
if (!other) return;
|
|
4533
|
+
if (up) other.before(li);
|
|
4534
|
+
else other.after(li);
|
|
4535
|
+
refresh(list, li);
|
|
4536
|
+
(btn.disabled ? li.querySelector('.ir-col-move button:not(:disabled)') : btn)?.focus();
|
|
4537
|
+
});
|
|
4538
|
+
let dragged = null;
|
|
4539
|
+
document.addEventListener('dragstart', (e) => {
|
|
4540
|
+
const li = e.target.closest?.('[data-col-order] > li');
|
|
4541
|
+
if (!li) return;
|
|
4542
|
+
dragged = li;
|
|
4543
|
+
li.classList.add('dragging');
|
|
4544
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
4545
|
+
e.dataTransfer.setData('text/plain', li.dataset.colName || '');
|
|
4546
|
+
});
|
|
4547
|
+
document.addEventListener('dragover', (e) => {
|
|
4548
|
+
const list = e.target.closest?.('[data-col-order]');
|
|
4549
|
+
if (!dragged || !list || dragged.parentElement !== list) return;
|
|
4550
|
+
e.preventDefault();
|
|
4551
|
+
const after = [...list.children].find((li) => li !== dragged && e.clientY < li.getBoundingClientRect().top + li.offsetHeight / 2);
|
|
4552
|
+
if (after) after.before(dragged);
|
|
4553
|
+
else list.append(dragged);
|
|
4554
|
+
});
|
|
4555
|
+
document.addEventListener('drop', (e) => { if (dragged && e.target.closest?.('[data-col-order]')) e.preventDefault(); });
|
|
4556
|
+
document.addEventListener('dragend', () => {
|
|
4557
|
+
if (!dragged) return;
|
|
4558
|
+
dragged.classList.remove('dragging');
|
|
4559
|
+
refresh(dragged.parentElement, dragged);
|
|
4560
|
+
dragged = null;
|
|
4561
|
+
});
|
|
4562
|
+
})();
|
|
4563
|
+
|
|
4423
4564
|
// (sprint 76) the report pager's rows per page: sent on change
|
|
4424
4565
|
document.addEventListener('change', (e) => {
|
|
4425
4566
|
if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
|
package/src/app.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { ldapRoutes } from './builder/ldap.ts';
|
|
|
27
27
|
import { aiRoutes } from './builder/ai.ts';
|
|
28
28
|
import { aiAgentRoutes } from './builder/ai-agents.ts';
|
|
29
29
|
import { vectorProviderRoutes } from './builder/vector-providers.ts';
|
|
30
|
+
import { jsonSourceRoutes } from './builder/json-sources.ts';
|
|
31
|
+
import { documentViewRoutes } from './builder/document-view.ts';
|
|
30
32
|
import { mailRoutes } from './builder/mail.ts';
|
|
31
33
|
import { backupRoutes } from './builder/backups.ts';
|
|
32
34
|
import { availabilityRoutes } from './builder/availability.ts';
|
|
@@ -252,6 +254,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
252
254
|
await app.register(aiAgentRoutes);
|
|
253
255
|
// (sprint 136, AP-1) vector providers: Shared Components and Workspace utilities
|
|
254
256
|
await app.register(vectorProviderRoutes);
|
|
257
|
+
// (sprint 142, AP-8) JSON sources (Shared Components) and document views (Table Properties)
|
|
258
|
+
await app.register(jsonSourceRoutes);
|
|
259
|
+
await app.register(documentViewRoutes);
|
|
255
260
|
await app.register(mailRoutes);
|
|
256
261
|
await app.register(backupRoutes);
|
|
257
262
|
await app.register(availabilityRoutes);
|