@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/create-dapp",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Create a minimal Enbox React application",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 # feature registration and wallet identity
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 resources, commands, and domain defaults
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
- `defineApplication` derives the protocol manifest from registered stores.
64
- `getApplication()` creates one headless runtime over a native connection.
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)`. Creation and rename defaults live here.
81
- Native resource commands share pending/error state across consumers.
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 resources or commands with `useStore`. Task details reuse loaded list
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 UI selection remain in React.
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`.
@@ -10,8 +10,8 @@
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
- "@enbox/browser": "0.3.83",
14
- "@enbox/react": "0.0.8",
13
+ "@enbox/browser": "0.3.84",
14
+ "@enbox/react": "0.0.9",
15
15
  "react": "19.3.0",
16
16
  "react-dom": "19.3.0"
17
17
  },
@@ -1,7 +1,18 @@
1
- import { defineApplication } from '@enbox/browser';
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 applicationDefinition = defineApplication({ stores: { tasks: TaskBoardStore } });
7
- export const wallet = { appName: app.name, applicationId: app.id };
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 creation = useStore(board.createProject);
12
- const paging = useStore(board.moreProjects);
13
- const [projectContextId, setProjectContextId] = useState<string>();
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.run(name);
23
- if (board.createProject.isCurrent()) { setProjectContextId(record.contextId); setName(''); }
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.retryProjects.run(); }}>Try again</button>
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={() => setProjectContextId(record.contextId)}>{value.name}</button>)}
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 || paging.pendingCount > 0}
48
- onClick={() => { void board.moreProjects.run(); }}>More projects</button>}
49
- {projectContextId !== undefined
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 }: { within: string; disabled: boolean }) {
62
+ function ProjectTasks({ within, tasks, disabled }: {
63
+ within: string; tasks: TaskState; disabled: boolean;
64
+ }) {
57
65
  const board = useFeature(TaskBoardStore);
58
- const resource = board.tasks.within(within);
59
- const tasks = useStore(resource);
60
- const creation = useStore(resource.create);
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 (resource.create.isCurrent()) { setSelection({ id: record.id }); setTitle(''); }
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 board.retryTasks.run(within); }}>Retry tasks</button></p>}
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.pendingVariables.some((input) => input.id === record.id)}
90
- onChange={(event) => { void resource.patch.run({ id: record.id, patch: { completed: event.currentTarget.checked } }); }} />
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 || paging.pendingVariables.includes(within)}
96
- onClick={() => { void board.moreTasks.run(within); }}>More tasks</button>}
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 resource = board.tasks.within(within);
109
- const list = useStore(resource);
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 selection = useStore(needsLookup ? board.taskById(within, recordId) : resource);
115
- const task = selection.records.find((row) => row.record.id === recordId);
116
- const status = selection.status;
117
- const updates = useStore(resource.patch);
118
- const deletion = useStore(resource.delete);
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.pendingVariables.some((input) => input.id === recordId) || deletion.pendingVariables.includes(recordId);
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 (resource.patch.isCurrent()) setDraft(undefined);
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 resource.delete.run(recordId);
132
- if (resource.delete.isCurrent()) onClose();
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
- if (needsLookup) void board.retryTask.run({ within, recordId });
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 resources and domain defaults are shared with every consumer. */
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, command }) {
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: command((name: string) => projects.create.run({ name: name.trim() })),
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
  });
@@ -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/runtime.js';
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(); });