@xeplr/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +37 -13
  2. package/bin/xeplr.js +10 -4
  3. package/lib/generate/index.js +121 -3
  4. package/lib/nextSteps.js +33 -18
  5. package/lib/plan.js +8 -0
  6. package/lib/questions.js +60 -1
  7. package/lib/setup.js +59 -0
  8. package/package.json +2 -1
  9. package/templates/CLAUDE.md +57 -0
  10. package/templates/api/app.js +31 -1
  11. package/templates/api/bin/www +19 -4
  12. package/templates/api/db/setup.js +1 -0
  13. package/templates/api/development.env.tpl +8 -2
  14. package/templates/api/env.required.js +1 -1
  15. package/templates/api/migrations/README.md +9 -0
  16. package/templates/api/migrations-auth/0003_forms_menu.sql +19 -0
  17. package/templates/api/models/index.js +7 -6
  18. package/templates/api/package.json +5 -2
  19. package/templates/api/routes/access.js +25 -0
  20. package/templates/api/routes/index.js +6 -15
  21. package/templates/api/screens/index.js +39 -0
  22. package/templates/api/screens/task/task-edit.screen.json +103 -0
  23. package/templates/api/screens/task/task-list.screen.json +45 -0
  24. package/templates/api/screens/task/task.entity.json +10 -0
  25. package/templates/api/screens/task/task.hooks.js +37 -0
  26. package/templates/api/screens/task/task.model.js +29 -0
  27. package/templates/ui/package.json +4 -0
  28. package/templates/ui/src/App.jsx +22 -2
  29. package/templates/ui/src/api/factory.js +10 -0
  30. package/templates/ui/src/index.css +60 -0
  31. package/templates/ui/src/main.jsx +2 -0
  32. package/templates/ui/src/pages/EditTask.jsx +57 -0
  33. package/templates/ui/src/pages/FormDesigner.jsx +74 -0
  34. package/templates/ui/src/pages/FormRecords.jsx +24 -0
  35. package/templates/ui/src/pages/Forms.jsx +144 -0
  36. package/templates/ui/src/pages/Home.jsx +4 -0
  37. package/templates/ui/src/pages/Tasks.jsx +20 -94
  38. package/templates/ui/src/pages/TasksHelp.jsx +66 -50
  39. package/templates/ui/vite.config.js +1 -1
  40. package/templates-tenancy/api/migrations/0001_tenants.sql +8 -0
  41. package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +10 -0
  42. package/templates-tenancy/api/routes/tenants.js +126 -0
  43. package/templates-tenancy/api/tenancy.js +12 -0
  44. package/templates-tenancy/ui/src/pages/SelectScope.css +45 -0
  45. package/templates-tenancy/ui/src/pages/SelectScope.jsx +154 -0
  46. package/templates-tenancy/ui/src/tenancy.js +3 -0
  47. package/templates/api/migrations/0001_tasks.sql +0 -23
  48. package/templates/api/models/Task.js +0 -48
  49. package/templates/ui/src/api/tasks.js +0 -34
  50. package/templates/ui/src/useTasksController.js +0 -83
@@ -0,0 +1,57 @@
1
+ // THE TASK FORM — and what the task screens do in the browser.
2
+ //
3
+ // The form itself is the "task_edit" screen, designed on the Forms page. This
4
+ // module holds its HOOKS, used by this page, by the Tasks list and by the
5
+ // popup the list opens.
6
+ import { useEffect, useState } from 'react'
7
+ import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
8
+ import { factory } from '../api/factory.js'
9
+
10
+ // ── WHAT THE TASK SCREENS DO IN THE BROWSER ──────────────────────────────
11
+ // Every method runs the default (super). Change the ones you need; leave the
12
+ // rest as they are.
13
+ //
14
+ // before change the input, then call super
15
+ // after call super, then use or change what it returns
16
+ // override do not call super
17
+ //
18
+ // ctx.screen says which screen is calling ("task_list" or "task_edit").
19
+ // Keep them as methods (save(values, ctx) { … }) — super does not work in
20
+ // arrow functions. Rules that must hold belong in the server's hooks
21
+ // (api/screens/task/task.hooks.js).
22
+ export class TaskHooks extends FactoryHooks {
23
+ /** A list's rows (ctx.many), or the record Edit opens (ctx.id). */
24
+ get(ctx) {
25
+ return super.get(ctx)
26
+ }
27
+
28
+ /** Every autosave — keep it quick. Returns the saved record. */
29
+ save(values, ctx) {
30
+ return super.save(values, ctx)
31
+ }
32
+
33
+ /** A list row, after the person confirmed. */
34
+ delete(record, ctx) {
35
+ return super.delete(record, ctx)
36
+ }
37
+
38
+ /** Extra row buttons beside Edit / Delete: [{ label, onClick: (record, ctx) => … }]. ctx.refresh() reloads. */
39
+ actions(ctx) {
40
+ return super.actions(ctx)
41
+ }
42
+ }
43
+
44
+ export const taskHooks = new TaskHooks()
45
+
46
+ /** The task form on a page of its own: <EditTask /> for a new task, <EditTask record={task} /> for one. */
47
+ export default function EditTask({ record }) {
48
+ const [screen, setScreen] = useState(null)
49
+ const [error, setError] = useState(null)
50
+
51
+ useEffect(() => {
52
+ factory.loadScreen('task_edit').then(setScreen, (e) => setError(e.message))
53
+ }, [])
54
+
55
+ if (error) return <div className="app-error">Could not load the task form — {error}</div>
56
+ return screen ? <FactoryScreen document={screen} record={record} {...factory.screenProps} hooks={taskHooks} /> : null
57
+ }
@@ -0,0 +1,74 @@
1
+ // DESIGN ONE FORM — its add / edit form, or its list. Move and resize fields,
2
+ // add new ones, change labels, rules, fonts and colours.
3
+ //
4
+ // Edits are kept as a DRAFT automatically; nobody sees them until Publish,
5
+ // which also changes the table to match: a new field adds a column; removing
6
+ // one asks first, because its column and every value in it would be deleted.
7
+ import { useCallback, useEffect, useState } from 'react'
8
+ import { useNavigate, useParams } from 'react-router-dom'
9
+ import { FactoryBuilder } from '@xeplr/ui-factory'
10
+ import { factory } from '../api/factory.js'
11
+
12
+ const PARTS = [
13
+ { part: 'form', suffix: '_edit', label: 'Form' },
14
+ { part: 'list', suffix: '_list', label: 'List' }
15
+ ]
16
+
17
+ export default function FormDesigner() {
18
+ const { form, part } = useParams()
19
+ const navigate = useNavigate()
20
+ const screenKey = form + (part === 'list' ? '_list' : '_edit')
21
+ const [current, setCurrent] = useState(null) // { key, document, lockedNames }
22
+ const [error, setError] = useState(null)
23
+
24
+ useEffect(() => {
25
+ let cancelled = false
26
+ setCurrent(null)
27
+ // The draft if there is one, else the published version — with the field
28
+ // names that are already columns, which cannot be renamed.
29
+ factory.loadDraft(screenKey).then(
30
+ (row) => { if (!cancelled) { setCurrent({ key: screenKey, document: row.document, lockedNames: row.lockedNames || [] }); setError(null) } },
31
+ (e) => { if (!cancelled) setError(e.message) }
32
+ )
33
+ return () => { cancelled = true }
34
+ }, [screenKey])
35
+
36
+ const publish = useCallback(async (doc, options) => {
37
+ const result = await factory.publish(doc, options)
38
+ const row = await factory.loadDraft(doc.id)
39
+ setCurrent((c) => (c && c.key === doc.id ? { ...c, lockedNames: row.lockedNames || [] } : c))
40
+ return result
41
+ }, [])
42
+
43
+ return (
44
+ <div className="app-designer">
45
+ <div className="app-designer-bar">
46
+ <button type="button" className="app-btn app-btn-quiet" onClick={() => navigate('/forms')}>← Forms</button>
47
+ <span className="app-designer-title">{current ? current.document.name : form}</span>
48
+ {PARTS.map((p) => (
49
+ <button
50
+ key={p.part}
51
+ type="button"
52
+ className={'app-btn app-btn-quiet' + ((part === 'list') === (p.part === 'list') ? ' is-active' : '')}
53
+ onClick={() => navigate('/forms/' + form + '/design/' + p.part)}
54
+ >
55
+ {p.label}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ {error && <div className="app-error">{error}</div>}
60
+ {current && (
61
+ <div className="app-designer-canvas">
62
+ <FactoryBuilder
63
+ key={current.key}
64
+ {...factory.builderProps}
65
+ document={current.document}
66
+ lockedNames={current.lockedNames}
67
+ onPublish={publish}
68
+ screens={PARTS.map((p) => ({ id: form + p.suffix, name: p.label }))}
69
+ />
70
+ </div>
71
+ )}
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,24 @@
1
+ // USE ANY FORM — its published list, with New / Edit opening the form in a
2
+ // popup. Tasks.jsx is the same thing for one form, with a help panel beside it.
3
+ import { useEffect, useState } from 'react'
4
+ import { useParams } from 'react-router-dom'
5
+ import { FactoryScreen } from '@xeplr/ui-factory'
6
+ import { factory } from '../api/factory.js'
7
+
8
+ export default function FormRecords() {
9
+ const { form } = useParams()
10
+ const [screen, setScreen] = useState(null)
11
+ const [error, setError] = useState(null)
12
+
13
+ useEffect(() => {
14
+ setScreen(null)
15
+ factory.loadScreen(form + '_list').then((doc) => { setScreen(doc); setError(null) }, (e) => setError(e.message))
16
+ }, [form])
17
+
18
+ return (
19
+ <div className="app-page">
20
+ {error && <div className="app-error">Could not open this form — {error}</div>}
21
+ {screen && <FactoryScreen key={form} document={screen} {...factory.screenProps} />}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,144 @@
1
+ // FORMS — every form in the app, for Super Admin: see them, make a new one,
2
+ // design it, publish it, open it.
3
+ //
4
+ // A form is two screens: a LIST of its records, and the add / edit FORM the
5
+ // list opens in a popup. Records live in an ordinary table named for the form
6
+ // ("farming_departments"), created when the form is first published.
7
+ //
8
+ // Claude can make forms too (api/screens/, `npx xeplr-factory screens`); this
9
+ // page is the same thing by hand.
10
+ import { useCallback, useEffect, useState } from 'react'
11
+ import { useNavigate } from 'react-router-dom'
12
+ import { factory } from '../api/factory.js'
13
+
14
+ /** listScreens rows → one entry per form: { key, name, source, list, edit }. */
15
+ function groupForms(rows) {
16
+ const byKey = new Map()
17
+ rows.forEach((row) => {
18
+ const m = /^(.*)_(list|edit)$/.exec(row.screenKey)
19
+ const key = m ? m[1] : row.screenKey
20
+ const entry = byKey.get(key) || { key, name: null, source: null, list: null, edit: null }
21
+ if (m) entry[m[2]] = row
22
+ else entry.edit = row
23
+ if (m && m[2] === 'list') entry.name = row.name
24
+ entry.name = entry.name || row.name
25
+ entry.source = entry.source || row.source
26
+ byKey.set(key, entry)
27
+ })
28
+ return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
29
+ }
30
+
31
+ function state(screen) {
32
+ if (!screen) return { label: '—', unpublished: false }
33
+ if (screen.version === null) return { label: 'Not published yet', unpublished: true }
34
+ return { label: 'v' + screen.version + (screen.hasDraft ? ' · unpublished changes' : ''), unpublished: screen.hasDraft }
35
+ }
36
+
37
+ export default function Forms() {
38
+ const navigate = useNavigate()
39
+ const [forms, setForms] = useState(null)
40
+ const [error, setError] = useState(null)
41
+ const [notice, setNotice] = useState(null)
42
+ const [name, setName] = useState('')
43
+ const [busy, setBusy] = useState(null)
44
+
45
+ const load = useCallback(() => {
46
+ factory.listScreens().then((rows) => { setForms(groupForms(rows)); setError(null) }, (e) => setError(e.message))
47
+ }, [])
48
+ useEffect(load, [load])
49
+
50
+ const create = async (e) => {
51
+ e.preventDefault()
52
+ setBusy('new')
53
+ try {
54
+ const made = await factory.createEntity({ entity: name })
55
+ // Straight into its form, to add the fields.
56
+ navigate('/forms/' + made.edit.replace(/_edit$/, '') + '/design/form')
57
+ } catch (err) {
58
+ setError(err.message)
59
+ setBusy(null)
60
+ }
61
+ }
62
+
63
+ // The form first — publishing it creates or changes the table — then the list.
64
+ const publish = async (form) => {
65
+ setBusy(form.key)
66
+ setNotice(null)
67
+ try {
68
+ for (const screen of [form.edit, form.list]) {
69
+ if (!screen || (screen.version !== null && !screen.hasDraft)) continue
70
+ const row = await factory.loadDraft(screen.screenKey)
71
+ await factory.publish(row.document)
72
+ }
73
+ setNotice('Published "' + form.name + '"')
74
+ setError(null)
75
+ load()
76
+ } catch (err) {
77
+ // Removing a field drops its column — that is confirmed in the designer,
78
+ // where the columns and their values are shown.
79
+ setError(err.confirm
80
+ ? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
81
+ : err.message)
82
+ } finally {
83
+ setBusy(null)
84
+ }
85
+ }
86
+
87
+ return (
88
+ <div className="app-page app-forms">
89
+ <h1>Forms</h1>
90
+ <p className="app-page-lead">
91
+ Every form in the app. A new one starts with a Name field — add the rest in its design, then publish to create its table.
92
+ </p>
93
+
94
+ <form className="app-forms-new" onSubmit={create}>
95
+ <input
96
+ value={name}
97
+ onChange={(e) => setName(e.target.value)}
98
+ placeholder='New form — one record is a… e.g. "farming department"'
99
+ aria-label="New form name"
100
+ />
101
+ <button type="submit" className="app-btn" disabled={!name.trim() || busy === 'new'}>
102
+ {busy === 'new' ? 'Creating…' : 'New form'}
103
+ </button>
104
+ </form>
105
+
106
+ {error && <div className="app-error">{error}</div>}
107
+ {notice && <div className="app-notice">{notice}</div>}
108
+
109
+ {forms && forms.length === 0 && <p className="app-sub">No forms yet.</p>}
110
+ {forms && forms.length > 0 && (
111
+ <table className="app-table">
112
+ <thead>
113
+ <tr><th>Name</th><th>Table</th><th>Form</th><th>List</th><th /></tr>
114
+ </thead>
115
+ <tbody>
116
+ {forms.map((form) => {
117
+ const edit = state(form.edit)
118
+ const list = state(form.list)
119
+ const canOpen = form.list && form.list.version !== null
120
+ return (
121
+ <tr key={form.key}>
122
+ <td><strong>{form.name || form.key}</strong><div className="app-sub">{form.key}</div></td>
123
+ <td><code>{form.source || '—'}</code></td>
124
+ <td className={edit.unpublished ? 'app-forms-pending' : undefined}>{edit.label}</td>
125
+ <td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
126
+ <td className="app-row-actions">
127
+ <button className="app-btn app-btn-quiet" disabled={!canOpen} title={canOpen ? '' : 'Publish it first'} onClick={() => navigate('/forms/' + form.key)}>Open</button>
128
+ {form.edit && <button className="app-btn app-btn-quiet" onClick={() => navigate('/forms/' + form.key + '/design/form')}>Design form</button>}
129
+ {form.list && <button className="app-btn app-btn-quiet" onClick={() => navigate('/forms/' + form.key + '/design/list')}>Design list</button>}
130
+ {(edit.unpublished || list.unpublished) && (
131
+ <button className="app-btn" disabled={busy === form.key} onClick={() => publish(form)}>
132
+ {busy === form.key ? 'Publishing…' : 'Publish'}
133
+ </button>
134
+ )}
135
+ </td>
136
+ </tr>
137
+ )
138
+ })}
139
+ </tbody>
140
+ </table>
141
+ )}
142
+ </div>
143
+ )
144
+ }
@@ -58,6 +58,10 @@ export default function Home() {
58
58
  <code>public/logo.svg</code> and <code>public/logo-wide.svg</code> — the
59
59
  sample logo, in the top bar and the side rail.
60
60
  </li>
61
+ <li>
62
+ <strong>Forms</strong>, in the side rail — make a new form, or change the
63
+ sample task screens. Publish creates or changes the table too.
64
+ </li>
61
65
  <li>
62
66
  <code>src/App.jsx</code> — add your own pages, and the side-rail items
63
67
  that open them.
@@ -1,105 +1,31 @@
1
- // DESIGN LAYER — presentation only. Every value and every handler comes from
2
- // the controller; there is no logic here to get out of step with it.
3
- import { useTasksController } from '../useTasksController.js'
1
+ // THE SAMPLE — a list of tasks. New and Edit open the task form in a popup,
2
+ // where it saves itself as you type; there is no Save button.
3
+ //
4
+ // Nothing about tasks is written in this file. The list and the form are
5
+ // SCREENS, designed on the Forms page and stored by the API; this page only
6
+ // loads the published "task_list" and hands it the calls it needs — and the
7
+ // task hooks (EditTask.jsx), which the popup uses too.
8
+ import { useEffect, useState } from 'react'
9
+ import { FactoryScreen } from '@xeplr/ui-factory'
10
+ import { factory } from '../api/factory.js'
4
11
  import TasksHelp from './TasksHelp.jsx'
5
-
6
- const STATUSES = [
7
- { value: 'todo', label: 'To do' },
8
- { value: 'in_progress', label: 'In progress' },
9
- { value: 'done', label: 'Done' }
10
- ]
12
+ import { taskHooks } from './EditTask.jsx'
11
13
 
12
14
  export default function Tasks() {
13
- const { tasks, form, setField, submit, edit, cancel, remove, editingId, busy, error } =
14
- useTasksController()
15
+ const [screen, setScreen] = useState(null)
16
+ const [error, setError] = useState(null)
17
+
18
+ useEffect(() => {
19
+ factory.loadScreen('task_list').then(setScreen, (e) => setError(e.message))
20
+ }, [])
15
21
 
16
22
  return (
17
23
  <div className="app-page app-page-split">
18
24
  <div className="app-page-main">
19
- <h1>Tasks</h1>
20
- <p className="app-page-lead">
21
- A sample form and list. One model, one migration, one line of routing.
22
- </p>
23
-
24
- {error && <div className="app-error">{error}</div>}
25
-
26
- <form className="app-form" onSubmit={submit}>
27
- <label className="app-field">
28
- <span>Title</span>
29
- <input
30
- value={form.title}
31
- onChange={(e) => setField('title', e.target.value)}
32
- placeholder="What needs doing?"
33
- required
34
- />
35
- </label>
36
-
37
- <label className="app-field">
38
- <span>Description</span>
39
- <textarea
40
- rows={2}
41
- value={form.description}
42
- onChange={(e) => setField('description', e.target.value)}
43
- placeholder="Optional"
44
- />
45
- </label>
46
-
47
- <div className="app-field-row">
48
- <label className="app-field">
49
- <span>Status</span>
50
- <select value={form.status} onChange={(e) => setField('status', e.target.value)}>
51
- {STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
52
- </select>
53
- </label>
54
-
55
- <label className="app-field">
56
- <span>Due date</span>
57
- <input
58
- type="date"
59
- value={form.dueDate}
60
- onChange={(e) => setField('dueDate', e.target.value)}
61
- />
62
- </label>
63
- </div>
64
-
65
- <div className="app-form-actions">
66
- <button type="submit" className="app-btn" disabled={busy}>
67
- {editingId ? 'Save changes' : 'Add task'}
68
- </button>
69
- {editingId && (
70
- <button type="button" className="app-btn app-btn-quiet" onClick={cancel}>
71
- Cancel
72
- </button>
73
- )}
74
- </div>
75
- </form>
76
-
77
- <table className="app-table">
78
- <thead>
79
- <tr><th>Title</th><th>Status</th><th>Due</th><th /></tr>
80
- </thead>
81
- <tbody>
82
- {tasks.length === 0 && (
83
- <tr><td colSpan={4} className="app-empty">No tasks yet. Add one above.</td></tr>
84
- )}
85
- {tasks.map((t) => (
86
- <tr key={t.id} className={editingId === t.id ? 'is-editing' : undefined}>
87
- <td>
88
- <strong>{t.title}</strong>
89
- {t.description && <div className="app-sub">{t.description}</div>}
90
- </td>
91
- <td><span className={'app-tag is-' + t.status}>{t.status.replace('_', ' ')}</span></td>
92
- <td>{t.dueDate ? String(t.dueDate).slice(0, 10) : '—'}</td>
93
- <td className="app-row-actions">
94
- <button className="app-btn app-btn-quiet" onClick={() => edit(t)}>Edit</button>
95
- <button className="app-btn app-btn-quiet" onClick={() => remove(t.id)}>Delete</button>
96
- </td>
97
- </tr>
98
- ))}
99
- </tbody>
100
- </table>
25
+ {error && <div className="app-error">Could not load the task list — {error}</div>}
26
+ {/* loadScreen lets the list's Edit / New fetch "task_edit" when first opened. */}
27
+ {screen && <FactoryScreen document={screen} {...factory.screenProps} hooks={taskHooks} />}
101
28
  </div>
102
-
103
29
  <TasksHelp />
104
30
  </div>
105
31
  )
@@ -1,64 +1,80 @@
1
- // The panel beside the sample form. It is here so the first thing anybody
2
- // builds is explained where they are looking, rather than in a document they
3
- // have to go and find.
1
+ import { useState } from 'react'
2
+
3
+ // The panel beside the sample. It is here so the first thing anybody builds is
4
+ // explained where they are looking, rather than in a document they have to go
5
+ // and find.
6
+
7
+ // The same prompt as in CLAUDE.md. Replace the form and its fields.
8
+ const PROMPT = '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.'
9
+
4
10
  export default function TasksHelp() {
11
+ const [copied, setCopied] = useState(false)
12
+ const copy = () => {
13
+ navigator.clipboard.writeText(PROMPT).then(() => {
14
+ setCopied(true)
15
+ setTimeout(() => setCopied(false), 2000)
16
+ }, () => {})
17
+ }
18
+
5
19
  return (
6
20
  <aside className="app-help">
7
21
  <div className="app-help-badge">Sample</div>
8
- <h2>This page is an example</h2>
22
+ <h2>Create your own UI with Claude</h2>
9
23
  <p>
10
- A task has a title, a description, a status and a due date. Nothing here
11
- is special — it is the same five pieces you will write for anything your
12
- app stores. Delete it once you have your own.
24
+ Open Claude in this project's folder (the one with <code>api</code> and{' '}
25
+ <code>ui</code>) and give it this — change the name and the fields:
26
+ </p>
27
+ <pre className="app-help-prompt">{PROMPT}</pre>
28
+ <button type="button" className="app-btn app-btn-quiet" onClick={copy}>
29
+ {copied ? 'Copied' : 'Copy prompt'}
30
+ </button>
31
+ <p>
32
+ Add behaviour in the same message if you want it — "a Mark active button
33
+ on each row", "Region is required when Area is over 100". When Claude is
34
+ done, restart the sign-in service and the API: the menu item appears and
35
+ the table is created.
13
36
  </p>
14
37
 
15
- <h3>Add your own, in five pieces</h3>
16
- <ol className="app-help-steps">
17
- <li>
18
- <strong>The table</strong>
19
- <code>api/migrations/0002_things.sql</code>
20
- <span>Runs by itself when the API next starts.</span>
21
- </li>
22
- <li>
23
- <strong>The model</strong>
24
- <code>api/models/Thing.js</code>
25
- <span>
26
- Names the table and lists the fields, with the rules for each. Those
27
- rules are checked on every save.
28
- </span>
29
- </li>
30
- <li>
31
- <strong>The routes</strong>
32
- <code>api/routes/index.js</code>
33
- <span>
34
- One line — hand the model to <code>genericRoute</code> and you get
35
- list, fetch, save and delete. You write none of them.
36
- </span>
37
- </li>
38
- <li>
39
- <strong>The page</strong>
40
- <code>ui/src/pages/Things.jsx</code>
41
- <span>
42
- Plus a file for the calls and one for the form's behaviour, so a new
43
- look means replacing the page only.
44
- </span>
45
- </li>
46
- <li>
47
- <strong>The menu entry</strong>
48
- <code>api/migrations-auth/000N_things_menu.sql</code>
49
- <span>
50
- The side-rail item appears only if its name exists in the database,
51
- spelled exactly the same.
52
- </span>
53
- </li>
54
- </ol>
38
+ <h3>This page is an example</h3>
39
+ <p>
40
+ A task has a title, a status, a due date and a description. Neither the
41
+ list nor the form is written by hand — both are <strong>screens</strong>,
42
+ and the tasks are saved in an ordinary <code>tasks</code> table with one
43
+ column per field.
44
+ </p>
45
+
46
+ <h3>Change it</h3>
47
+ <p>
48
+ Open <strong>Forms</strong> in the side rail and{' '}
49
+ <strong>Design form</strong> on Tasks. Add a field, move one, change a
50
+ label or a colour — then <strong>Publish</strong>. The table gains its
51
+ new column as you publish.
52
+ </p>
53
+
54
+ <h3>Or by hand</h3>
55
+ <p>
56
+ <strong>Forms → New form</strong>: name it, design it, publish it, open
57
+ it — no code. The full steps for a form with its own page, hooks and
58
+ model are in <code>CLAUDE.md</code>, under "Create a new UI".
59
+ </p>
60
+
61
+ <h3>More than saving</h3>
62
+ <p>
63
+ In the browser — a value added before saving, rows filtered for display,
64
+ an extra button on each row: <code>ui/src/pages/EditTask.jsx</code>.
65
+ Every method calls <code>super</code>; change the one you need.
66
+ </p>
67
+ <p>
68
+ On the server — a check against other records, an email afterwards, a
69
+ list only managers see: <code>api/screens/task/task.hooks.js</code>.
70
+ </p>
55
71
 
56
72
  <h3>Two things that catch people</h3>
57
73
  <ul className="app-help-notes">
58
74
  <li>
59
- Keep every data address under <code>/api</code>. Serve data at{' '}
60
- <code>/things</code> and it hides the <em>page</em> at the same
61
- address — you get raw data instead of your app.
75
+ Once a screen is published, the <em>database</em> holds its design.
76
+ Editing its <code>.screen.json</code> afterwards changes nothing — use
77
+ Forms.
62
78
  </li>
63
79
  <li>
64
80
  A new menu entry is remembered for ten minutes. Restart the sign-in
@@ -43,7 +43,7 @@ export default defineConfig(({ mode }) => {
43
43
  plugins: [react()],
44
44
  // Force every import to THIS app's copy, so hook-using libraries don't
45
45
  // load twice and break the rules of hooks in ways that read as impossible.
46
- resolve: { dedupe: ['react', 'react-dom', 'react-router-dom'] },
46
+ resolve: { dedupe: ['react', 'react-dom', 'react-router-dom', '@xeplr/ui-canvas', '@xeplr/ui-table'] },
47
47
  server: { port, strictPort: true, proxy },
48
48
  preview: { port, strictPort: true }
49
49
  }
@@ -0,0 +1,8 @@
1
+ -- The tables the picker lists and adds to: __MT_LEVEL_NAMES__.
2
+ --
3
+ -- Deliberately plain — an id and a name. Add what your app needs (an address,
4
+ -- a plan) with a later migration. Who may use one is NOT here: that is a
5
+ -- membership row in the sign-in database (userTenantsMapping), made for
6
+ -- whoever adds it.
7
+
8
+ __MT_TABLES_SQL__
@@ -0,0 +1,10 @@
1
+ -- "__MT_SWITCH_NAME__" — the drawer item that goes back to the picker.
2
+ --
3
+ -- Public: everybody who signs in works inside one, so everybody can change it.
4
+ -- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
5
+
6
+ INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
7
+ SELECT encode(gen_random_bytes(12), 'hex'), '__MT_SWITCH_NAME__', '', true, true, '*', now(), now()
8
+ WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = '__MT_SWITCH_NAME__');
9
+
10
+ UPDATE "menus" SET "isPublic" = true WHERE name = '__MT_SWITCH_NAME__';