@xeplr/cli 0.2.0 → 0.3.1

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/README.md CHANGED
@@ -77,6 +77,7 @@ These are architecture decisions, not defaults to tune — each one exists becau
77
77
  | **`REDIS_PREFIX` is required and unique per app** (`api/development.env`, set to `<name>:`). | `@xeplr/auth`, `@xeplr/utils` | Two apps on one Redis under the shared default serve each other's sessions, menus and API permissions. Sign-in refuses to start without it, or with `xeplr:`. |
78
78
  | **Designing, new forms and publishing are Super Admin only.** | `api/routes/access.js`, `@xeplr/auth` menu routes | Publishing changes database tables. Every other screen route checks the permission catalog. |
79
79
  | **The browser is not a security boundary.** Front-end hooks (`ui/src/pages/Edit<Form>.jsx`) shape what people see and send; anything that must hold goes in server hooks (`api/screens/<form>/<form>.hooks.js`); data shape in the model (`<form>.model.js`). | `@xeplr/ui-factory`, `@xeplr/factory` | |
80
+ | **Anything every app would write identically lives in a package, not in the generated app.** A generated app is a starting point that then lives its own life — glue copied into it can never be fixed centrally, so an app made last month keeps that copy forever. When the flow designer needed this app's forms as its Screen steps, the wiring went into `@xeplr/ui-factory` (`useScreenSource`) and the generated page passes two props. | `@xeplr/ui-factory`, `@xeplr/ui-workflow` | A fix or a new control type has to reach apps that already exist, and it cannot reach code that was copied into them. `test/cli.test.js` fails if the glue reappears in the generated app. |
80
81
  | **Every data address is under `/api`.** | `api/routes`, `ui/vite.config.js` | A page path and a data path must never collide. |
81
82
  | **No guessed database names or connections.** | `api/development.env` | A wrong-but-present value starts cleanly and reads as empty data. |
82
83
  | **Email is checked at start, never fatal.** The sign-in banner shows `email ✓ …` or `✗ NOT WORKING — reason`. | `@xeplr/auth` | Sign-in works without email; activation, invite and reset links cannot be sent until `EMAIL_PROVIDER` is set. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Creates a working xeplr application — API, sign-in and UI — from a few answers.",
5
5
  "license": "MIT",
6
6
  "author": "xeppie <admin@xeplr.com>",
@@ -38,4 +38,5 @@ The API (`@xeplr/base-apis`) and the sign-in service (`@xeplr/auth`), with this
38
38
  | `__PREFIX___CONNECTION`, `AUTH_DB_CONNECTION_INFO_ENCRYPTED` | the database, encrypted — `npm run db:encrypt` |
39
39
  | `DB_API`, `AUTH_DB_NAME` | the two database names — never guessed |
40
40
  | `AUTH_SUPER_ADMIN_EMAIL` / `_PASSWORD` | the first account — created once, when the sign-in database is first set up |
41
+ | `AUTH_TENANT_HEADER` | `none`: one set of roles for everyone. A multi-tenant app names its company header (e.g. `x-company-id`), so a role made inside a company belongs to it |
41
42
  | `EMAIL_PROVIDER` and its settings | optional; without them sign-in works, but activation and reset links cannot be sent |
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // The SAME workflow as the embedded one (../workflow.js): this app's database,
9
9
  // this app's tenancy and membership gate, the same permission checks, and the
10
- // same path — /api/workflow/flows — so the browser cannot tell the two apart.
10
+ // same path — /api/workflow — so the browser cannot tell the two apart.
11
11
  // What routes /api/workflow here: ui/.env WORKFLOW_URL for Vite's dev proxy,
12
12
  // nginx in production (README, "Production").
13
13
  require('dotenv').config({ path: require('path').join(__dirname, '..', (process.env.NODE_ENV || 'development') + '.env') });
@@ -28,7 +28,7 @@ async function start() {
28
28
  // This app's tenancy levels, registered exactly as the API registers them.
29
29
  await require('../db/setup');
30
30
  var app = require('../app');
31
- var flowsRouter = await require('../workflow')(app.memberGate);
31
+ var workflowRouter = await require('../workflow')(app.memberGate);
32
32
 
33
33
  var health = express.Router();
34
34
  health.get('/', function(req, res) { res.json({ status: 'ok', service: 'workflow' }); });
@@ -41,7 +41,7 @@ async function start() {
41
41
  middleware: app.middleware(),
42
42
  routes: {
43
43
  '/health': health,
44
- '/api/workflow/flows': flowsRouter
44
+ '/api/workflow': workflowRouter
45
45
  }
46
46
  });
47
47
  }
@@ -50,6 +50,12 @@ XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations
50
50
  AUTH_SUPER_ADMIN_EMAIL=__ADMIN_EMAIL__
51
51
  AUTH_SUPER_ADMIN_PASSWORD=__ADMIN_PASSWORD__
52
52
 
53
+ # ── Which company a role belongs to ──
54
+ # none: this app has one set of roles for everyone. A multi-tenant app names
55
+ # the header that carries the company id instead (e.g. x-company-id), so a
56
+ # role made inside a company belongs to that company.
57
+ AUTH_TENANT_HEADER=none
58
+
53
59
  # ── Links that go out in emails ──
54
60
  # FULLY QUALIFIED, and they point at UI pages rather than at the API. The
55
61
  # sign-in service builds these links and appends the token as ?token=... — an
@@ -21,10 +21,10 @@
21
21
  "@xeplr/auth": "^1.1.4",
22
22
  "@xeplr/base-apis": "^2.0.0",
23
23
  "@xeplr/db": "^1.0.6",
24
- "@xeplr/factory": "^1.0.6",
25
- "@xeplr/ui-factory": "^1.0.14",
24
+ "@xeplr/factory": "^1.1.0",
25
+ "@xeplr/ui-factory": "^1.1.0",
26
26
  "@xeplr/utils": "^1.0.11",
27
- "@xeplr/workflow": "^1.1.0",
27
+ "@xeplr/workflow": "^1.2.0",
28
28
  "dotenv": "^17.4.2",
29
29
  "express": "^5.2.0",
30
30
  "multer": "^2.0.2"
@@ -30,6 +30,8 @@ AUTH_URL=http://127.0.0.1:__AUTH_PORT__
30
30
  XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth,./node_modules/@xeplr/workflow/migrations-auth
31
31
  AUTH_SUPER_ADMIN_EMAIL=
32
32
  AUTH_SUPER_ADMIN_PASSWORD=
33
+ # none for one set of roles; a multi-tenant app names its company header, e.g. x-company-id
34
+ AUTH_TENANT_HEADER=none
33
35
 
34
36
  # ── Public addresses (what people's browsers open) ──
35
37
  AUTH_ACTIVATION_URL=https://app.example.com/auth/activate
@@ -20,24 +20,28 @@
20
20
  // "Start flow run"…), granted by @xeplr/workflow's
21
21
  // migrations-auth — in XEPLR_AUTH_MIGRATIONS.
22
22
  //
23
- // Mounted by routes/index.js at /api/workflow/flows, behind this app's auth gate.
23
+ // Mounted by routes/index.js at /api/workflow, behind this app's auth gate.
24
+ // Everything Workflow serves lives under it:
25
+ // /api/workflow/flows the flows people walk through (Journey)
26
+ // /api/workflow/workflows the designer's document API
27
+ // /api/workflow/actions what a step can DO, for the palette
24
28
  var express = require('express');
25
29
 
26
30
  var router = express.Router();
27
- var flows = null;
31
+ var workflow = null;
28
32
 
29
33
  router.use(function(req, res, next) {
30
34
  if (process.env.WORKFLOW_PORT) {
31
35
  return res.status(404).json({ message: 'Workflow runs on its own port (WORKFLOW_PORT) — route /api/workflow there (ui/.env WORKFLOW_URL, or nginx)' });
32
36
  }
33
37
  // Only before startup has finished — bin/www mounts it before listening.
34
- if (!flows) return res.status(503).json({ message: 'Flows are still starting' });
35
- return flows(req, res, next);
38
+ if (!workflow) return res.status(503).json({ message: 'Flows are still starting' });
39
+ return workflow(req, res, next);
36
40
  });
37
41
 
38
- /** bin/www hands over registerWorkflow's flowsRouter, already gated. */
39
- router.mount = function(flowsRouter) {
40
- flows = flowsRouter;
42
+ /** bin/www hands over registerWorkflow's router, already gated. */
43
+ router.mount = function(workflowRouter) {
44
+ workflow = workflowRouter;
41
45
  };
42
46
 
43
47
  module.exports = router;
@@ -34,9 +34,9 @@ router.get('/whoami', function(req, res) {
34
34
  // var { genericRoute } = require('@xeplr/base-apis');
35
35
  // router.use('/api/things', genericRoute({ key: 'thing', model: require('../models').Thing }));
36
36
 
37
- // Flows: Xeplr Workflow, at /api/workflow/flows — inside this API, or on its
37
+ // Flows: Xeplr Workflow, at /api/workflow — inside this API, or on its
38
38
  // own port with WORKFLOW_PORT (see routes/flows.js). The path is the same
39
39
  // either way: the browser never knows which.
40
- router.use('/api/workflow/flows', require('./flows'));
40
+ router.use('/api/workflow', require('./flows'));
41
41
 
42
42
  module.exports = router;
@@ -9,7 +9,14 @@
9
9
  // @xeplr/workflow's migrations-auth, which XEPLR_AUTH_MIGRATIONS loads.
10
10
  var { registerWorkflow } = require('@xeplr/workflow');
11
11
 
12
- /** Runs workflow's migrations, and returns its flows router — already gated. */
12
+ /**
13
+ * Runs workflow's migrations and returns its ROUTER — already gated.
14
+ *
15
+ * The whole router, not just the flows facade: it carries `/flows` (the
16
+ * screens a person walks through) AND `/workflows` (the designer's own
17
+ * document API). One designer serves both — @xeplr/ui-workflow's canvas, the
18
+ * same one every xeplr product uses — so the app has to serve what it reads.
19
+ */
13
20
  module.exports = async function startWorkflow(memberGate) {
14
21
  var workflow = await registerWorkflow({
15
22
  applicationId: '__NAME__',
@@ -23,5 +30,5 @@ module.exports = async function startWorkflow(memberGate) {
23
30
  access: true,
24
31
  mtMembershipGate: memberGate
25
32
  });
26
- return workflow.flowsRouter;
33
+ return workflow.router;
27
34
  };
@@ -11,12 +11,14 @@
11
11
  "dependencies": {
12
12
  "@tanstack/react-table": "^8.21.3",
13
13
  "@xeplr/ui-account": "^1.0.9",
14
- "@xeplr/ui-canvas": "^1.0.2",
15
- "@xeplr/ui-factory": "^1.0.14",
14
+ "@xeplr/ui-canvas": "^1.1.0",
15
+ "@xeplr/ui-factory": "^1.1.0",
16
16
  "@xeplr/ui-table": "^1.0.6",
17
17
  "react": "^19.2.4",
18
18
  "react-dom": "^19.2.4",
19
- "react-router-dom": "^7.13.2"
19
+ "react-router-dom": "^7.13.2",
20
+ "@xeplr/ui-workflow": "^1.2.0",
21
+ "@xeplr/ui-utils": "^1.0.2"
20
22
  },
21
23
  "devDependencies": {
22
24
  "@vitejs/plugin-react": "^5.2.0",
@@ -2,6 +2,9 @@
2
2
  //
3
3
  // Forms every UI in the app: make one, design it, publish it, open it, add it
4
4
  // to the side rail
5
+ // Masters the lists the rest of the app points at — departments, statuses,
6
+ // countries. Ordinary forms with a flag, gathered in one place because
7
+ // that is what they are: reference data, not daily work
5
8
  // Menu the side rail's items: rename, reorder, hide
6
9
  // Flows forms one after another — what is filled in decides the next; run by
7
10
  // Xeplr Workflow, and says how to connect it until it is
@@ -10,11 +13,13 @@
10
13
  // people use.
11
14
  import { useSearchParams } from 'react-router-dom'
12
15
  import Forms from './Forms.jsx'
16
+ import Masters from './Masters.jsx'
13
17
  import MenuSettings from './MenuSettings.jsx'
14
18
  import Flows from './Flows.jsx'
15
19
 
16
20
  const TABS = [
17
21
  { id: 'forms', label: 'Forms', Page: Forms },
22
+ { id: 'masters', label: 'Masters', Page: Masters },
18
23
  { id: 'flows', label: 'Flows', Page: Flows },
19
24
  { id: 'menu', label: 'Menu', Page: MenuSettings }
20
25
  ]
@@ -1,37 +1,54 @@
1
- // One flow, on the canvas: the published forms on the left, arrows between
2
- // them, and on the right what happens after the screen you picked. Saves
3
- // itself; Publish is what people then walk through.
4
- import { useEffect, useState } from 'react'
5
- import { useParams, Link } from 'react-router-dom'
6
- import { FlowBuilder } from '@xeplr/ui-factory'
7
- import { flows } from '../api/flows.js'
1
+ // ONE FLOW, ON WORKFLOW'S OWN CANVAS.
2
+ //
3
+ // The designer is a component from @xeplr/ui-workflow — the same one BI and
4
+ // every other xeplr product uses — dropped straight onto this page. It is not
5
+ // a section mounted at URLs of its own: this app already has a page for it,
6
+ // inside its own shell, and the designer renders where it is put.
7
+ //
8
+ // There used to be a second, screens-only builder here (@xeplr/ui-factory's
9
+ // FlowBuilder). One designer over one engine is the point: the trigger box,
10
+ // the condition boxes and the reference pickers are built once, and this app
11
+ // gets them by upgrading a package.
12
+ //
13
+ // THIS APP'S FORMS ARE THE FLOW'S SCREENS. A step can show one and wait for
14
+ // somebody to fill it in, and `useScreenSource` is @xeplr/ui-factory's answer
15
+ // to the two things the designer asks its host: which screens exist (with
16
+ // what each one hands back, so a later step can pick `title` by name) and how
17
+ // to design one without leaving the flow. Two props, because the alternative
18
+ // was the same twenty lines in every app — and a copy in your app could never
19
+ // be fixed once.
20
+ //
21
+ // WHERE THE API IS: /api/workflow, always — the dev proxy (vite.config.js) or
22
+ // nginx decides whether that is this API or Workflow on its own port.
23
+ import { useParams, useNavigate } from 'react-router-dom'
24
+ import { WorkflowDesigner } from '@xeplr/ui-workflow'
25
+ import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'
8
26
  import { factory } from '../api/factory.js'
9
27
 
10
28
  export default function FlowDesigner() {
11
29
  const { flow: key } = useParams()
12
- const [flow, setFlow] = useState(null)
13
- const [screens, setScreens] = useState([])
14
- const [error, setError] = useState(null)
30
+ const navigate = useNavigate()
31
+ const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
15
32
 
16
- useEffect(() => {
17
- flows.loadFlow(key).then(setFlow, (e) => setError(e.message))
18
- // A step is a FORM (an edit screen) that is published: what people will
19
- // actually see, and something they can fill in.
20
- factory.listScreens().then((rows) => setScreens(rows
21
- .filter((s) => /_edit$/.test(s.screenKey) && s.version !== null)
22
- .map((s) => ({ id: s.screenKey, name: s.name }))), () => {})
23
- }, [key])
24
-
25
- if (error) return <div className="app-page"><div className="app-error">{error}</div></div>
26
- if (!flow) return null
27
33
  return (
28
34
  <div className="app-designer">
29
- <div className="app-designer-bar">
30
- <Link to="/configure?tab=flows">← All flows</Link>
31
- </div>
32
- <div className="app-designer-canvas">
33
- <FlowBuilder {...flows.builderProps} flow={flow} screens={screens} loadScreen={factory.loadScreen} />
34
- </div>
35
+ <WorkflowDesigner
36
+ apiBase="/api/workflow"
37
+ screens={screens}
38
+ screenEditor={screenEditor}
39
+ // BY KEY, because that is what this app's URLs carry:
40
+ // /configure/flows/leave_request. A flow is created on the Flows page
41
+ // with a key made from its name, and that key is how it is addressed
42
+ // for the rest of its life — a readable link that survives a rebuild
43
+ // minting new ids.
44
+ workflowKey={key === 'new' ? undefined : key}
45
+ newKind={key === 'new' ? 'workflow' : undefined}
46
+ // Only reachable from /configure/flows/new, which this app does not
47
+ // link to — the Flows page creates the flow first, so the designer
48
+ // always opens on one that exists.
49
+ onOpened={(saved) => navigate('/configure/flows/' + saved, { replace: true })}
50
+ onBack={() => navigate('/configure?tab=flows')}
51
+ />
35
52
  </div>
36
53
  )
37
54
  }
@@ -1,15 +1,29 @@
1
1
  // CONFIGURE UI → FLOWS — every journey across the app's forms.
2
2
  //
3
- // A flow is screens one after another: what someone fills in on one decides
4
- // which comes next. It is run by Xeplr Workflow, so a journey can be left and
5
- // picked up later — by someone else, on another day. Design it here; open it
6
- // at /journey/<key>.
3
+ // A flow is what someone is walked through: screens one after another, and
4
+ // whatever has to happen between them. What they fill in on one decides which
5
+ // comes next. Xeplr Workflow runs it, so a journey can be left and picked up
6
+ // later — by someone else, on another day. Design it here; open it at
7
+ // /journey/<key>.
7
8
  //
8
- // Run by @xeplr/workflow inside this app's own API and database — nothing to
9
- // connect.
9
+ // A FLOW IS AN ORDINARY WORKFLOW, addressed by a key this page makes from its
10
+ // name. It used to be created as kind 'screens', which is a workflow whose
11
+ // steps are GENERATED from a design held in another app — and that app was
12
+ // the screens-only builder this one replaced. The designer then had to refuse
13
+ // to edit its own flows ("designed in Configure UI"), which is how a flow
14
+ // became a page you could open and not change.
15
+ //
16
+ // So a flow is a workflow now, and every kind of step is available inside it:
17
+ // a Screen for the part a person fills in, an Action for the email that goes
18
+ // out after it, a Condition for where it goes next. Flows created the old way
19
+ // still appear here and still run; the designer shows them read-only.
10
20
  import { useEffect, useState } from 'react'
11
21
  import { Link, useNavigate } from 'react-router-dom'
12
- import { flows } from '../api/flows.js'
22
+ import { listWorkflows, saveWorkflow, configureWorkflowApi } from '@xeplr/ui-workflow'
23
+
24
+ // The same mount the designer uses. Said here as well because this page can
25
+ // be the first one to ask, and a base set after the first request is too late.
26
+ configureWorkflowApi('/api/workflow')
13
27
 
14
28
  export default function Flows() {
15
29
  const [list, setList] = useState(null)
@@ -18,7 +32,7 @@ export default function Flows() {
18
32
  const [name, setName] = useState('')
19
33
  const navigate = useNavigate()
20
34
 
21
- const load = () => flows.listFlows().then(setList, (e) => {
35
+ const load = () => listWorkflows().then(setList, (e) => {
22
36
  const off = flowsOffline(e)
23
37
  if (off) setOffline(off)
24
38
  else setError(e.message)
@@ -31,7 +45,9 @@ export default function Flows() {
31
45
  if (!label) return
32
46
  const key = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
33
47
  try {
34
- await flows.createFlow({ key, name: label })
48
+ // No steps: the designer is where those come from, and it opens on the
49
+ // flow this creates. `key` is what every link to it uses from here on.
50
+ await saveWorkflow({ name: label, key, kind: 'workflow', status: 'draft' })
35
51
  navigate(`/configure/flows/${key}`)
36
52
  } catch (err) {
37
53
  setError(err.message)
@@ -62,14 +78,20 @@ export default function Flows() {
62
78
  <thead><tr><th>Flow</th><th>Key</th><th>Status</th><th>Screens</th><th /></tr></thead>
63
79
  <tbody>
64
80
  {list.map((f) => (
65
- <tr key={f.key}>
81
+ <tr key={f.id || f.key}>
66
82
  <td><strong>{f.name}</strong></td>
67
- <td className="app-sub">{f.key}</td>
68
- <td>{f.status}</td>
83
+ <td className="app-sub">{f.key || '—'}</td>
84
+ <td>{f.kind === 'screens' ? f.status + ' · read-only' : f.status}</td>
69
85
  <td>{typeof f.steps === 'number' ? f.steps : (f.steps || []).length}</td>
70
86
  <td className="app-row-actions">
71
- <Link to={`/configure/flows/${f.key}`}>Design</Link>
72
- {f.status === 'published' && <Link to={`/journey/${f.key}`}>Open</Link>}
87
+ {f.key && <Link to={`/configure/flows/${f.key}`}>Design</Link>}
88
+ {/* PUBLISHING IS A SCREENS-FLOW IDEA: there, publish is what
89
+ checks the design before somebody is walked through it.
90
+ A flow drawn in the designer has no such step, so it can
91
+ be opened as soon as it has one. */}
92
+ {(f.kind === 'screens' ? f.status === 'published' : (f.steps || []).length > 0) && f.key && (
93
+ <Link to={`/journey/${f.key}`}>Open</Link>
94
+ )}
73
95
  </td>
74
96
  </tr>
75
97
  ))}
@@ -9,27 +9,56 @@
9
9
  // Claude can make forms too — see "Create a new UI" in CLAUDE.md.
10
10
  import { useCallback, useEffect, useState } from 'react'
11
11
  import { useNavigate } from 'react-router-dom'
12
- import { listMenuItems, addMenuItem, removeMenuItem, useAccess } from '@xeplr/ui-account'
12
+ import { listMenuItems, addMenuItem, removeMenuItem, useAccess, getUsers } from '@xeplr/ui-account'
13
+ import { deleteForm } from '@xeplr/ui-factory'
14
+ // THE APP'S OWN DIALOGS. window.confirm cannot say which button is the
15
+ // dangerous one and looks like a different piece of software appearing over
16
+ // this one; a red box pinned to the page stays there until something else
17
+ // replaces it, long after it has been read.
18
+ import { raiseConfirm, raiseSnackbar } from '@xeplr/ui-utils'
13
19
  import { factory } from '../api/factory.js'
14
20
  import { FORM_MENU_PREFIX } from '../menu.js'
15
21
 
16
- /** listScreens rows → one entry per form: { key, name, source, list, edit }. */
22
+ /**
23
+ * listScreens rows → one entry per form.
24
+ *
25
+ * `description` and `createdBy` travel with it because of the thing that
26
+ * happens once more than a handful of people can make forms: three of them
27
+ * are called "Inspection", and the fields say what each HOLDS, never what it
28
+ * was for or whose it is. Deciding which to keep is otherwise guesswork.
29
+ */
17
30
  function groupForms(rows) {
18
31
  const byKey = new Map()
19
32
  rows.forEach((row) => {
20
33
  const m = /^(.*)_(list|edit)$/.exec(row.screenKey)
21
34
  const key = m ? m[1] : row.screenKey
22
- const entry = byKey.get(key) || { key, name: null, source: null, list: null, edit: null }
35
+ const entry = byKey.get(key) || {
36
+ key, name: null, source: null, list: null, edit: null,
37
+ description: null, createdBy: null, createdDate: null
38
+ }
23
39
  if (m) entry[m[2]] = row
24
40
  else entry.edit = row
25
41
  if (m && m[2] === 'list') entry.name = row.name
26
42
  entry.name = entry.name || row.name
27
43
  entry.source = entry.source || row.source
44
+ entry.description = entry.description || row.description
45
+ // The form's own half is the one that speaks for it; a list screen is
46
+ // only consulted when there is no other answer.
47
+ if (m && m[2] === 'edit') { entry.createdBy = row.createdBy; entry.createdDate = row.createdDate }
48
+ entry.createdBy = entry.createdBy || row.createdBy
49
+ entry.createdDate = entry.createdDate || row.createdDate
28
50
  byKey.set(key, entry)
29
51
  })
30
52
  return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
31
53
  }
32
54
 
55
+ /** "3 Feb 2026" — enough to tell two forms apart, no more. */
56
+ function madeOn(when) {
57
+ if (!when) return null
58
+ const d = new Date(when)
59
+ return isNaN(d) ? null : d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
60
+ }
61
+
33
62
  function state(screen) {
34
63
  if (!screen) return { label: '—', unpublished: false }
35
64
  if (screen.version === null) return { label: 'Not published yet', unpublished: true }
@@ -50,16 +79,19 @@ export default function Forms() {
50
79
  const navigate = useNavigate()
51
80
  const { refreshAccess } = useAccess()
52
81
  const [forms, setForms] = useState(null)
82
+ // WHO MADE EACH ONE. The screens table keeps the maker's id; the names live
83
+ // in the sign-in service, so they are fetched once here and matched up.
84
+ // Failing to get them is not an error worth showing — the date alone still
85
+ // tells two forms of the same name apart.
86
+ const [people, setPeople] = useState({})
53
87
  const [inMenu, setInMenu] = useState(new Set())
54
- const [error, setError] = useState(null)
55
- const [notice, setNotice] = useState(null)
56
88
  const [label, setLabel] = useState('')
57
89
  const [key, setKey] = useState('')
58
90
  const [keyEdited, setKeyEdited] = useState(false)
59
91
  const [busy, setBusy] = useState(null)
60
92
 
61
93
  const load = useCallback(() => {
62
- factory.listScreens().then((rows) => { setForms(groupForms(rows)); setError(null) }, (e) => setError(e.message))
94
+ factory.listScreens().then((rows) => setForms(groupForms(rows)), (e) => raiseSnackbar(e.message, { design: 'error' }))
63
95
  listMenuItems().then(
64
96
  (items) => setInMenu(new Set(items.filter((m) => m.name.startsWith(FORM_MENU_PREFIX) && !m.isHidden).map((m) => m.name.slice(FORM_MENU_PREFIX.length)))),
65
97
  () => {}
@@ -67,6 +99,21 @@ export default function Forms() {
67
99
  }, [])
68
100
  useEffect(load, [load])
69
101
 
102
+ // Once. A name per row would be one request per form, and these change far
103
+ // more slowly than the page is opened.
104
+ useEffect(() => {
105
+ let cancelled = false
106
+ getUsers()
107
+ .then((rows) => {
108
+ if (cancelled) return
109
+ const by = {}
110
+ ;(rows || []).forEach((u) => { by[u.id] = u.name || u.email || u.id })
111
+ setPeople(by)
112
+ })
113
+ .catch(() => {})
114
+ return () => { cancelled = true }
115
+ }, [])
116
+
70
117
  const onLabel = (value) => {
71
118
  setLabel(value)
72
119
  if (!keyEdited) setKey(suggestKey(value))
@@ -79,28 +126,63 @@ export default function Forms() {
79
126
  const made = await factory.createEntity({ key: key.trim(), label: label.trim() })
80
127
  navigate('/configure/forms/' + made.key + '/design/form')
81
128
  } catch (err) {
82
- setError(err.message)
129
+ raiseSnackbar(err.message, { design: 'error' })
83
130
  setBusy(null)
84
131
  }
85
132
  }
86
133
 
87
134
  // The form first — publishing it creates or changes the table — then the list.
135
+ /**
136
+ * Delete a form. Asked first, in the words of what will actually happen
137
+ * rather than "are you sure": the design goes, and the records do not
138
+ * unless there are none.
139
+ */
140
+ const remove = async (form) => {
141
+ const ok = await raiseConfirm(
142
+ 'Its design goes. Its table and any records in it are kept unless the table is empty.',
143
+ {
144
+ title: 'Delete "' + (form.name || form.key) + '"?',
145
+ confirmLabel: 'Delete',
146
+ // Red, because it cannot be undone from here.
147
+ danger: true
148
+ }
149
+ )
150
+ if (!ok) return
151
+ setBusy(form.key)
152
+ try {
153
+ const out = await deleteForm(factory, form)
154
+ // Said plainly: somebody deleting a form expects all of it to go, and a
155
+ // table left behind is worth knowing about rather than discovering.
156
+ if (out && out.records > 0) {
157
+ raiseSnackbar('"' + (form.name || form.key) + '" is gone. Its table "' + out.table + '" was kept — it still holds ' +
158
+ out.records + ' record' + (out.records === 1 ? '' : 's') + '.', { design: 'info' })
159
+ } else if (out && out.tableDropped) {
160
+ raiseSnackbar('"' + (form.name || form.key) + '" and its empty table are gone.', { design: 'success' })
161
+ } else {
162
+ raiseSnackbar('"' + (form.name || form.key) + '" is gone.', { design: 'success' })
163
+ }
164
+ load()
165
+ } catch (err) {
166
+ raiseSnackbar(err.message, { design: 'error' })
167
+ } finally {
168
+ setBusy(null)
169
+ }
170
+ }
171
+
88
172
  const publish = async (form) => {
89
173
  setBusy(form.key)
90
- setNotice(null)
91
174
  try {
92
175
  for (const screen of [form.edit, form.list]) {
93
176
  if (!screen || (screen.version !== null && !screen.hasDraft)) continue
94
177
  const row = await factory.loadDraft(screen.screenKey)
95
178
  await factory.publish(row.document)
96
179
  }
97
- setNotice('Published "' + form.name + '"')
98
- setError(null)
180
+ raiseSnackbar('Published "' + form.name + '"', { design: 'success' })
99
181
  load()
100
182
  } catch (err) {
101
- setError(err.confirm
183
+ raiseSnackbar(err.confirm
102
184
  ? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
103
- : err.message)
185
+ : err.message, { design: 'error' })
104
186
  } finally {
105
187
  setBusy(null)
106
188
  }
@@ -112,10 +194,10 @@ export default function Forms() {
112
194
  if (inMenu.has(form.key)) await removeMenuItem(FORM_MENU_PREFIX + form.key)
113
195
  else await addMenuItem({ name: FORM_MENU_PREFIX + form.key, label: form.name })
114
196
  await refreshAccess()
115
- setNotice(inMenu.has(form.key) ? 'Removed "' + form.name + '" from the menu' : 'Added "' + form.name + '" to the menu — rename or move it in the Menu tab')
197
+ raiseSnackbar(inMenu.has(form.key) ? 'Removed "' + form.name + '" from the menu' : 'Added "' + form.name + '" to the menu — rename or move it in the Menu tab', { design: 'success' })
116
198
  load()
117
199
  } catch (err) {
118
- setError(err.message)
200
+ raiseSnackbar(err.message, { design: 'error' })
119
201
  } finally {
120
202
  setBusy(null)
121
203
  }
@@ -141,8 +223,6 @@ export default function Forms() {
141
223
  </button>
142
224
  </form>
143
225
 
144
- {error && <div className="app-error">{error}</div>}
145
- {notice && <div className="app-notice">{notice}</div>}
146
226
 
147
227
  {forms && forms.length === 0 && <p className="app-sub">No forms yet.</p>}
148
228
  {forms && forms.length > 0 && (
@@ -157,7 +237,19 @@ export default function Forms() {
157
237
  const canOpen = form.list && form.list.version !== null
158
238
  return (
159
239
  <tr key={form.key}>
160
- <td><strong>{form.name || form.key}</strong><div className="app-sub">{form.key}</div></td>
240
+ <td>
241
+ <strong>{form.name || form.key}</strong>
242
+ <div className="app-sub">{form.key}</div>
243
+ {/* WHAT IT IS FOR, and whose it is — the two things that
244
+ tell three forms of the same name apart. The date
245
+ stands alone when the names cannot be fetched. */}
246
+ {form.description && <div className="app-sub">{form.description}</div>}
247
+ {(form.createdBy || form.createdDate) && (
248
+ <div className="app-sub">
249
+ {[people[form.createdBy], madeOn(form.createdDate)].filter(Boolean).join(' · ')}
250
+ </div>
251
+ )}
252
+ </td>
161
253
  <td><code>{form.source || '—'}</code></td>
162
254
  <td className={edit.unpublished ? 'app-forms-pending' : undefined}>{edit.label}</td>
163
255
  <td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
@@ -173,6 +265,17 @@ export default function Forms() {
173
265
  {busy === form.key ? 'Publishing…' : 'Publish'}
174
266
  </button>
175
267
  )}
268
+ {/* The design always goes; the TABLE only when it is empty
269
+ and nothing else describes it. Records are never lost
270
+ to a click — @xeplr/factory refuses to drop a table
271
+ with rows in it and says how many kept it. */}
272
+ <button
273
+ className="app-btn app-btn-quiet app-btn-danger"
274
+ disabled={busy === form.key}
275
+ onClick={() => remove(form)}
276
+ >
277
+ Delete
278
+ </button>
176
279
  </td>
177
280
  </tr>
178
281
  )
@@ -0,0 +1,114 @@
1
+ // CONFIGURE UI → MASTERS — the lists the rest of the app points at.
2
+ //
3
+ // Departments, statuses, countries, categories: small tables that other forms
4
+ // choose from. They are ordinary forms with a flag (Design → This is a
5
+ // master), so they have a table, a list, an edit screen and permissions like
6
+ // anything else — a master is a fact about a form, not a kind of form.
7
+ //
8
+ // They are gathered HERE rather than scattered through the side rail because
9
+ // that is what they are: reference data somebody maintains occasionally, not
10
+ // pages people work in daily. The rail stays for the work.
11
+ //
12
+ // Which forms are masters is @xeplr/ui-factory's answer (`useScreenSource`),
13
+ // not this app's — grouping two screens into a form and reading the flag is
14
+ // the same code in every app, and a copy here could never be fixed centrally.
15
+ import { useState } from 'react'
16
+ import { Link } from 'react-router-dom'
17
+ import { useScreenSource, createMaster, possibleParents, FactoryBuilder } from '@xeplr/ui-factory'
18
+ import { factory } from '../api/factory.js'
19
+
20
+ export default function Masters() {
21
+ const { masters, loading, reload } = useScreenSource(factory, { Builder: FactoryBuilder })
22
+ const [name, setName] = useState('')
23
+ const [parent, setParent] = useState('')
24
+ const [busy, setBusy] = useState(false)
25
+ const [error, setError] = useState(null)
26
+
27
+ // MADE, NOT DESIGNED. A master is a name, an order and what it belongs to —
28
+ // the same three things every time — so it is created outright and the
29
+ // builder is for the day one needs a fourth field.
30
+ const create = async (e) => {
31
+ e.preventDefault()
32
+ if (!name.trim() || busy) return
33
+ setBusy(true)
34
+ try {
35
+ const owner = masters.find((m) => m.key === parent)
36
+ await createMaster(factory, {
37
+ name: name.trim(),
38
+ parent: owner ? { table: owner.source, label: owner.name } : null
39
+ })
40
+ setName('')
41
+ setParent('')
42
+ setError(null)
43
+ reload()
44
+ } catch (err) {
45
+ setError(err.message)
46
+ } finally {
47
+ setBusy(false)
48
+ }
49
+ }
50
+
51
+ if (loading) return <p className="app-sub">Loading…</p>
52
+
53
+ return (
54
+ <div className="app-forms">
55
+ <p className="app-page-lead">
56
+ The lists the rest of the app chooses from. A master is a name, an order of your own,
57
+ and — when it belongs to one — the master above it.
58
+ </p>
59
+
60
+ {error && <div className="app-error">{error}</div>}
61
+
62
+ <form className="app-forms-new" onSubmit={create}>
63
+ <label className="app-forms-field">
64
+ <span>New master</span>
65
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Departments" />
66
+ </label>
67
+ {/* Only worth asking once there is something to belong to. */}
68
+ {masters.length > 0 && (
69
+ <label className="app-forms-field">
70
+ <span>Belongs to</span>
71
+ <select value={parent} onChange={(e) => setParent(e.target.value)}>
72
+ <option value="">— nothing —</option>
73
+ {possibleParents(masters).map((m) => (
74
+ <option key={m.key} value={m.key}>{m.name}</option>
75
+ ))}
76
+ </select>
77
+ </label>
78
+ )}
79
+ <button type="submit" className="app-btn" disabled={!name.trim() || busy}>
80
+ {busy ? 'Making it…' : 'Make the master'}
81
+ </button>
82
+ </form>
83
+
84
+ {masters.length === 0 && (
85
+ <p className="app-sub">
86
+ No masters yet. Departments, statuses, countries — anything other forms point at.
87
+ </p>
88
+ )}
89
+
90
+ {masters.length > 0 && (
91
+ <table className="app-table">
92
+ <thead><tr><th>Master</th><th>Shown as</th><th>Table</th><th /></tr></thead>
93
+ <tbody>
94
+ {masters.map((m) => (
95
+ <tr key={m.key}>
96
+ <td><strong>{m.name}</strong></td>
97
+ {/* What a picker shows for one of its records — said once on
98
+ the master, not again on every dropdown pointing at it. */}
99
+ <td className="app-sub">{m.labelField || '—'}</td>
100
+ <td className="app-sub">{m.source || '—'}</td>
101
+ <td className="app-row-actions">
102
+ {/* Its records: the list screen is what looking at a master
103
+ means. Its design is where the flag was set. */}
104
+ <Link to={`/forms/${m.key}`}>Open</Link>
105
+ <Link to={`/configure/forms/${m.key}/design/form`}>Design</Link>
106
+ </td>
107
+ </tr>
108
+ ))}
109
+ </tbody>
110
+ </table>
111
+ )}
112
+ </div>
113
+ )
114
+ }