@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,126 @@
1
+ // THE TENANTS — what the picker lists and adds to (__MT_LEVEL_NAMES__), and
2
+ // the check that a caller belongs to the one their request names.
3
+ //
4
+ // GET /api/tenants/l1 the ones at that level the caller belongs to
5
+ // POST /api/tenants/l1 { name } add one — and make the caller a member of it
6
+ //
7
+ // A level below the first lists only the children of the one chosen above it,
8
+ // which arrives as that level's header like on every other request.
9
+ //
10
+ // MEMBERSHIP lives in the sign-in database (userTenantsMapping): user, level,
11
+ // id, role. Adding one makes you its member. Super Admin sees and may enter
12
+ // every one.
13
+
14
+ var express = require('express');
15
+ var { getMtContext } = require('@xeplr/db');
16
+ var { generateId } = require('@xeplr/utils');
17
+ var { respond } = require('@xeplr/utils/lib/response');
18
+ var { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
19
+ var authLib = require('@xeplr/auth');
20
+ var tenancy = require('../tenancy');
21
+ var { isSuperAdmin } = require('./access');
22
+ var appDatabase = require('../db/setup');
23
+
24
+ // The sign-in database, read directly — this app does not run the sign-in
25
+ // service in its own process, it only needs its membership table.
26
+ var auth = authLib.attach();
27
+
28
+ // Whoever creates one is its member, with this role: the outermost level's
29
+ // admin, a creator below it. Roles from @xeplr/factory's access migration.
30
+ var CREATOR_ROLE = { 1: 'CompanyAdmin' };
31
+ var DEEPER_ROLE = 'Creator';
32
+
33
+ var membership = null;
34
+
35
+ /**
36
+ * Refuses a request naming a company (or any level) the caller is not a
37
+ * member of — 403 "Not authorized for companyId …". A header that is absent
38
+ * is let through: nothing is read or written without one anyway.
39
+ */
40
+ async function memberGate(req, res, next) {
41
+ if (isSuperAdmin(req)) return next();
42
+ if (!membership) {
43
+ await auth.ready();
44
+ membership = authLib.mtMembershipMiddleware({ userTenantsMapping: auth.model('UserTenantsMapping') });
45
+ }
46
+ return membership(req, res, next);
47
+ }
48
+
49
+ function levelOf(key) {
50
+ var level = tenancy.levels.filter(function(l) { return l.key === key; })[0];
51
+ if (!level) throw status(HTTP.NOT_FOUND, 'No tenancy level "' + key + '"');
52
+ return level;
53
+ }
54
+
55
+ /** The chosen parents, as mtIdN values — refusing if one is not chosen yet. */
56
+ function parentsOf(level) {
57
+ var ctx = getMtContext();
58
+ var index = Number(level.key.slice(1));
59
+ var values = {};
60
+ for (var i = 1; i < index; i++) {
61
+ var parent = tenancy.levels[i - 1];
62
+ if (!ctx['mtId' + i]) throw status(HTTP.BAD_REQUEST, 'Choose a ' + parent.label.toLowerCase() + ' first');
63
+ values['mtId' + i] = ctx['mtId' + i];
64
+ }
65
+ return values;
66
+ }
67
+
68
+ var router = express.Router();
69
+ router.use(express.json());
70
+ router.use(memberGate);
71
+
72
+ router.get('/:level', handle(async function(req) {
73
+ var level = levelOf(req.params.level);
74
+ var knex = await appDatabase;
75
+ var query = knex(level.table).select('id', 'name').where('isActive', true).where(parentsOf(level)).orderBy('name');
76
+ if (!isSuperAdmin(req)) {
77
+ await auth.ready();
78
+ var ids = await auth.conn()('userTenantsMapping')
79
+ .where({ userId: req.user.id, level: level.key, isActive: true })
80
+ .pluck('value');
81
+ query.whereIn('id', ids);
82
+ }
83
+ return query;
84
+ }));
85
+
86
+ router.post('/:level', handle(async function(req) {
87
+ var level = levelOf(req.params.level);
88
+ var name = String((req.body && req.body.name) || '').trim();
89
+ if (!name || name.length > 200) throw status(HTTP.VALIDATION_ERROR, 'Give the ' + level.label.toLowerCase() + ' a name, up to 200 characters');
90
+ var who = String(req.user.id).slice(0, 25);
91
+ var now = new Date();
92
+ var row = Object.assign({ id: generateId(), name: name, isActive: true, recordCreatedDate: now, recordModifiedDate: now, recordCreatedBy: who, recordModifiedBy: who }, parentsOf(level));
93
+
94
+ var knex = await appDatabase;
95
+ await knex(level.table).insert(row);
96
+
97
+ await auth.ready();
98
+ var conn = auth.conn();
99
+ var roleName = CREATOR_ROLE[Number(level.key.slice(1))] || DEEPER_ROLE;
100
+ var role = await conn('roles').where({ name: roleName, isActive: true }).first('id');
101
+ await conn('userTenantsMapping').insert({
102
+ id: generateId(), userId: req.user.id, level: level.key, value: row.id, roleId: role ? role.id : null,
103
+ isActive: true, mtId1: '*', recordCreatedDate: now, recordModifiedDate: now, recordCreatedBy: who, recordModifiedBy: who
104
+ });
105
+ return [{ id: row.id, name: row.name }];
106
+ }));
107
+
108
+ function handle(fn) {
109
+ return async function(req, res) {
110
+ try {
111
+ respond(res, HTTP.OK, STATUS.SUCCESS, 'success', { dataArray: (await fn(req)) || [] });
112
+ } catch (err) {
113
+ var code = err.status || HTTP.SERVER_ERROR;
114
+ if (code >= 500) console.error('[tenants]', err);
115
+ res.status(code).send({ code: code >= 500 ? STATUS.SERVER_ERROR : STATUS.BAD_REQUEST, message: code >= 500 ? 'Something went wrong' : err.message, error: null, dataArray: [] });
116
+ }
117
+ };
118
+ }
119
+
120
+ function status(code, message) {
121
+ var e = new Error(message);
122
+ e.status = code;
123
+ return e;
124
+ }
125
+
126
+ module.exports = { router: router, memberGate: memberGate };
@@ -0,0 +1,12 @@
1
+ // TENANCY — the levels this app's data belongs to, outermost first
2
+ // (__MT_LEVEL_NAMES__).
3
+ //
4
+ // Every row a screen saves is stamped with the chosen one(s) in mtId1… and
5
+ // every read sees only those rows. The UI registers the SAME levels
6
+ // (ui/src/tenancy.js) so its requests carry the same headers; keep the two in
7
+ // step if you ever change them.
8
+ //
9
+ // levels one entry per level: its header, the column name it stands for,
10
+ // and the table its choices live in
11
+ // slots the shape @xeplr/db's registerMTs takes
12
+ module.exports = __MT_TENANCY_JSON__;
@@ -0,0 +1,45 @@
1
+ .app-select { max-width: 560px; }
2
+
3
+ .app-select h1 { font-size: 26px; margin: 0 0 6px; }
4
+
5
+ .app-select-level {
6
+ margin: 22px 0;
7
+ padding: 16px 18px;
8
+ border: 1px solid var(--xeplr-border-primary);
9
+ border-radius: 10px;
10
+ background: var(--xeplr-bg-secondary);
11
+ }
12
+
13
+ .app-select-level.is-disabled { opacity: 0.55; }
14
+
15
+ .app-select-level h2 { font-size: 15px; margin: 0 0 10px; }
16
+
17
+ .app-select-items { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
18
+
19
+ .app-select-item {
20
+ padding: 8px 14px;
21
+ border-radius: 8px;
22
+ border: 1px solid var(--xeplr-border-primary);
23
+ background: var(--xeplr-bg-primary);
24
+ color: var(--xeplr-text-primary);
25
+ font: inherit;
26
+ cursor: pointer;
27
+ }
28
+
29
+ .app-select-item.is-chosen {
30
+ border-color: var(--xeplr-accent);
31
+ background: var(--xeplr-accent-muted);
32
+ color: var(--xeplr-text-primary);
33
+ }
34
+
35
+ .app-select-add { display: flex; gap: 8px; }
36
+
37
+ .app-select-add input {
38
+ flex: 1;
39
+ padding: 8px 10px;
40
+ border-radius: 8px;
41
+ border: 1px solid var(--xeplr-input-border);
42
+ background: var(--xeplr-input-bg);
43
+ color: var(--xeplr-input-text);
44
+ font: inherit;
45
+ }
@@ -0,0 +1,154 @@
1
+ // THE FIRST SCREEN AFTER SIGNING IN — choose the __MT_LEVEL_NAMES__ to work in.
2
+ //
3
+ // Everything the app shows and saves afterwards belongs to that choice:
4
+ // authFetch sends it as a header on every request, the API stamps new rows
5
+ // with it and reads only its rows.
6
+ //
7
+ // Shown once per browser tab (ScopeGate below), with the last choice already
8
+ // selected, so returning is one click. "__MT_SWITCH_NAME__" in the side rail
9
+ // comes back here.
10
+ import { useCallback, useEffect, useState } from 'react'
11
+ import { Navigate, useLocation, useNavigate } from 'react-router-dom'
12
+ import {
13
+ authFetch, getActiveScope, setActiveScope, getLastScope, setLastScope, saveReturnTo, consumeReturnTo
14
+ } from '@xeplr/ui-account'
15
+ import { tenancy } from '../tenancy.js'
16
+ import './SelectScope.css'
17
+
18
+ // Per tab: a new tab, or a new sign-in, asks again.
19
+ const CONFIRMED = 'app:scopeConfirmed'
20
+ const confirmed = () => { try { return sessionStorage.getItem(CONFIRMED) === '1' } catch (e) { return false } }
21
+ const setConfirmed = (yes) => { try { yes ? sessionStorage.setItem(CONFIRMED, '1') : sessionStorage.removeItem(CONFIRMED) } catch (e) {} }
22
+
23
+ /** Lets the app's pages render only once every level is chosen in this tab. */
24
+ export function ScopeGate({ children }) {
25
+ const location = useLocation()
26
+ const complete = tenancy.levels.every((l) => getActiveScope(l.key))
27
+ if (!complete || !confirmed()) {
28
+ saveReturnTo(location)
29
+ return <Navigate to="/select" replace />
30
+ }
31
+ return children
32
+ }
33
+
34
+ export default function SelectScope() {
35
+ const navigate = useNavigate()
36
+ // What is chosen so far, per level — starting from the active or last choice.
37
+ // Made active straight away — before the lists below load, since a level's
38
+ // list is asked for with the level above it as a header.
39
+ const [chosen, setChosen] = useState(() => {
40
+ const start = Object.fromEntries(tenancy.levels.map((l) => [l.key, getActiveScope(l.key) || getLastScope(l.key) || null]))
41
+ tenancy.levels.forEach((l) => setActiveScope(l.key, start[l.key]))
42
+ return start
43
+ })
44
+
45
+ const choose = useCallback((index, item) => {
46
+ setChosen((c) => {
47
+ const next = { ...c, [tenancy.levels[index].key]: item }
48
+ // A different parent: whatever was chosen below it belonged to the old one.
49
+ tenancy.levels.slice(index + 1).forEach((l) => { next[l.key] = null })
50
+ return next
51
+ })
52
+ // Set as it is chosen, so the level below lists this one's children.
53
+ setActiveScope(tenancy.levels[index].key, item)
54
+ tenancy.levels.slice(index + 1).forEach((l) => setActiveScope(l.key, null))
55
+ setConfirmed(false)
56
+ }, [])
57
+
58
+ const complete = tenancy.levels.every((l) => chosen[l.key])
59
+
60
+ const go = () => {
61
+ tenancy.levels.forEach((l) => { setActiveScope(l.key, chosen[l.key]); setLastScope(l.key, chosen[l.key]) })
62
+ setConfirmed(true)
63
+ navigate(consumeReturnTo() || '/home', { replace: true })
64
+ }
65
+
66
+ return (
67
+ <div className="app-select">
68
+ <h1>Where are you working?</h1>
69
+ <p className="app-page-lead">Everything you see and save next belongs to this choice.</p>
70
+
71
+ {tenancy.levels.map((level, i) => {
72
+ const parentChosen = tenancy.levels.slice(0, i).every((l) => chosen[l.key])
73
+ return (
74
+ <LevelChoice
75
+ key={level.key + (i ? ':' + (chosen[tenancy.levels[i - 1].key] || {}).id : '')}
76
+ level={level}
77
+ enabled={parentChosen}
78
+ value={chosen[level.key]}
79
+ onChoose={(item) => choose(i, item)}
80
+ />
81
+ )
82
+ })}
83
+
84
+ <button type="button" className="app-btn" disabled={!complete} onClick={go}>Continue</button>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ function LevelChoice({ level, enabled, value, onChoose }) {
90
+ const [items, setItems] = useState(null)
91
+ const [error, setError] = useState(null)
92
+ const [adding, setAdding] = useState('')
93
+ const [busy, setBusy] = useState(false)
94
+ const noun = level.label.toLowerCase()
95
+
96
+ useEffect(() => {
97
+ if (!enabled) return
98
+ authFetch('/api/tenants/' + level.key).then((body) => {
99
+ const rows = body.dataArray || []
100
+ setItems(rows)
101
+ // A remembered choice that is no longer on the list is not a choice.
102
+ if (value && !rows.some((r) => r.id === value.id)) onChoose(null)
103
+ else if (!value && rows.length === 1) onChoose(rows[0])
104
+ }, (e) => setError(e.message))
105
+ }, [enabled, level.key]) // eslint-disable-line react-hooks/exhaustive-deps
106
+
107
+ const add = async (e) => {
108
+ e.preventDefault()
109
+ setBusy(true)
110
+ try {
111
+ const body = await authFetch('/api/tenants/' + level.key, { method: 'POST', body: JSON.stringify({ name: adding }) })
112
+ const created = body.dataArray[0]
113
+ setItems((list) => [...(list || []), created].sort((a, b) => a.name.localeCompare(b.name)))
114
+ setAdding('')
115
+ setError(null)
116
+ onChoose(created)
117
+ } catch (err) {
118
+ setError(err.message)
119
+ } finally {
120
+ setBusy(false)
121
+ }
122
+ }
123
+
124
+ return (
125
+ <section className={'app-select-level' + (enabled ? '' : ' is-disabled')}>
126
+ <h2>{level.label}</h2>
127
+ {!enabled && <p className="app-sub">Choose the one above first.</p>}
128
+ {enabled && items === null && !error && <p className="app-sub">Loading…</p>}
129
+ {error && <div className="app-error">{error}</div>}
130
+ {enabled && items && (
131
+ <>
132
+ {items.length === 0 && <p className="app-sub">No {noun} yet — add the first one.</p>}
133
+ <div className="app-select-items">
134
+ {items.map((item) => (
135
+ <button
136
+ key={item.id}
137
+ type="button"
138
+ className={'app-select-item' + (value && value.id === item.id ? ' is-chosen' : '')}
139
+ aria-pressed={!!value && value.id === item.id}
140
+ onClick={() => onChoose(item)}
141
+ >
142
+ {item.name}
143
+ </button>
144
+ ))}
145
+ </div>
146
+ <form className="app-select-add" onSubmit={add}>
147
+ <input value={adding} onChange={(e) => setAdding(e.target.value)} placeholder={'New ' + noun + ' name'} aria-label={'New ' + noun + ' name'} />
148
+ <button type="submit" className="app-btn app-btn-quiet" disabled={busy || !adding.trim()}>Add {noun}</button>
149
+ </form>
150
+ </>
151
+ )}
152
+ </section>
153
+ )
154
+ }
@@ -0,0 +1,3 @@
1
+ // TENANCY — the same levels as the API's api/tenancy.js (__MT_LEVEL_NAMES__).
2
+ // Keep the two in step.
3
+ export const tenancy = __MT_TENANCY_JSON__
@@ -1,23 +0,0 @@
1
- -- The sample table.
2
- --
3
- -- `id` is varchar(25) because ids are minted by the framework
4
- -- (@xeplr/utils' generateId) rather than by the database.
5
- --
6
- -- `isActive` is what makes a delete SOFT: the generic delete route sets it
7
- -- false and every query filters on it, so a deleted row stops appearing
8
- -- without ever leaving the table.
9
-
10
- CREATE TABLE IF NOT EXISTS "tasks" (
11
- "id" varchar(25) PRIMARY KEY,
12
- "title" varchar(200) NOT NULL,
13
- "description" varchar(1000),
14
- "status" varchar(20) NOT NULL DEFAULT 'todo',
15
- "dueDate" date,
16
- "isActive" boolean DEFAULT true,
17
- "recordCreatedDate" timestamptz,
18
- "recordModifiedDate" timestamptz,
19
- "recordCreatedBy" varchar(25),
20
- "recordModifiedBy" varchar(25)
21
- );
22
-
23
- CREATE INDEX IF NOT EXISTS "tasks_status_idx" ON "tasks" ("status");
@@ -1,48 +0,0 @@
1
- var { BaseModel } = require('@xeplr/db');
2
-
3
- /**
4
- * A task. The sample model — deliberately small, and deliberately showing the
5
- * three things almost every real model needs: required fields, a value
6
- * restricted to a known set, and a date.
7
- *
8
- * `jsonSchema` is not decoration. It is checked on every insert and update, so
9
- * a bad value is refused by the API with a message naming the field, rather
10
- * than reaching the database and failing as a constraint error nobody can read.
11
- */
12
- class Task extends BaseModel {
13
- static get tableName() { return 'tasks'; }
14
- static get idColumn() { return 'id'; }
15
-
16
- // Rows are NOT scoped to a company/workspace here, because this app has not
17
- // declared any tenancy levels. Leave it as the default (true) and add
18
- // registerMTs() later, and every row written before that has no owner.
19
- static get multiTenant() { return false; }
20
-
21
- static get jsonSchema() {
22
- return {
23
- type: 'object',
24
- required: ['title'],
25
- properties: {
26
- // 25 characters, because that is what the framework's own id generator
27
- // produces (see genericController — ids are minted there, not here).
28
- id: { type: 'string', maxLength: 25 },
29
- title: { type: 'string', minLength: 1, maxLength: 200 },
30
- description: { type: ['string', 'null'], maxLength: 1000 },
31
- status: { type: 'string', enum: ['todo', 'in_progress', 'done'] },
32
- dueDate: { type: ['string', 'null'] },
33
- isActive: { type: 'boolean' },
34
- recordCreatedDate: { type: ['string', 'null'] },
35
- recordModifiedDate: { type: ['string', 'null'] },
36
- recordCreatedBy: { type: ['string', 'null'], maxLength: 25 },
37
- recordModifiedBy: { type: ['string', 'null'], maxLength: 25 }
38
- }
39
- };
40
- }
41
-
42
- $beforeInsert() {
43
- super.$beforeInsert(); // keeps recordCreatedDate / isActive handling
44
- if (!this.status) this.status = 'todo';
45
- }
46
- }
47
-
48
- module.exports = Task;
@@ -1,34 +0,0 @@
1
- // MODEL LAYER — plain functions, no React.
2
- //
3
- // Kept apart from the screen on purpose: this file could be called from a
4
- // test, a script, or a different design entirely. `authFetch` attaches the
5
- // signed-in user's token and returns the PARSED body, throwing on failure.
6
- import { authFetch } from '@xeplr/ui-account'
7
-
8
- export async function listTasks() {
9
- const res = await authFetch('/api/tasks')
10
- return res.dataArray || []
11
- }
12
-
13
- /**
14
- * Create and update in one call.
15
- *
16
- * The API takes a CHANGESET — an array where each entry is judged by what it
17
- * carries: no id means create, an id means update, an id plus `deleted: true`
18
- * means remove. One request can do all three, and the whole array is applied
19
- * in a single transaction, so a later failure undoes the earlier entries
20
- * rather than leaving half of them written.
21
- */
22
- export function saveTasks(changeset) {
23
- return authFetch('/api/tasks/save', {
24
- method: 'POST',
25
- body: JSON.stringify(changeset)
26
- })
27
- }
28
-
29
- export function deleteTasks(ids) {
30
- return authFetch('/api/tasks/delete', {
31
- method: 'POST',
32
- body: JSON.stringify({ ids })
33
- })
34
- }
@@ -1,83 +0,0 @@
1
- // CONTROLLER LAYER — state and handlers, no JSX.
2
- //
3
- // The same split the @xeplr/ui-* libraries use. A different design can import
4
- // this hook and render the fields however it likes; the behaviour below is
5
- // unchanged by that, which is what makes a rebrand safe.
6
- import { useCallback, useEffect, useState } from 'react'
7
- import { listTasks, saveTasks, deleteTasks } from './api/tasks.js'
8
-
9
- const EMPTY = { title: '', description: '', status: 'todo', dueDate: '' }
10
-
11
- export function useTasksController() {
12
- const [tasks, setTasks] = useState([])
13
- const [form, setForm] = useState(EMPTY)
14
- const [editingId, setEditingId] = useState(null)
15
- const [busy, setBusy] = useState(false)
16
- const [error, setError] = useState(null)
17
-
18
- const refresh = useCallback(async () => {
19
- try {
20
- setTasks(await listTasks())
21
- setError(null)
22
- } catch (e) {
23
- setError(e.message)
24
- }
25
- }, [])
26
-
27
- useEffect(() => { refresh() }, [refresh])
28
-
29
- const setField = useCallback((name, value) => {
30
- setForm((f) => ({ ...f, [name]: value }))
31
- }, [])
32
-
33
- const edit = useCallback((task) => {
34
- setEditingId(task.id)
35
- setForm({
36
- title: task.title || '',
37
- description: task.description || '',
38
- status: task.status || 'todo',
39
- // The API returns a full timestamp; a date input wants just the day.
40
- dueDate: task.dueDate ? String(task.dueDate).slice(0, 10) : ''
41
- })
42
- }, [])
43
-
44
- const cancel = useCallback(() => {
45
- setEditingId(null)
46
- setForm(EMPTY)
47
- }, [])
48
-
49
- const submit = useCallback(async (e) => {
50
- if (e) e.preventDefault()
51
- setBusy(true)
52
- try {
53
- // An entry with no id is a create; with one, an update. Same call.
54
- const entry = { ...form, dueDate: form.dueDate || null }
55
- if (editingId) entry.id = editingId
56
- await saveTasks([entry])
57
- cancel()
58
- await refresh()
59
- setError(null)
60
- } catch (e2) {
61
- // The API refuses on the model's own jsonSchema and names the field, so
62
- // showing its message beats inventing our own.
63
- setError(e2.message)
64
- } finally {
65
- setBusy(false)
66
- }
67
- }, [form, editingId, cancel, refresh])
68
-
69
- const remove = useCallback(async (id) => {
70
- setBusy(true)
71
- try {
72
- await deleteTasks([id])
73
- if (editingId === id) cancel()
74
- await refresh()
75
- } catch (e) {
76
- setError(e.message)
77
- } finally {
78
- setBusy(false)
79
- }
80
- }, [editingId, cancel, refresh])
81
-
82
- return { tasks, form, setField, submit, edit, cancel, remove, editingId, busy, error, refresh }
83
- }