@enbox/create-dapp 0.0.3 → 0.0.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.
- package/package.json +1 -1
- package/template/README.md +18 -9
- package/template/package.json +2 -2
- package/template/src/app/application.ts +14 -3
- package/template/src/features/tasks/components/TaskBoard.tsx +43 -37
- package/template/src/features/tasks/store.ts +5 -8
- package/template/src/main.tsx +1 -1
- package/template/src/app/runtime.ts +0 -17
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -47,12 +47,11 @@ The application follows the same feature-store structure as notesd:
|
|
|
47
47
|
```text
|
|
48
48
|
src/
|
|
49
49
|
app/
|
|
50
|
-
application.ts #
|
|
51
|
-
runtime.ts # one browser-lifetime application owner
|
|
50
|
+
application.ts # features, wallet identity, and one application owner
|
|
52
51
|
App.tsx # connection shell and feature composition
|
|
53
52
|
features/tasks/
|
|
54
53
|
protocol.ts # typed encrypted records and validation
|
|
55
|
-
store.ts # shared
|
|
54
|
+
store.ts # shared observations, commands, and domain defaults
|
|
56
55
|
components/ # task board and its styles
|
|
57
56
|
shared/components/
|
|
58
57
|
ProfileCard.tsx
|
|
@@ -60,8 +59,9 @@ src/
|
|
|
60
59
|
sw.ts
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
`
|
|
64
|
-
|
|
62
|
+
`createApplication` derives the protocol manifest from registered stores and
|
|
63
|
+
constructs the browser connection with the configured wallet identity.
|
|
64
|
+
`getApplication()` keeps one headless runtime for the browser lifetime.
|
|
65
65
|
`main.tsx` waits for worker control before passing that runtime to
|
|
66
66
|
`EnboxProvider application={...}`. The provider initializes the borrowed runtime;
|
|
67
67
|
the application owns its lifetime. Development module replacement disposes it.
|
|
@@ -77,13 +77,18 @@ The replaceable task feature lives in `src/features/tasks`:
|
|
|
77
77
|
- `protocol.ts` declares encrypted `project` and `project/task` records with
|
|
78
78
|
payload validators. Tasks store a title and completion state.
|
|
79
79
|
- `store.ts` declares one shared project collection and a task collection scoped
|
|
80
|
-
with `tasks.within(projectContextId)`.
|
|
81
|
-
|
|
80
|
+
with `tasks.within(projectContextId)`. Its `activeTasks` observation keeps
|
|
81
|
+
the chosen project's query live through `activeTasks.select`; parent metadata and
|
|
82
|
+
rows arrive together. Fixed and selected observations use the same SDK
|
|
83
|
+
implementation. Creation and rename helpers reuse each collection's commands and their
|
|
84
|
+
shared pending/error state; observed state includes pagination progress.
|
|
82
85
|
- `components/TaskBoard.tsx` resolves the feature with `useFeature(TaskBoardStore)`
|
|
83
|
-
and reads
|
|
86
|
+
and reads observed data or commands with `useStore`. Task details reuse loaded list
|
|
84
87
|
rows and acquire a bounded single-record observation for a selection outside
|
|
85
|
-
the retained window. Input drafts and
|
|
88
|
+
the retained window. Input drafts and task-detail selection remain in React.
|
|
86
89
|
Awaited handlers check command authorization before applying their UI result.
|
|
90
|
+
Reopened task details also use selection identity to reject an older panel’s
|
|
91
|
+
completion.
|
|
87
92
|
- `components/tasks.css` styles the task feature. Shared app and profile styles
|
|
88
93
|
live in `src/style.css`.
|
|
89
94
|
|
|
@@ -102,6 +107,10 @@ try {
|
|
|
102
107
|
|
|
103
108
|
Handles belong to their authorization session. Resolve a fresh feature when
|
|
104
109
|
that session changes. React's `useFeature` handles that replacement automatically.
|
|
110
|
+
`activeTasks` owns its selected query until another project, null, or session
|
|
111
|
+
retirement. Other features can declare observations with changing inputs through
|
|
112
|
+
`observe({ source: (parent: string) => tasks.within(parent) })`;
|
|
113
|
+
commands remain on canonical `tasks.within(parentContextId)` handles.
|
|
105
114
|
|
|
106
115
|
To replace the task feature, register your own store in `app/application.ts`,
|
|
107
116
|
delete `src/features/tasks`, and change the feature rendered by `app/App.tsx`.
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createApplication, type BrowserApplicationOptions } from '@enbox/browser';
|
|
2
2
|
|
|
3
3
|
import app from '../app.json' with { type: 'json' };
|
|
4
4
|
import { TaskBoardStore } from '../features/tasks/store.js';
|
|
5
5
|
|
|
6
|
-
export const
|
|
7
|
-
|
|
6
|
+
export const applicationOptions = {
|
|
7
|
+
stores: { tasks: TaskBoardStore },
|
|
8
|
+
wallet: { appName: app.name, applicationId: app.id },
|
|
9
|
+
} satisfies BrowserApplicationOptions;
|
|
10
|
+
|
|
11
|
+
let application: ReturnType<typeof createApplication> | undefined;
|
|
12
|
+
|
|
13
|
+
/** One browser-lifetime owner shared by React and application controllers. */
|
|
14
|
+
export function getApplication() {
|
|
15
|
+
return application ??= createApplication(applicationOptions);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (import.meta.hot) import.meta.hot.dispose(() => { void application?.dispose(); });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StoreInstance } from '@enbox/browser';
|
|
1
2
|
import { useFeature, useStore } from '@enbox/react';
|
|
2
3
|
import { useState } from 'react';
|
|
3
4
|
import type { FormEvent } from 'react';
|
|
@@ -5,12 +6,14 @@ import type { FormEvent } from 'react';
|
|
|
5
6
|
import { TaskBoardStore } from '../store.js';
|
|
6
7
|
import './tasks.css';
|
|
7
8
|
|
|
9
|
+
type TaskState = ReturnType<StoreInstance<typeof TaskBoardStore>['activeTasks']['getSnapshot']>;
|
|
10
|
+
|
|
8
11
|
export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
|
|
9
12
|
const board = useFeature(TaskBoardStore);
|
|
10
13
|
const projects = useStore(board.projects);
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
+
const activeTasks = useStore(board.activeTasks);
|
|
15
|
+
const creation = useStore(board.projects.create);
|
|
16
|
+
const projectContextId = activeTasks.selection;
|
|
14
17
|
const [name, setName] = useState('');
|
|
15
18
|
const creating = creation.pendingCount > 0;
|
|
16
19
|
const prompt = projects.status === 'loading' ? 'Loading projects…'
|
|
@@ -19,8 +22,11 @@ export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
|
|
|
19
22
|
async function addProject(event: FormEvent) {
|
|
20
23
|
event.preventDefault();
|
|
21
24
|
try {
|
|
22
|
-
const record = await board.createProject
|
|
23
|
-
if (board.
|
|
25
|
+
const record = await board.createProject(name);
|
|
26
|
+
if (board.projects.create.isCurrent()) {
|
|
27
|
+
setName('');
|
|
28
|
+
await board.activeTasks.select(record.contextId);
|
|
29
|
+
}
|
|
24
30
|
} catch { /* The command exposes the error below. */ }
|
|
25
31
|
}
|
|
26
32
|
return (
|
|
@@ -38,28 +44,28 @@ export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
|
|
|
38
44
|
{creation.error && <p role="alert">Couldn’t create the project: {creation.error.message}</p>}
|
|
39
45
|
{projects.status === 'error' && <div role="alert">
|
|
40
46
|
<p>Couldn’t load projects: {projects.error.message}</p>
|
|
41
|
-
<button type="button" disabled={disabled} onClick={() => { void board.
|
|
47
|
+
<button type="button" disabled={disabled} onClick={() => { void board.projects.retry().catch(() => {}); }}>Try again</button>
|
|
42
48
|
</div>}
|
|
43
49
|
<div className="project-picker" role="group" aria-label="Projects">
|
|
44
50
|
{projects.records.map(({ record, value }) => <button type="button" className="button button-quiet" key={record.id} disabled={disabled}
|
|
45
|
-
aria-pressed={record.contextId === projectContextId} onClick={() =>
|
|
51
|
+
aria-pressed={record.contextId === projectContextId} onClick={() => { void board.activeTasks.select(record.contextId).catch(() => {}); }}>{value.name}</button>)}
|
|
46
52
|
</div>
|
|
47
|
-
{projects.hasMore && <button type="button" disabled={disabled ||
|
|
48
|
-
onClick={() => { void board.
|
|
49
|
-
{projectContextId !==
|
|
50
|
-
? <ProjectTasks key={projectContextId} within={projectContextId} disabled={disabled} />
|
|
53
|
+
{projects.hasMore && <button type="button" disabled={disabled || projects.isLoadingMore}
|
|
54
|
+
onClick={() => { void board.projects.loadMore().catch(() => {}); }}>More projects</button>}
|
|
55
|
+
{projectContextId !== null
|
|
56
|
+
? <ProjectTasks key={projectContextId} within={projectContextId} tasks={activeTasks} disabled={disabled} />
|
|
51
57
|
: <p role="status">{prompt}</p>}
|
|
52
58
|
</section>
|
|
53
59
|
);
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function ProjectTasks({ within, disabled }: {
|
|
62
|
+
function ProjectTasks({ within, tasks, disabled }: {
|
|
63
|
+
within: string; tasks: TaskState; disabled: boolean;
|
|
64
|
+
}) {
|
|
57
65
|
const board = useFeature(TaskBoardStore);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const updates = useStore(resource.patch);
|
|
62
|
-
const paging = useStore(board.moreTasks);
|
|
66
|
+
const taskCollection = board.tasks.within(within);
|
|
67
|
+
const creation = useStore(taskCollection.create);
|
|
68
|
+
const updates = useStore(taskCollection.patch);
|
|
63
69
|
const [title, setTitle] = useState('');
|
|
64
70
|
// Reopening the same record creates a new UI target for completion guards.
|
|
65
71
|
const [selection, setSelection] = useState<{ id: string }>();
|
|
@@ -68,7 +74,7 @@ function ProjectTasks({ within, disabled }: { within: string; disabled: boolean
|
|
|
68
74
|
event.preventDefault();
|
|
69
75
|
try {
|
|
70
76
|
const record = await board.createTask({ within, title });
|
|
71
|
-
if (
|
|
77
|
+
if (taskCollection.create.isCurrent()) { setSelection({ id: record.id }); setTitle(''); }
|
|
72
78
|
} catch { /* The scoped create command exposes the error below. */ }
|
|
73
79
|
}
|
|
74
80
|
return <div className="project-content">
|
|
@@ -82,18 +88,18 @@ function ProjectTasks({ within, disabled }: { within: string; disabled: boolean
|
|
|
82
88
|
{creation.error && <p role="alert">Couldn’t add the task: {creation.error.message}</p>}
|
|
83
89
|
{updates.error && <p role="alert">Couldn’t update the task: {updates.error.message}</p>}
|
|
84
90
|
{tasks.status === 'error' && <p role="alert">Couldn’t load tasks. <button type="button"
|
|
85
|
-
disabled={disabled} onClick={() => { void
|
|
91
|
+
disabled={disabled} onClick={() => { void taskCollection.retry().catch(() => {}); }}>Retry tasks</button></p>}
|
|
86
92
|
<ul className="task-list">
|
|
87
|
-
{tasks.records.map(({ record, value }) => <li key={record.id} data-completed={value.completed}>
|
|
93
|
+
{(tasks.records ?? []).map(({ record, value }) => <li key={record.id} data-completed={value.completed}>
|
|
88
94
|
<input type="checkbox" aria-label={`Complete ${value.title}`} checked={value.completed}
|
|
89
|
-
disabled={disabled || updates.
|
|
90
|
-
onChange={(event) => { void
|
|
95
|
+
disabled={disabled || updates.pendingInputs.some((input) => input.id === record.id)}
|
|
96
|
+
onChange={(event) => { void taskCollection.patch.run({ id: record.id, patch: { completed: event.currentTarget.checked } }).catch(() => {}); }} />
|
|
91
97
|
<button type="button" className="button button-quiet" aria-label={`Open task ${value.title}`}
|
|
92
98
|
disabled={disabled} aria-pressed={record.id === selection?.id} onClick={() => setSelection({ id: record.id })}>{value.title}</button>
|
|
93
99
|
</li>)}
|
|
94
100
|
</ul>
|
|
95
|
-
{tasks.hasMore && <button type="button" disabled={disabled ||
|
|
96
|
-
onClick={() => { void
|
|
101
|
+
{tasks.hasMore && <button type="button" disabled={disabled || tasks.isLoadingMore}
|
|
102
|
+
onClick={() => { void taskCollection.loadMore().catch(() => {}); }}>More tasks</button>}
|
|
97
103
|
{selection !== undefined
|
|
98
104
|
? <TaskDetails key={selection.id} within={within} recordId={selection.id} disabled={disabled}
|
|
99
105
|
onClose={() => setSelection((current) => current === selection ? undefined : current)} />
|
|
@@ -105,31 +111,32 @@ function TaskDetails({ within, recordId, disabled, onClose }: {
|
|
|
105
111
|
within: string; recordId: string; disabled: boolean; onClose: () => void;
|
|
106
112
|
}) {
|
|
107
113
|
const board = useFeature(TaskBoardStore);
|
|
108
|
-
const
|
|
109
|
-
const list = useStore(
|
|
114
|
+
const taskCollection = board.tasks.within(within);
|
|
115
|
+
const list = useStore(taskCollection);
|
|
110
116
|
const listed = list.records.some((row) => row.record.id === recordId);
|
|
111
117
|
// A bounded window cannot prove an unlisted record is missing. Reuse loaded
|
|
112
118
|
// rows, and acquire a single-record view only while that proof is unavailable.
|
|
113
119
|
const needsLookup = !listed && (list.status !== 'ready' || list.hasMore);
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
120
|
+
const lookup = needsLookup ? board.taskById(within, recordId) : null;
|
|
121
|
+
const details = useStore(lookup, { fallback: list });
|
|
122
|
+
const task = details.records.find((row) => row.record.id === recordId);
|
|
123
|
+
const status = details.status;
|
|
124
|
+
const updates = useStore(taskCollection.patch);
|
|
125
|
+
const deletion = useStore(taskCollection.delete);
|
|
119
126
|
const [draft, setDraft] = useState<string>();
|
|
120
|
-
const pending = updates.
|
|
127
|
+
const pending = updates.pendingInputs.some((input) => input.id === recordId) || deletion.pendingInputs.includes(recordId);
|
|
121
128
|
async function saveTitle(event: FormEvent) {
|
|
122
129
|
event.preventDefault();
|
|
123
130
|
if (draft === undefined) return;
|
|
124
131
|
try {
|
|
125
132
|
await board.renameTask({ within, id: recordId, title: draft });
|
|
126
|
-
if (
|
|
133
|
+
if (taskCollection.patch.isCurrent()) setDraft(undefined);
|
|
127
134
|
} catch { /* The scoped patch command exposes the error below. */ }
|
|
128
135
|
}
|
|
129
136
|
async function deleteTask() {
|
|
130
137
|
try {
|
|
131
|
-
await
|
|
132
|
-
if (
|
|
138
|
+
await taskCollection.delete.run(recordId);
|
|
139
|
+
if (taskCollection.delete.isCurrent()) onClose();
|
|
133
140
|
} catch { /* The scoped delete command exposes the error below. */ }
|
|
134
141
|
}
|
|
135
142
|
const data = task?.value;
|
|
@@ -137,8 +144,7 @@ function TaskDetails({ within, recordId, disabled, onClose }: {
|
|
|
137
144
|
<h3>Task details</h3>
|
|
138
145
|
{status === 'error' && <p role="alert">Couldn’t load the task. <button type="button"
|
|
139
146
|
disabled={disabled} onClick={() => {
|
|
140
|
-
|
|
141
|
-
else void board.retryTasks.run(within);
|
|
147
|
+
void (lookup ?? taskCollection).retry().catch(() => {});
|
|
142
148
|
}}>Retry task</button></p>}
|
|
143
149
|
{data === undefined && status === 'loading' && <p role="status">Loading task…</p>}
|
|
144
150
|
{data === undefined && status === 'ready' && <p role="status">This task is no longer available.</p>}
|
|
@@ -2,12 +2,13 @@ import { DateSort, defineStore } from '@enbox/browser';
|
|
|
2
2
|
|
|
3
3
|
import { TaskBoardProtocol } from './protocol.js';
|
|
4
4
|
|
|
5
|
-
/** Protocol
|
|
5
|
+
/** Protocol observations and domain defaults are shared with every consumer. */
|
|
6
6
|
export const TaskBoardStore = defineStore({
|
|
7
7
|
protocols: { board: TaskBoardProtocol },
|
|
8
|
-
create({ protocols,
|
|
8
|
+
create({ protocols, observe }) {
|
|
9
9
|
const projects = protocols.board.collection('project', { dateSort: DateSort.CreatedDescending });
|
|
10
10
|
const tasks = protocols.board.collection('project/task');
|
|
11
|
+
const activeTasks = observe({ source: (parent: string) => tasks.within(parent) });
|
|
11
12
|
const taskById = (within: string, recordId: string) => protocols.board.collection('project/task', {
|
|
12
13
|
filter: { recordId }, pagination: { limit: 1 },
|
|
13
14
|
}).within(within);
|
|
@@ -15,17 +16,13 @@ export const TaskBoardStore = defineStore({
|
|
|
15
16
|
return {
|
|
16
17
|
projects,
|
|
17
18
|
tasks,
|
|
19
|
+
activeTasks,
|
|
18
20
|
taskById,
|
|
19
|
-
createProject:
|
|
21
|
+
createProject: (name: string) => projects.create.run({ name: name.trim() }),
|
|
20
22
|
createTask: ({ within, title }: { within: string; title: string }) =>
|
|
21
23
|
tasks.within(within).create.run({ title: title.trim(), completed: false }),
|
|
22
24
|
renameTask: ({ within, id, title }: { within: string; id: string; title: string }) =>
|
|
23
25
|
tasks.within(within).patch.run({ id, patch: { title: title.trim() } }),
|
|
24
|
-
moreProjects: command(() => projects.loadMore()),
|
|
25
|
-
moreTasks: command((within: string) => tasks.within(within).loadMore()),
|
|
26
|
-
retryProjects: command(() => projects.retry()),
|
|
27
|
-
retryTasks: command((within: string) => tasks.within(within).retry()),
|
|
28
|
-
retryTask: command(({ within, recordId }: { within: string; recordId: string }) => taskById(within, recordId).retry()),
|
|
29
26
|
};
|
|
30
27
|
},
|
|
31
28
|
});
|
package/template/src/main.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { StrictMode } from 'react';
|
|
|
4
4
|
import { createRoot } from 'react-dom/client';
|
|
5
5
|
|
|
6
6
|
import { App } from './app/App.js';
|
|
7
|
-
import { getApplication } from './app/
|
|
7
|
+
import { getApplication } from './app/application.js';
|
|
8
8
|
import workerPath from './sw.ts?worker&url';
|
|
9
9
|
import './style.css';
|
|
10
10
|
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { BrowserConnectHandler, createApplication, createConnectionStore } from '@enbox/browser';
|
|
2
|
-
|
|
3
|
-
import { applicationDefinition, wallet } from './application.js';
|
|
4
|
-
|
|
5
|
-
let runtime: ReturnType<typeof createApplication> | undefined;
|
|
6
|
-
|
|
7
|
-
/** One browser-lifetime owner shared by React and application controllers. */
|
|
8
|
-
export function getApplication() {
|
|
9
|
-
return runtime ??= createApplication(applicationDefinition, {
|
|
10
|
-
connection: createConnectionStore({
|
|
11
|
-
application: applicationDefinition.manifest,
|
|
12
|
-
connectHandler: BrowserConnectHandler(wallet),
|
|
13
|
-
}),
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (import.meta.hot) import.meta.hot.dispose(() => { void runtime?.dispose(); });
|