@xeplr/cli 0.1.3 → 0.1.5
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 +12 -3
- package/lib/generate/index.js +4 -5
- package/lib/plan.js +1 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +59 -0
- package/templates/api/app.js +13 -2
- package/templates/api/bin/www +5 -4
- package/templates/api/development.env.tpl +5 -0
- package/templates/api/env.required.js +1 -1
- package/templates/api/migrations/README.md +1 -1
- package/templates/api/migrations-auth/0001_home_menu.sql +1 -1
- package/templates/api/migrations-auth/0002_tasks_menu.sql +2 -1
- package/templates/api/migrations-auth/0003_configure_ui_menu.sql +20 -0
- package/templates/api/models/index.js +1 -1
- package/templates/api/package.json +4 -3
- package/templates/api/routes/access.js +25 -0
- package/templates/api/routes/index.js +1 -1
- package/templates/api/screens/index.js +14 -7
- package/templates/api/screens/task/task.model.js +29 -0
- package/templates/ui/package.json +2 -2
- package/templates/ui/src/App.jsx +29 -13
- package/templates/ui/src/api/factory.js +1 -1
- package/templates/ui/src/index.css +59 -0
- package/templates/ui/src/menu.js +17 -0
- package/templates/ui/src/pages/ConfigureUI.jsx +42 -0
- package/templates/ui/src/pages/EditTask.jsx +57 -0
- package/templates/ui/src/pages/FormDesigner.jsx +74 -0
- package/templates/ui/src/pages/FormRecords.jsx +24 -0
- package/templates/ui/src/pages/Forms.jsx +185 -0
- package/templates/ui/src/pages/Home.jsx +2 -2
- package/templates/ui/src/pages/MenuSettings.jsx +99 -0
- package/templates/ui/src/pages/Tasks.jsx +5 -3
- package/templates/ui/src/pages/TasksHelp.jsx +49 -35
- package/templates-tenancy/api/migrations-auth/0004_switch_menu.sql +2 -1
- package/templates-tenancy/api/routes/tenants.js +1 -5
- package/templates/api/migrations-auth/0003_designer_menu.sql +0 -19
- package/templates/ui/src/pages/Designer.jsx +0 -74
|
@@ -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 in Configure UI → Forms. 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('/configure')}>← Configure UI</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('/configure/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,185 @@
|
|
|
1
|
+
// FORMS — every UI in the app (Configure UI → Forms). Make one, design it,
|
|
2
|
+
// publish it, open it, add it to the side rail.
|
|
3
|
+
//
|
|
4
|
+
// A form is two screens: a LIST of its records, and the add / edit FORM the list
|
|
5
|
+
// opens in a popup. Its KEY (farming_department) names the screens and the table
|
|
6
|
+
// (farming_departments) and cannot change once published; its LABEL is what
|
|
7
|
+
// people see, renamed any time in its design.
|
|
8
|
+
//
|
|
9
|
+
// Claude can make forms too — see "Create a new UI" in CLAUDE.md.
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
import { useNavigate } from 'react-router-dom'
|
|
12
|
+
import { listMenuItems, addMenuItem, removeMenuItem, useAccess } from '@xeplr/ui-account'
|
|
13
|
+
import { factory } from '../api/factory.js'
|
|
14
|
+
import { FORM_MENU_PREFIX } from '../menu.js'
|
|
15
|
+
|
|
16
|
+
/** listScreens rows → one entry per form: { key, name, source, list, edit }. */
|
|
17
|
+
function groupForms(rows) {
|
|
18
|
+
const byKey = new Map()
|
|
19
|
+
rows.forEach((row) => {
|
|
20
|
+
const m = /^(.*)_(list|edit)$/.exec(row.screenKey)
|
|
21
|
+
const key = m ? m[1] : row.screenKey
|
|
22
|
+
const entry = byKey.get(key) || { key, name: null, source: null, list: null, edit: null }
|
|
23
|
+
if (m) entry[m[2]] = row
|
|
24
|
+
else entry.edit = row
|
|
25
|
+
if (m && m[2] === 'list') entry.name = row.name
|
|
26
|
+
entry.name = entry.name || row.name
|
|
27
|
+
entry.source = entry.source || row.source
|
|
28
|
+
byKey.set(key, entry)
|
|
29
|
+
})
|
|
30
|
+
return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function state(screen) {
|
|
34
|
+
if (!screen) return { label: '—', unpublished: false }
|
|
35
|
+
if (screen.version === null) return { label: 'Not published yet', unpublished: true }
|
|
36
|
+
return { label: 'v' + screen.version + (screen.hasDraft ? ' · unpublished changes' : ''), unpublished: screen.hasDraft }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "Farming departments" → "farming_department": a suggestion, editable. */
|
|
40
|
+
function suggestKey(label) {
|
|
41
|
+
const words = String(label).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean)
|
|
42
|
+
if (!words.length) return ''
|
|
43
|
+
const last = words[words.length - 1]
|
|
44
|
+
words[words.length - 1] = /ies$/.test(last) ? last.slice(0, -3) + 'y' : /(ss|us)$/.test(last) ? last : last.replace(/s$/, '')
|
|
45
|
+
const key = words.join('_')
|
|
46
|
+
return /^[a-z]/.test(key) ? key : 'form_' + key
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function Forms() {
|
|
50
|
+
const navigate = useNavigate()
|
|
51
|
+
const { refreshAccess } = useAccess()
|
|
52
|
+
const [forms, setForms] = useState(null)
|
|
53
|
+
const [inMenu, setInMenu] = useState(new Set())
|
|
54
|
+
const [error, setError] = useState(null)
|
|
55
|
+
const [notice, setNotice] = useState(null)
|
|
56
|
+
const [label, setLabel] = useState('')
|
|
57
|
+
const [key, setKey] = useState('')
|
|
58
|
+
const [keyEdited, setKeyEdited] = useState(false)
|
|
59
|
+
const [busy, setBusy] = useState(null)
|
|
60
|
+
|
|
61
|
+
const load = useCallback(() => {
|
|
62
|
+
factory.listScreens().then((rows) => { setForms(groupForms(rows)); setError(null) }, (e) => setError(e.message))
|
|
63
|
+
listMenuItems().then(
|
|
64
|
+
(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
|
+
() => {}
|
|
66
|
+
)
|
|
67
|
+
}, [])
|
|
68
|
+
useEffect(load, [load])
|
|
69
|
+
|
|
70
|
+
const onLabel = (value) => {
|
|
71
|
+
setLabel(value)
|
|
72
|
+
if (!keyEdited) setKey(suggestKey(value))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const create = async (e) => {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
setBusy('new')
|
|
78
|
+
try {
|
|
79
|
+
const made = await factory.createEntity({ key: key.trim(), label: label.trim() })
|
|
80
|
+
navigate('/configure/forms/' + made.key + '/design/form')
|
|
81
|
+
} catch (err) {
|
|
82
|
+
setError(err.message)
|
|
83
|
+
setBusy(null)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The form first — publishing it creates or changes the table — then the list.
|
|
88
|
+
const publish = async (form) => {
|
|
89
|
+
setBusy(form.key)
|
|
90
|
+
setNotice(null)
|
|
91
|
+
try {
|
|
92
|
+
for (const screen of [form.edit, form.list]) {
|
|
93
|
+
if (!screen || (screen.version !== null && !screen.hasDraft)) continue
|
|
94
|
+
const row = await factory.loadDraft(screen.screenKey)
|
|
95
|
+
await factory.publish(row.document)
|
|
96
|
+
}
|
|
97
|
+
setNotice('Published "' + form.name + '"')
|
|
98
|
+
setError(null)
|
|
99
|
+
load()
|
|
100
|
+
} catch (err) {
|
|
101
|
+
setError(err.confirm
|
|
102
|
+
? 'Publishing "' + form.name + '" would remove columns and their data — open Design form and publish there to see what, and confirm.'
|
|
103
|
+
: err.message)
|
|
104
|
+
} finally {
|
|
105
|
+
setBusy(null)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const toggleMenu = async (form) => {
|
|
110
|
+
setBusy(form.key)
|
|
111
|
+
try {
|
|
112
|
+
if (inMenu.has(form.key)) await removeMenuItem(FORM_MENU_PREFIX + form.key)
|
|
113
|
+
else await addMenuItem({ name: FORM_MENU_PREFIX + form.key, label: form.name })
|
|
114
|
+
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')
|
|
116
|
+
load()
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setError(err.message)
|
|
119
|
+
} finally {
|
|
120
|
+
setBusy(null)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="app-forms">
|
|
126
|
+
<p className="app-page-lead">
|
|
127
|
+
Every UI in the app. A new one starts with a Name field — add the rest in its design, then publish to create its table.
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<form className="app-forms-new" onSubmit={create}>
|
|
131
|
+
<label className="app-forms-field">
|
|
132
|
+
<span>Label — what people see</span>
|
|
133
|
+
<input value={label} onChange={(e) => onLabel(e.target.value)} placeholder="Farming departments" />
|
|
134
|
+
</label>
|
|
135
|
+
<label className="app-forms-field">
|
|
136
|
+
<span>Key — names its table; fixed once published</span>
|
|
137
|
+
<input value={key} onChange={(e) => { setKey(e.target.value); setKeyEdited(true) }} placeholder="farming_department" />
|
|
138
|
+
</label>
|
|
139
|
+
<button type="submit" className="app-btn" disabled={!key.trim() || busy === 'new'}>
|
|
140
|
+
{busy === 'new' ? 'Creating…' : 'New form'}
|
|
141
|
+
</button>
|
|
142
|
+
</form>
|
|
143
|
+
|
|
144
|
+
{error && <div className="app-error">{error}</div>}
|
|
145
|
+
{notice && <div className="app-notice">{notice}</div>}
|
|
146
|
+
|
|
147
|
+
{forms && forms.length === 0 && <p className="app-sub">No forms yet.</p>}
|
|
148
|
+
{forms && forms.length > 0 && (
|
|
149
|
+
<table className="app-table">
|
|
150
|
+
<thead>
|
|
151
|
+
<tr><th>Label</th><th>Table</th><th>Form</th><th>List</th><th /></tr>
|
|
152
|
+
</thead>
|
|
153
|
+
<tbody>
|
|
154
|
+
{forms.map((form) => {
|
|
155
|
+
const edit = state(form.edit)
|
|
156
|
+
const list = state(form.list)
|
|
157
|
+
const canOpen = form.list && form.list.version !== null
|
|
158
|
+
return (
|
|
159
|
+
<tr key={form.key}>
|
|
160
|
+
<td><strong>{form.name || form.key}</strong><div className="app-sub">{form.key}</div></td>
|
|
161
|
+
<td><code>{form.source || '—'}</code></td>
|
|
162
|
+
<td className={edit.unpublished ? 'app-forms-pending' : undefined}>{edit.label}</td>
|
|
163
|
+
<td className={list.unpublished ? 'app-forms-pending' : undefined}>{list.label}</td>
|
|
164
|
+
<td className="app-row-actions">
|
|
165
|
+
<button className="app-btn app-btn-quiet" disabled={!canOpen} title={canOpen ? '' : 'Publish it first'} onClick={() => navigate('/forms/' + form.key)}>Open</button>
|
|
166
|
+
{form.edit && <button className="app-btn app-btn-quiet" onClick={() => navigate('/configure/forms/' + form.key + '/design/form')}>Design form</button>}
|
|
167
|
+
{form.list && <button className="app-btn app-btn-quiet" onClick={() => navigate('/configure/forms/' + form.key + '/design/list')}>Design list</button>}
|
|
168
|
+
<button className="app-btn app-btn-quiet" disabled={!canOpen || busy === form.key} title={canOpen ? '' : 'Publish it first'} onClick={() => toggleMenu(form)}>
|
|
169
|
+
{inMenu.has(form.key) ? 'Remove from menu' : 'Add to menu'}
|
|
170
|
+
</button>
|
|
171
|
+
{(edit.unpublished || list.unpublished) && (
|
|
172
|
+
<button className="app-btn" disabled={busy === form.key} onClick={() => publish(form)}>
|
|
173
|
+
{busy === form.key ? 'Publishing…' : 'Publish'}
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</td>
|
|
177
|
+
</tr>
|
|
178
|
+
)
|
|
179
|
+
})}
|
|
180
|
+
</tbody>
|
|
181
|
+
</table>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -59,8 +59,8 @@ export default function Home() {
|
|
|
59
59
|
sample logo, in the top bar and the side rail.
|
|
60
60
|
</li>
|
|
61
61
|
<li>
|
|
62
|
-
<strong>
|
|
63
|
-
|
|
62
|
+
<strong>Configure UI</strong>, in the settings menu — every form (make one,
|
|
63
|
+
design it, publish it, add it to the menu) and the side rail's labels.
|
|
64
64
|
</li>
|
|
65
65
|
<li>
|
|
66
66
|
<code>src/App.jsx</code> — add your own pages, and the side-rail items
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// THE SIDE RAIL'S ITEMS (Configure UI → Menu) — rename, reorder, hide.
|
|
2
|
+
//
|
|
3
|
+
// Each item has a KEY, which the code matches on and which never changes here,
|
|
4
|
+
// and a LABEL, which is what people read. Leave the label empty to show the key.
|
|
5
|
+
// Saved for everyone at once; your own rail updates straight away.
|
|
6
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
7
|
+
import { listMenuItems, saveMenuItems, useAccess } from '@xeplr/ui-account'
|
|
8
|
+
import { APP_MENU_KEYS, FORM_MENU_PREFIX } from '../menu.js'
|
|
9
|
+
|
|
10
|
+
// The app's own items (ui/src/menu.js) and forms added to the menu. The rest —
|
|
11
|
+
// sign-in's pages (Login, Register …) and admin screens — only with "Show all".
|
|
12
|
+
const isAppItem = (name) => APP_MENU_KEYS.includes(name) || name.startsWith(FORM_MENU_PREFIX)
|
|
13
|
+
|
|
14
|
+
export default function MenuSettings() {
|
|
15
|
+
const { refreshAccess } = useAccess()
|
|
16
|
+
const [items, setItems] = useState(null)
|
|
17
|
+
const [error, setError] = useState(null)
|
|
18
|
+
const [notice, setNotice] = useState(null)
|
|
19
|
+
const [saving, setSaving] = useState(false)
|
|
20
|
+
const [showAll, setShowAll] = useState(false)
|
|
21
|
+
|
|
22
|
+
const load = useCallback(() => {
|
|
23
|
+
listMenuItems().then((rows) => { setItems(rows.map((r) => ({ ...r, label: r.label || '' }))); setError(null) }, (e) => setError(e.message))
|
|
24
|
+
}, [])
|
|
25
|
+
useEffect(load, [load])
|
|
26
|
+
|
|
27
|
+
const shown = (it) => showAll || isAppItem(it.name)
|
|
28
|
+
const change = (name, patch) => setItems((list) => list.map((it) => (it.name === name ? { ...it, ...patch } : it)))
|
|
29
|
+
// Swap with the next row ON SCREEN — entries filtered out keep their places.
|
|
30
|
+
const move = (name, by) => setItems((list) => {
|
|
31
|
+
const visible = list.filter(shown).map((it) => it.name)
|
|
32
|
+
const k = visible.indexOf(name) + by
|
|
33
|
+
if (k < 0 || k >= visible.length) return list
|
|
34
|
+
const a = list.findIndex((it) => it.name === name)
|
|
35
|
+
const b = list.findIndex((it) => it.name === visible[k])
|
|
36
|
+
const next = [...list]
|
|
37
|
+
;[next[a], next[b]] = [next[b], next[a]]
|
|
38
|
+
return next
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const save = async () => {
|
|
42
|
+
setSaving(true)
|
|
43
|
+
setNotice(null)
|
|
44
|
+
try {
|
|
45
|
+
// The order on screen becomes the order in the rail.
|
|
46
|
+
await saveMenuItems(items.map((it, i) => ({ name: it.name, label: it.label, sortOrder: i, isHidden: it.isHidden })))
|
|
47
|
+
await refreshAccess()
|
|
48
|
+
setNotice('Saved — the menu shows it now')
|
|
49
|
+
setError(null)
|
|
50
|
+
load()
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err.message)
|
|
53
|
+
} finally {
|
|
54
|
+
setSaving(false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="app-menu-settings">
|
|
60
|
+
<p className="app-page-lead">
|
|
61
|
+
What the side rail and the settings menu show, and in what order. Add a form to the menu from the Forms tab.
|
|
62
|
+
</p>
|
|
63
|
+
<label className="app-menu-shown">
|
|
64
|
+
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all menu entries, including sign-in and admin pages
|
|
65
|
+
</label>
|
|
66
|
+
{error && <div className="app-error">{error}</div>}
|
|
67
|
+
{notice && <div className="app-notice">{notice}</div>}
|
|
68
|
+
{items && (
|
|
69
|
+
<table className="app-table">
|
|
70
|
+
<thead>
|
|
71
|
+
<tr><th>Order</th><th>Label</th><th>Key</th><th>Shown</th></tr>
|
|
72
|
+
</thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
{items.filter(shown).map((it, i, visible) => (
|
|
75
|
+
<tr key={it.name} className={it.isHidden ? 'app-menu-hidden' : undefined}>
|
|
76
|
+
<td className="app-row-actions">
|
|
77
|
+
<button type="button" className="app-btn app-btn-quiet" aria-label={'Move ' + it.shown + ' up'} disabled={i === 0} onClick={() => move(it.name, -1)}>↑</button>
|
|
78
|
+
<button type="button" className="app-btn app-btn-quiet" aria-label={'Move ' + it.shown + ' down'} disabled={i === visible.length - 1} onClick={() => move(it.name, 1)}>↓</button>
|
|
79
|
+
</td>
|
|
80
|
+
<td>
|
|
81
|
+
<input className="app-menu-label" value={it.label} placeholder={it.name} aria-label={'Label for ' + it.name} onChange={(e) => change(it.name, { label: e.target.value })} />
|
|
82
|
+
</td>
|
|
83
|
+
<td><code>{it.name}</code></td>
|
|
84
|
+
<td>
|
|
85
|
+
<label className="app-menu-shown">
|
|
86
|
+
<input type="checkbox" checked={!it.isHidden} onChange={(e) => change(it.name, { isHidden: !e.target.checked })} /> Shown
|
|
87
|
+
</label>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
))}
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
)}
|
|
94
|
+
{items && (
|
|
95
|
+
<button type="button" className="app-btn" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save menu'}</button>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// where it saves itself as you type; there is no Save button.
|
|
3
3
|
//
|
|
4
4
|
// Nothing about tasks is written in this file. The list and the form are
|
|
5
|
-
// SCREENS, designed in
|
|
6
|
-
// loads the published "task_list" and hands it the calls it needs
|
|
5
|
+
// SCREENS, designed in Configure UI → Forms 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.
|
|
7
8
|
import { useEffect, useState } from 'react'
|
|
8
9
|
import { FactoryScreen } from '@xeplr/ui-factory'
|
|
9
10
|
import { factory } from '../api/factory.js'
|
|
10
11
|
import TasksHelp from './TasksHelp.jsx'
|
|
12
|
+
import { taskHooks } from './EditTask.jsx'
|
|
11
13
|
|
|
12
14
|
export default function Tasks() {
|
|
13
15
|
const [screen, setScreen] = useState(null)
|
|
@@ -22,7 +24,7 @@ export default function Tasks() {
|
|
|
22
24
|
<div className="app-page-main">
|
|
23
25
|
{error && <div className="app-error">Could not load the task list — {error}</div>}
|
|
24
26
|
{/* loadScreen lets the list's Edit / New fetch "task_edit" when first opened. */}
|
|
25
|
-
{screen && <FactoryScreen document={screen} {...factory.screenProps} />}
|
|
27
|
+
{screen && <FactoryScreen document={screen} {...factory.screenProps} hooks={taskHooks} />}
|
|
26
28
|
</div>
|
|
27
29
|
<TasksHelp />
|
|
28
30
|
</div>
|
|
@@ -1,11 +1,41 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
1
3
|
// The panel beside the sample. It is here so the first thing anybody builds is
|
|
2
4
|
// explained where they are looking, rather than in a document they have to go
|
|
3
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>
|
|
22
|
+
<h2>Create your own UI with Claude</h2>
|
|
23
|
+
<p>
|
|
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.
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<h3>This page is an example</h3>
|
|
9
39
|
<p>
|
|
10
40
|
A task has a title, a status, a due date and a description. Neither the
|
|
11
41
|
list nor the form is written by hand — both are <strong>screens</strong>,
|
|
@@ -15,44 +45,28 @@ export default function TasksHelp() {
|
|
|
15
45
|
|
|
16
46
|
<h3>Change it</h3>
|
|
17
47
|
<p>
|
|
18
|
-
Open <strong>
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
Open <strong>Configure UI</strong> in the settings menu (top right) 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.
|
|
21
52
|
</p>
|
|
22
53
|
|
|
23
|
-
<h3>
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
</li>
|
|
30
|
-
<li>
|
|
31
|
-
<strong>Make its screens</strong>
|
|
32
|
-
<code>npx xeplr-factory screens screens/project/project.entity.json -o screens/project</code>
|
|
33
|
-
<span>From the api folder. Then list them in <code>api/screens/index.js</code>.</span>
|
|
34
|
-
</li>
|
|
35
|
-
<li>
|
|
36
|
-
<strong>The page</strong>
|
|
37
|
-
<code>ui/src/pages/Projects.jsx</code>
|
|
38
|
-
<span>Copy <code>Tasks.jsx</code> and load <code>project_list</code>.</span>
|
|
39
|
-
</li>
|
|
40
|
-
<li>
|
|
41
|
-
<strong>The menu entry</strong>
|
|
42
|
-
<code>api/migrations-auth/000N_projects_menu.sql</code>
|
|
43
|
-
<span>
|
|
44
|
-
The side-rail item appears only if its name exists in the database,
|
|
45
|
-
spelled exactly the same.
|
|
46
|
-
</span>
|
|
47
|
-
</li>
|
|
48
|
-
</ol>
|
|
49
|
-
<p>Restart the API and the new table is created for you.</p>
|
|
54
|
+
<h3>Or by hand</h3>
|
|
55
|
+
<p>
|
|
56
|
+
<strong>Configure UI → Forms → New form</strong>: label and key, design,
|
|
57
|
+
publish, add to menu — 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>
|
|
50
60
|
|
|
51
61
|
<h3>More than saving</h3>
|
|
52
62
|
<p>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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>.
|
|
56
70
|
</p>
|
|
57
71
|
|
|
58
72
|
<h3>Two things that catch people</h3>
|
|
@@ -60,7 +74,7 @@ export default function TasksHelp() {
|
|
|
60
74
|
<li>
|
|
61
75
|
Once a screen is published, the <em>database</em> holds its design.
|
|
62
76
|
Editing its <code>.screen.json</code> afterwards changes nothing — use
|
|
63
|
-
|
|
77
|
+
Forms.
|
|
64
78
|
</li>
|
|
65
79
|
<li>
|
|
66
80
|
A new menu entry is remembered for ten minutes. Restart the sign-in
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
-- "__MT_SWITCH_NAME__" — the drawer item that goes back to the picker.
|
|
2
2
|
--
|
|
3
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
|
|
4
|
+
-- The name is the KEY: it must match ui/src/App.jsx's drawerItems `key` EXACTLY.
|
|
5
|
+
-- What people read is the label, changed in Configure UI → Menu.
|
|
5
6
|
|
|
6
7
|
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
7
8
|
SELECT encode(gen_random_bytes(12), 'hex'), '__MT_SWITCH_NAME__', '', true, true, '*', now(), now()
|
|
@@ -18,6 +18,7 @@ var { respond } = require('@xeplr/utils/lib/response');
|
|
|
18
18
|
var { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
|
|
19
19
|
var authLib = require('@xeplr/auth');
|
|
20
20
|
var tenancy = require('../tenancy');
|
|
21
|
+
var { isSuperAdmin } = require('./access');
|
|
21
22
|
var appDatabase = require('../db/setup');
|
|
22
23
|
|
|
23
24
|
// The sign-in database, read directly — this app does not run the sign-in
|
|
@@ -29,11 +30,6 @@ var auth = authLib.attach();
|
|
|
29
30
|
var CREATOR_ROLE = { 1: 'CompanyAdmin' };
|
|
30
31
|
var DEEPER_ROLE = 'Creator';
|
|
31
32
|
|
|
32
|
-
function isSuperAdmin(req) {
|
|
33
|
-
var roles = (req.access && req.access.roles) || (req.user && req.user.roles) || [];
|
|
34
|
-
return roles.indexOf('Super Admin') !== -1;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
var membership = null;
|
|
38
34
|
|
|
39
35
|
/**
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
-- "Designer" — the drawer item for designing screens (ui/src/pages/Designer.jsx).
|
|
2
|
-
--
|
|
3
|
-
-- NOT public: Publish changes database tables, so only the roles that hold
|
|
4
|
-
-- @xeplr/factory's design permission see the item. The API refuses everyone
|
|
5
|
-
-- else anyway ("Access denied: Publish factory screen"); this keeps the menu
|
|
6
|
-
-- honest about it.
|
|
7
|
-
--
|
|
8
|
-
-- The name must match ui/src/App.jsx's drawerItems entry EXACTLY.
|
|
9
|
-
|
|
10
|
-
INSERT INTO "menus" (id, name, "menuGroup", "isPublic", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
11
|
-
SELECT encode(gen_random_bytes(12), 'hex'), 'Designer', '', false, true, '*', now(), now()
|
|
12
|
-
WHERE NOT EXISTS (SELECT 1 FROM "menus" WHERE name = 'Designer');
|
|
13
|
-
|
|
14
|
-
INSERT INTO "menuRolesMapping" (id, "roleId", "menuId", "isActive", "mtId1", "recordCreatedDate", "recordModifiedDate")
|
|
15
|
-
SELECT encode(gen_random_bytes(12), 'hex'), r.id, m.id, true, '*', now(), now()
|
|
16
|
-
FROM "roles" r CROSS JOIN "menus" m
|
|
17
|
-
WHERE r.name IN ('Super Admin', 'CompanyAdmin')
|
|
18
|
-
AND m.name = 'Designer'
|
|
19
|
-
AND NOT EXISTS (SELECT 1 FROM "menuRolesMapping" x WHERE x."roleId" = r.id AND x."menuId" = m.id);
|