@xeplr/cli 0.1.9 → 0.1.11

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
@@ -26,9 +26,11 @@ Already working, with nothing to wire up:
26
26
  open is named explicitly; there is no way to leave the whole thing public by
27
27
  forgetting.
28
28
  - **A sample built with the screen designer** — a task list whose New and Edit
29
- open the task form in a popup, saving as you type into an ordinary `tasks`
30
- table. Neither is written by hand: both are screens
31
- ([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
29
+ open the task form on a page of its own, filled in over three steps and saving
30
+ as you type into an ordinary `tasks` table. It uses every control there is:
31
+ text, dropdown, radio buttons, tick box, date, date and time, a multi-select
32
+ and a file upload. Neither the list nor the form is written by hand: both are
33
+ screens ([`@xeplr/ui-factory`](https://www.npmjs.com/package/@xeplr/ui-factory)),
32
34
  published when the API first starts.
33
35
  - **Configure UI** for Super Admin, in the settings menu (top right) — not in
34
36
  the side rail, which is for the pages people use:
@@ -39,6 +41,9 @@ Already working, with nothing to wire up:
39
41
  - **Menu**: rename, reorder and hide the side rail's items.
40
42
 
41
43
  Nobody else sees it, and the API refuses everyone else.
44
+ - **Flows, if you run Xeplr Workflow** — your forms one after another, where
45
+ what someone fills in decides the next screen. Designed in Configure UI →
46
+ Flows, walked at `/journey/<key>`, and resumable, since Workflow keeps the run.
42
47
  - **Multi-tenancy, if you ask for it** — companies (or companies and
43
48
  workspaces, up to four levels). Every row is stamped with the one it was made
44
49
  in, every read sees only its rows, membership is checked on every request,
@@ -53,8 +58,10 @@ Already working, with nothing to wire up:
53
58
 
54
59
  > Create a new UI for farming departments. One record is a farming department
55
60
  > with: Name (required), Region (dropdown: North, South, East, West), Area in
56
- > acres (number, at least 0), Started on (date), Notes (long text). Show Name,
57
- > Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
61
+ > acres (number, at least 0), Started on (date), Certifications (choose several:
62
+ > Organic, Fair trade, Rainforest), Inspection at (date and time), Licence
63
+ > (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
64
+ > list. Follow "Create a new UI" in CLAUDE.md.
58
65
 
59
66
  ## Rules the generated app follows
60
67
 
@@ -129,8 +136,10 @@ Two ways, both in the running app's project:
129
136
 
130
137
  > Create a new UI for farming departments. One record is a farming department
131
138
  > with: Name (required), Region (dropdown: North, South, East, West), Area in
132
- > acres (number, at least 0), Started on (date), Notes (long text). Show Name,
133
- > Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
139
+ > acres (number, at least 0), Started on (date), Certifications (choose several:
140
+ > Organic, Fair trade, Rainforest), Inspection at (date and time), Licence
141
+ > (file: .pdf or .jpg), Notes (long text). Show Name, Region and Area in the
142
+ > list. Follow "Create a new UI" in CLAUDE.md.
134
143
 
135
144
  Claude follows `CLAUDE.md`: the screens and server hooks and model
136
145
  (`npx xeplr-factory screens … --no-pages`), the page with its front-end hooks,
@@ -189,10 +198,17 @@ no defaults, ever.
189
198
  | Super admin email | the account you sign in with |
190
199
  | Super admin password | its password |
191
200
  | Multi-tenant? | no — or the levels, outermost first: `company`, `company, workspace` |
201
+ | Flows — is Xeplr Workflow running? | no — or its address (`http://localhost:19122`) and its backend folder. Yes adds **Configure UI → Flows**, a `/journey/<key>` page, and `/api/flows` forwarded to Workflow — and sets multi-tenancy to Workflow's own levels, company then workspace, since it keeps every journey inside both |
192
202
  | Database connection | host, port, user, password — or skip, and fill it in later |
193
203
 
194
204
  Encryption keys and signing secrets are **generated, never asked**.
195
205
 
206
+ **Flows need Workflow to share the app's sign-in.** Workflow is a separate
207
+ service, not a package the project installs: set its `AUTH_URL` and
208
+ `AUTH_DB_NAME` to the new app's, and the app's sign-in service loads Workflow's
209
+ access rules from the folder you gave. The generated README says exactly which
210
+ values.
211
+
196
212
  ## Licence
197
213
 
198
214
  MIT
@@ -8,6 +8,10 @@ var TEMPLATES = path.join(__dirname, '..', '..', 'templates');
8
8
  // with the same path as one there replaces it.
9
9
  var TENANCY_TEMPLATES = path.join(__dirname, '..', '..', 'templates-tenancy');
10
10
 
11
+ // Files that exist ONLY in an app connected to Xeplr Workflow — the route that
12
+ // forwards flows to it, the designer, and the page a journey runs on.
13
+ var FLOWS_TEMPLATES = path.join(__dirname, '..', '..', 'templates-flows');
14
+
11
15
  // Files whose names cannot ship as-is. `.env` and `.gitignore` are both
12
16
  // swallowed on the way here — npm refuses to pack a .gitignore, and an .env in
13
17
  // a published package is a mistake waiting to happen — so they travel under a
@@ -34,7 +38,51 @@ function substitutions(answers) {
34
38
  __ADMIN_PASSWORD__: answers.admin.password,
35
39
  __DB_CONNECTION__: answers.connection || '',
36
40
  __CONNECTION_NOTE__: connectionNote(answers)
37
- }, tenancySubstitutions(answers.tenancy || []));
41
+ }, tenancySubstitutions(answers.tenancy || []), flowsSubstitutions(answers.workflow || null, answers));
42
+ }
43
+
44
+ /**
45
+ * The lines an app connected to Xeplr Workflow has, and one without it does
46
+ * not. Like tenancy, every token exists either way and expands to nothing when
47
+ * flows are off, so no template needs a condition of its own.
48
+ */
49
+ function flowsSubstitutions(workflow, answers) {
50
+ var on = Boolean(workflow);
51
+ return {
52
+ __WF_ENV__: on ? [
53
+ '# ── Flows (Xeplr Workflow) ──',
54
+ '# Your forms, one after another — Workflow runs the journey. It is a separate',
55
+ '# service: start it, and point it at THIS app\'s sign-in (see README, "Flows").',
56
+ 'WORKFLOW_URL=' + workflow.url,
57
+ ''
58
+ ].join('\n') : '',
59
+ // Workflow's access rules — who may design a flow, who may run one — are
60
+ // loaded by this app's sign-in service, after the app's own.
61
+ __WF_AUTH_MIGRATIONS__: on && workflow.dir ? ',' + workflow.dir + '/migrations-auth' : '',
62
+ __WF_REQUIRED_ENV__: on ? " // Where Xeplr Workflow is — flows are forwarded to it.\n 'WORKFLOW_URL'," : '',
63
+ __WF_API_ROUTE__: on ? [
64
+ '// Flows go to Xeplr Workflow (WORKFLOW_URL), behind this app\'s own gate.',
65
+ "router.use('/api/flows', require('./flows'));"
66
+ ].join('\n') : '',
67
+ __WF_UI_IMPORTS__: on ? [
68
+ "import FlowDesigner from './pages/FlowDesigner.jsx'",
69
+ "import Journey from './pages/Journey.jsx'"
70
+ ].join('\n') : '',
71
+ __WF_UI_ROUTES__: on ? [
72
+ ' <Route path="/configure/flows/:flow" element={<FlowDesigner />} />',
73
+ ' {/* A journey: its run id joins the address once it starts, so it can be resumed. */}',
74
+ ' <Route path="/journey/:flow" element={<Journey />} />',
75
+ ' <Route path="/journey/:flow/:run" element={<Journey />} />'
76
+ ].join('\n') : '',
77
+ __WF_TAB_IMPORT__: on ? "import Flows from './Flows.jsx'" : '',
78
+ __WF_TAB__: on ? " { id: 'flows', label: 'Flows', Page: Flows }," : '',
79
+ __WF_TAB_NOTE__: on ? '// Flows forms one after another — what is filled in decides the next; run by Xeplr Workflow' : '',
80
+ __WF_README__: on ? flowsReadme(workflow, answers) : '',
81
+ __WF_CLAUDE__: on ? [
82
+ '**Flows** — forms one after another, run by Xeplr Workflow (`WORKFLOW_URL`). Design them in Configure UI → Flows, never in code: a flow is data in Workflow, reached through `/api/flows`, and walked at `/journey/<key>`. An arrow tests one field of the screen it leaves; a form that is a step must be published first.',
83
+ ''
84
+ ].join('\n') : ''
85
+ };
38
86
  }
39
87
 
40
88
  /**
@@ -118,6 +166,37 @@ function tenancySubstitutions(levels) {
118
166
  };
119
167
  }
120
168
 
169
+ /**
170
+ * The README's Flows section — what a flow is, and the one thing that has to
171
+ * be true for it to work: Workflow and this app share a sign-in.
172
+ */
173
+ function flowsReadme(workflow, answers) {
174
+ return [
175
+ '## Flows',
176
+ '',
177
+ 'Your forms, one after another: what someone fills in on one decides which comes next. Design one in **Configure UI → Flows**, publish it, and open it at `/journey/<key>`. The run id joins the address once it starts, so a journey can be reloaded, left, or handed to someone else and opened on the step it is on.',
178
+ '',
179
+ 'Flows are run by **Xeplr Workflow**, a separate service at `WORKFLOW_URL` (' + workflow.url + '). The API forwards `/api/flows/…` to it unchanged; each screen still saves into its own table.',
180
+ '',
181
+ 'Workflow keeps every journey inside a **company and a workspace** — the two levels it registers — which is why this app has exactly those: a flow is made, and a run started, in the company and workspace picked after signing in.',
182
+ '',
183
+ '**Workflow must use this app\'s sign-in.** In Workflow\'s own `development.env`:',
184
+ '',
185
+ '```sh',
186
+ 'AUTH_URL=http://localhost:' + answers.ports.auth + ' # this app\'s sign-in service',
187
+ 'AUTH_DB_NAME=' + answers.name + '_auth # and its database',
188
+ 'REDIS_PREFIX=' + answers.name + ':',
189
+ '```',
190
+ '',
191
+ 'If Workflow\'s database server is not this app\'s, also set `AUTH_DB_CONNECTION_INFO_ENCRYPTED` in Workflow to this app\'s server, encrypted with **Workflow\'s** `ENCRYPTION_KEY` (`npm run db:encrypt` in Workflow\'s backend). Otherwise every flow call fails with *database "' + answers.name + '_auth" does not exist*.',
192
+ '',
193
+ workflow.dir
194
+ ? 'Its access rules — who may design a flow, who may run one — are loaded by this app\'s sign-in service from `' + workflow.dir + '/migrations-auth` (in `XEPLR_AUTH_MIGRATIONS`). Restart the sign-in service after changing it.'
195
+ : 'Its access rules — who may design a flow, who may run one — live in Workflow\'s `backend/migrations-auth`. Add that folder to `XEPLR_AUTH_MIGRATIONS` in `api/development.env` (comma separated, after `./migrations-auth`) and restart the sign-in service; until then, flows answer 403.',
196
+ ''
197
+ ].join('\n');
198
+ }
199
+
121
200
  /** One level's own table: what the picker lists. Its parents' ids in mtIdN. */
122
201
  function tenantTableSql(level, i) {
123
202
  var parents = i === 0 ? 'none — the outermost level' : i === 1 ? 'mtId1 holds the one above' : 'mtId1–mtId' + i + ' hold the ones above';
@@ -230,6 +309,15 @@ function generate(answers, target) {
230
309
  throw new Error(target + ' already exists and is not empty.');
231
310
  }
232
311
 
312
+ // Workflow files every flow and run under a company; an app without
313
+ // companies would build, start, and then refuse every flow it tried to make.
314
+ if (answers.workflow) {
315
+ var q = require('../questions');
316
+ if (!q.sameLevels(answers.tenancy || [], q.tenancyLevels(q.WORKFLOW_LEVELS))) {
317
+ throw new Error('Flows need the same multi-tenancy as Xeplr Workflow — levels "' + q.WORKFLOW_LEVELS + '" — because Workflow keeps every journey inside a company and a workspace.');
318
+ }
319
+ }
320
+
233
321
  var subs = substitutions(answers);
234
322
  var written = [];
235
323
 
@@ -238,6 +326,9 @@ function generate(answers, target) {
238
326
  if (answers.tenancy && answers.tenancy.length) {
239
327
  walk(TENANCY_TEMPLATES).forEach(function (src) { sources[path.relative(TENANCY_TEMPLATES, src)] = src; });
240
328
  }
329
+ if (answers.workflow) {
330
+ walk(FLOWS_TEMPLATES).forEach(function (src) { sources[path.relative(FLOWS_TEMPLATES, src)] = src; });
331
+ }
241
332
 
242
333
  Object.keys(sources).forEach(function (rel) {
243
334
  var src = sources[rel];
package/lib/prompt.js CHANGED
@@ -124,7 +124,10 @@ function createSession(options) {
124
124
  return /^y/i.test(v);
125
125
  }
126
126
 
127
- return { ask: ask, askSecret: askSecret, confirm: confirm, close: function () { rl.close(); } };
127
+ /** A line of explanation between questions — no answer expected. */
128
+ function say(text) { output.write(text + '\n'); }
129
+
130
+ return { ask: ask, askSecret: askSecret, confirm: confirm, say: say, close: function () { rl.close(); } };
128
131
  }
129
132
 
130
133
  module.exports = { createSession: createSession };
package/lib/questions.js CHANGED
@@ -134,6 +134,40 @@ async function collect(session, argvName) {
134
134
  tenancy = tenancyLevels(levelsText);
135
135
  }
136
136
 
137
+ // ── flows ─────────────────────────────────────────────────────────────
138
+ //
139
+ // Screens one after another, where what someone filled in decides the next
140
+ // screen. The journey is run by Xeplr Workflow — a separate service, not a
141
+ // package — so this only asks where it is. Off, the app has no flows at all.
142
+ var workflow = null;
143
+ var wantsFlows = await session.confirm('Flows — is Xeplr Workflow running for this app?', false);
144
+ if (wantsFlows) {
145
+ var workflowUrl = await session.ask(' Workflow API address?', {
146
+ default: 'http://localhost:19122',
147
+ validate: function (v) { return /^https?:\/\/[^\s]+$/.test(v) ? true : 'An http:// or https:// address.'; }
148
+ });
149
+ // Workflow keeps its access rules (who may design a flow, who may run one)
150
+ // in migrations-auth/. This app's sign-in service has to load them, so it
151
+ // needs to know where they are. Blank is allowed: the README says how.
152
+ var workflowDir = await session.ask(' Workflow backend folder (for its access rules — blank to add later)?', {
153
+ default: '',
154
+ validate: function (v) { return !v || !/\s/.test(v) ? true : 'A path without spaces.'; }
155
+ });
156
+ workflow = { url: workflowUrl.replace(/\/+$/, ''), dir: workflowDir.replace(/\/+$/, '') };
157
+
158
+ // Workflow files every flow and every run under a company and a workspace
159
+ // — the two levels it registers, with those headers. An app that sends
160
+ // anything else builds, starts, and then has every flow refused, so the
161
+ // levels that make flows work are set here, and said out loud.
162
+ if (!sameLevels(tenancy, tenancyLevels(WORKFLOW_LEVELS))) {
163
+ tenancy = tenancyLevels(WORKFLOW_LEVELS);
164
+ if (session.say) {
165
+ session.say(' Flows need the same tenancy as Xeplr Workflow — company, then workspace —');
166
+ session.say(' so this app uses those two levels.');
167
+ }
168
+ }
169
+ }
170
+
137
171
  // ── the database ──────────────────────────────────────────────────────
138
172
  //
139
173
  // ASKED, not defaulted, and not left for the reader to do by hand either.
@@ -174,6 +208,7 @@ async function collect(session, argvName) {
174
208
  databases: { auth: name + '_auth', api: name + '_api' },
175
209
  admin: { email: adminEmail, password: adminPassword },
176
210
  tenancy: tenancy,
211
+ workflow: workflow,
177
212
 
178
213
  // GENERATED, never asked. Nobody should be inventing these by hand, and a
179
214
  // prompt invites a weak answer or a reused one.
@@ -181,4 +216,12 @@ async function collect(session, argvName) {
181
216
  };
182
217
  }
183
218
 
184
- module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, NAME_RE: NAME_RE };
219
+ /** The tenancy Xeplr Workflow registers: a flows app must send the same headers. */
220
+ var WORKFLOW_LEVELS = 'company, workspace';
221
+
222
+ /** Same levels, in the same order, with the same headers. */
223
+ function sameLevels(a, b) {
224
+ return a.length === b.length && a.every(function (l, i) { return l.header === b[i].header; });
225
+ }
226
+
227
+ module.exports = { collect: collect, envPrefix: envPrefix, tenancyLevels: tenancyLevels, sameLevels: sameLevels, WORKFLOW_LEVELS: WORKFLOW_LEVELS, NAME_RE: NAME_RE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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>",
@@ -45,10 +45,11 @@ Then change only what the request asks for:
45
45
 
46
46
  - **`multiselect`** — several ids in ONE text column, comma separated, and an array in your code. An option's id may not contain a comma.
47
47
  - **`file`** — the column holds the **path**, never the file. Say which extensions (`"accept": ".pdf,.docx"`) and how large (`"maxSize": 10`, in MB); the API enforces both, by extension, and keeps the file under `FACTORY_FILES_DIR`. Uploading needs `multer` in `api/` (already in package.json).
48
- - **`stepper`** — a journey across the top. Every other field says which step it is on (`"step": "Planning"`), and one without a `step` shows on every step. `step(ctx)` in the front-end hooks runs before each move.
48
+ - **`stepper`** — a journey across the top. Every other field says which step it is on (`"step": "Planning"`), and one without a `step` shows on every step. `step(ctx)` in the front-end hooks runs before each move: return `false` to stay, a step's key to go there, `ctx.disable(['planning'])` to rule steps out (struck through on the bar, passed over by Next and Back) and `ctx.enable()` to bring them back.
49
49
 
50
50
  **A list opens its form on a page or in a popup** — `"openIn": "page"` in the entity spec, or Configure UI → Forms → the list. A page needs `onOpenRecord` on the list's page (where to navigate) and `onDone` on the form's page (where Done goes back to): `Tasks.jsx` and `EditTask.jsx` show both, with routes `/tasks/new` and `/tasks/:id` in `App.jsx`.
51
51
 
52
+ __WF_CLAUDE__
52
53
  **Keys and labels.** A form's key names its screens and table and never changes once published; its label is the screens' name. A menu item's key (`menus.name`) is what `drawerItems` matches; its label is what the rail shows, renamed in Configure UI → Menu. Code only ever uses keys.
53
54
 
54
55
  ### The prompt
@@ -34,6 +34,7 @@ Open http://localhost:__UI_PORT__ and sign in as `__ADMIN_EMAIL__`.
34
34
 
35
35
  > Create a new UI for farming departments. One record is a farming department with: Name (required), Region (dropdown: North, South, East, West), Area in acres (number, at least 0), Started on (date), Notes (long text). Show Name, Region and Area in the list. Follow "Create a new UI" in CLAUDE.md.
36
36
 
37
+ __WF_README__
37
38
  ## Rules this app follows
38
39
 
39
40
  | Rule | Why |
@@ -42,7 +42,7 @@ AUTH_URL=http://localhost:__AUTH_PORT__
42
42
 
43
43
  # Sign-in setup files: the screens' permissions (@xeplr/factory's), then this
44
44
  # app's own — menu entries and anything else it adds. Comma-separated, in order.
45
- XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth
45
+ XEPLR_AUTH_MIGRATIONS=./node_modules/@xeplr/factory/migrations-auth,./migrations-auth__WF_AUTH_MIGRATIONS__
46
46
 
47
47
  # ── The first account you sign in with ──
48
48
  # Created for you when you set up the database. Change the password here and
@@ -57,6 +57,7 @@ AUTH_SUPER_ADMIN_PASSWORD=__ADMIN_PASSWORD__
57
57
  AUTH_ACTIVATION_URL=http://localhost:__UI_PORT__/auth/activate
58
58
  AUTH_INVITE_URL=http://localhost:__UI_PORT__/auth/accept-invite
59
59
 
60
+ __WF_ENV__
60
61
  # ── Uploaded files ──
61
62
  # Where a form's FILE field puts what people upload. The path is what the
62
63
  # record stores; the file itself is served only through the API's own route,
@@ -25,5 +25,6 @@ module.exports = [
25
25
  // clean, migrates, serves traffic and reads as EMPTY DATA rather than as an
26
26
  // error — the most expensive way for this to go wrong.
27
27
  'DB_API',
28
- '__PREFIX___CONNECTION'
28
+ '__PREFIX___CONNECTION',
29
+ __WF_REQUIRED_ENV__
29
30
  ];
@@ -21,7 +21,7 @@
21
21
  "@xeplr/base-apis": "^2.0.0",
22
22
  "@xeplr/db": "^1.0.6",
23
23
  "@xeplr/factory": "^1.0.6",
24
- "@xeplr/ui-factory": "^1.0.11",
24
+ "@xeplr/ui-factory": "^1.0.14",
25
25
  "@xeplr/utils": "^1.0.11",
26
26
  "dotenv": "^17.4.2",
27
27
  "express": "^5.2.0",
@@ -34,4 +34,6 @@ 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
+ __WF_API_ROUTE__
38
+
37
39
  module.exports = router;
@@ -12,7 +12,7 @@
12
12
  "@tanstack/react-table": "^8.21.3",
13
13
  "@xeplr/ui-account": "^1.0.9",
14
14
  "@xeplr/ui-canvas": "^1.0.2",
15
- "@xeplr/ui-factory": "^1.0.11",
15
+ "@xeplr/ui-factory": "^1.0.14",
16
16
  "@xeplr/ui-table": "^1.0.6",
17
17
  "react": "^19.2.4",
18
18
  "react-dom": "^19.2.4",
@@ -4,6 +4,7 @@ import { authRoutes, authPath, NavPage, ProtectedRoute, useAccess } from '@xeplr
4
4
  import Home from './pages/Home.jsx'
5
5
  import Tasks from './pages/Tasks.jsx'
6
6
  import EditTask from './pages/EditTask.jsx'
7
+ __WF_UI_IMPORTS__
7
8
  import ConfigureUI from './pages/ConfigureUI.jsx'
8
9
  import { FORM_MENU_PREFIX } from './menu.js'
9
10
  import FormDesigner from './pages/FormDesigner.jsx'
@@ -112,6 +113,7 @@ __MT_SELECT_ROUTE__
112
113
  <Route path="/forms/:form" element={<FormRecords />} />
113
114
  <Route path="/configure" element={<ConfigureUI />} />
114
115
  <Route path="/configure/forms/:form/design/:part" element={<FormDesigner />} />
116
+ __WF_UI_ROUTES__
115
117
  </Route>
116
118
 
117
119
  <Route path="*" element={<Navigate to="/auth/login" replace />} />
@@ -3,15 +3,18 @@
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
5
  // Menu the side rail's items: rename, reorder, hide
6
+ __WF_TAB_NOTE__
6
7
  //
7
8
  // Configuration lives here, not in the side rail — the rail is for the pages
8
9
  // people use.
9
10
  import { useSearchParams } from 'react-router-dom'
10
11
  import Forms from './Forms.jsx'
11
12
  import MenuSettings from './MenuSettings.jsx'
13
+ __WF_TAB_IMPORT__
12
14
 
13
15
  const TABS = [
14
16
  { id: 'forms', label: 'Forms', Page: Forms },
17
+ __WF_TAB__
15
18
  { id: 'menu', label: 'Menu', Page: MenuSettings }
16
19
  ]
17
20
 
@@ -38,8 +38,18 @@ export class TaskHooks extends FactoryHooks {
38
38
 
39
39
  /**
40
40
  * Before a stepper moves — Next, Back, or a click on the bar.
41
- * Return false to stay where you are, or a step number to go there instead.
42
- * The step's own required fields are checked before this runs.
41
+ *
42
+ * Return false to stay where you are, or a step (its key or its number) to
43
+ * go there instead. ctx.disable(['planning']) rules steps out — they stay on
44
+ * the bar, struck through, and Next and Back pass over them; ctx.enable()
45
+ * brings them all back. The step's own required fields are checked before
46
+ * this runs, so this is for your rules, not theirs. For example:
47
+ *
48
+ * if (ctx.from === 0 && ctx.values.priority === 'low') {
49
+ * ctx.disable(['planning'])
50
+ * return 'attachment'
51
+ * }
52
+ * ctx.enable()
43
53
  */
44
54
  step(ctx) {
45
55
  return super.step(ctx)