@loadbare/app 0.4.0 → 0.5.1
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 +53 -82
- package/dist/build/assemble.d.ts +7 -5
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +29 -9
- package/dist/build/cli.d.ts +20 -12
- package/dist/build/cli.d.ts.map +1 -1
- package/dist/build/cli.js +34 -16
- package/dist/build/elements.d.ts +15 -29
- package/dist/build/elements.d.ts.map +1 -1
- package/dist/build/elements.js +25 -111
- package/dist/build/expand.d.ts +1 -1
- package/dist/build/expand.js +1 -1
- package/dist/build/format.d.ts +6 -3
- package/dist/build/format.d.ts.map +1 -1
- package/dist/build/format.js +6 -3
- package/dist/build/locations.d.ts +14 -37
- package/dist/build/locations.d.ts.map +1 -1
- package/dist/build/locations.js +31 -69
- package/dist/build/origins.d.ts +109 -0
- package/dist/build/origins.d.ts.map +1 -0
- package/dist/build/origins.js +270 -0
- package/dist/core/lb-constants.d.ts +1 -0
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +15 -8
- package/dist/core/lb-types.d.ts +2 -2
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +1 -1
- package/dist/hub/lb-hub.d.ts.map +1 -1
- package/dist/hub/lb-hub.js +44 -17
- package/dist/hub/lb-rows.d.ts.map +1 -1
- package/dist/hub/lb-rows.js +3 -3
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-server.d.ts +5 -4
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/tests/assemble.test.js +11 -4
- package/dist/tests/elements.test.js +47 -51
- package/dist/tests/expand.test.d.ts +1 -1
- package/dist/tests/expand.test.js +2 -2
- package/dist/tests/fixtures/elements/collision/imports.d.ts +3 -0
- package/dist/tests/fixtures/elements/collision/imports.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/collision/imports.js +1 -0
- package/dist/tests/fixtures/elements/manifest/imports.d.ts +3 -0
- package/dist/tests/fixtures/elements/manifest/imports.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest/imports.js +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts +3 -0
- package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-entry/imports.js +1 -0
- package/dist/tests/fixtures/elements/{collision/elements.d.ts → manifest-not-array/imports.d.ts} +1 -1
- package/dist/tests/fixtures/elements/manifest-not-array/imports.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-not-array/imports.js +1 -0
- package/dist/tests/fixtures/elements/pkg/acme-widget.d.ts +2 -0
- package/dist/tests/fixtures/elements/pkg/acme-widget.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/pkg/acme-widget.js +1 -0
- package/dist/tests/lb-express.test.js +1 -1
- package/dist/tests/origins.test.d.ts +10 -0
- package/dist/tests/origins.test.d.ts.map +1 -0
- package/dist/tests/origins.test.js +326 -0
- package/dist/tests/pages.test.js +3 -3
- package/dist/tests/styles.test.js +7 -4
- package/docs/reference/builder.md +128 -0
- package/docs/reference/chrome.md +75 -0
- package/docs/reference/css.md +44 -0
- package/docs/reference/custom-elements.md +327 -0
- package/docs/reference/data-binding.md +240 -0
- package/docs/reference/overview.md +38 -0
- package/docs/reference/page-files.md +175 -0
- package/docs/reference/server.md +123 -0
- package/docs/reference/widgets.md +163 -0
- package/docs/roadmap.md +130 -0
- package/docs/testing.md +228 -0
- package/docs/theory.md +344 -223
- package/docs/tutorials/000-getting-started.md +86 -0
- package/docs/tutorials/010-pages-and-navigation.md +129 -0
- package/docs/tutorials/020-css.md +103 -0
- package/docs/tutorials/030-html-decomposition.md +79 -0
- package/docs/tutorials/040-displaying-data.md +169 -0
- package/docs/tutorials/050-actions.md +77 -0
- package/docs/tutorials/060-custom-element-code.md +73 -0
- package/docs/tutorials/065-conditional-rendering.md +161 -0
- package/docs/tutorials/070-displaying-a-list.md +137 -0
- package/docs/tutorials/072-inserting-into-a-list.md +88 -0
- package/docs/tutorials/074-deleting-from-a-list.md +77 -0
- package/docs/tutorials/076-updating-a-list-item.md +86 -0
- package/docs/tutorials/080-widget-requests.md +124 -0
- package/docs/tutorials/090-using-widget-libraries.md +75 -0
- package/package.json +10 -18
- package/dist/client.js +0 -522
- package/dist/demo-static/src/widgets/app-box.d.ts +0 -15
- package/dist/demo-static/src/widgets/app-box.d.ts.map +0 -1
- package/dist/demo-static/src/widgets/app-box.js +0 -19
- package/dist/tests/fixtures/elements/collision/elements.d.ts.map +0 -1
- package/dist/tests/fixtures/elements/collision/elements.js +0 -3
- package/dist/tests/fixtures/elements/manifest/elements.d.ts +0 -5
- package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +0 -1
- package/dist/tests/fixtures/elements/manifest/elements.js +0 -3
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +0 -5
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +0 -1
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +0 -3
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +0 -5
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +0 -1
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +0 -3
- package/dist/tests/golden.test.d.ts +0 -19
- package/dist/tests/golden.test.d.ts.map +0 -1
- package/dist/tests/golden.test.js +0 -60
- package/dist/tests/helpers/window.d.ts +0 -43
- package/dist/tests/helpers/window.d.ts.map +0 -1
- package/dist/tests/helpers/window.js +0 -78
- package/dist/tests/lb-input.test.d.ts +0 -9
- package/dist/tests/lb-input.test.d.ts.map +0 -1
- package/dist/tests/lb-input.test.js +0 -78
- package/dist/tests/lb-list.test.d.ts +0 -12
- package/dist/tests/lb-list.test.d.ts.map +0 -1
- package/dist/tests/lb-list.test.js +0 -44
- package/dist/tests/lb-options.test.d.ts +0 -10
- package/dist/tests/lb-options.test.d.ts.map +0 -1
- package/dist/tests/lb-options.test.js +0 -121
- package/dist/tests/lb-picker.test.d.ts +0 -14
- package/dist/tests/lb-picker.test.d.ts.map +0 -1
- package/dist/tests/lb-picker.test.js +0 -59
- package/dist/tests/lb-select.test.d.ts +0 -9
- package/dist/tests/lb-select.test.d.ts.map +0 -1
- package/dist/tests/lb-select.test.js +0 -71
- package/dist/tests/lb-table.test.d.ts +0 -15
- package/dist/tests/lb-table.test.d.ts.map +0 -1
- package/dist/tests/lb-table.test.js +0 -205
- package/dist/widgets/index.d.ts +0 -7
- package/dist/widgets/index.d.ts.map +0 -1
- package/dist/widgets/index.js +0 -6
- package/dist/widgets/lb-input.d.ts +0 -2
- package/dist/widgets/lb-input.d.ts.map +0 -1
- package/dist/widgets/lb-input.js +0 -48
- package/dist/widgets/lb-list.d.ts +0 -2
- package/dist/widgets/lb-list.d.ts.map +0 -1
- package/dist/widgets/lb-list.js +0 -17
- package/dist/widgets/lb-options.d.ts +0 -26
- package/dist/widgets/lb-options.d.ts.map +0 -1
- package/dist/widgets/lb-options.js +0 -72
- package/dist/widgets/lb-picker.d.ts +0 -2
- package/dist/widgets/lb-picker.d.ts.map +0 -1
- package/dist/widgets/lb-picker.js +0 -25
- package/dist/widgets/lb-select.d.ts +0 -2
- package/dist/widgets/lb-select.d.ts.map +0 -1
- package/dist/widgets/lb-select.js +0 -43
- package/dist/widgets/lb-table.d.ts +0 -2
- package/dist/widgets/lb-table.d.ts.map +0 -1
- package/dist/widgets/lb-table.js +0 -113
- package/docs/application-chrome.md +0 -36
- package/docs/building-html-pages.md +0 -130
- package/docs/getting-started.md +0 -120
- package/docs/guide.md +0 -1164
- package/docs/hosting.md +0 -218
- package/docs/latent-risks.md +0 -20
- package/widgets/index.ts +0 -6
- package/widgets/lb-input.html +0 -1
- package/widgets/lb-input.ts +0 -64
- package/widgets/lb-list.html +0 -1
- package/widgets/lb-list.ts +0 -21
- package/widgets/lb-options.html +0 -4
- package/widgets/lb-options.ts +0 -88
- package/widgets/lb-picker.html +0 -7
- package/widgets/lb-picker.ts +0 -27
- package/widgets/lb-select.html +0 -4
- package/widgets/lb-select.ts +0 -55
- package/widgets/lb-table.html +0 -8
- package/widgets/lb-table.ts +0 -126
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Custom Element Code
|
|
2
|
+
|
|
3
|
+
So far we have simple scalar data binding, and server-implemented
|
|
4
|
+
actions that can be requested from the server.
|
|
5
|
+
|
|
6
|
+
The natural next step is CRUD operations, but those will require
|
|
7
|
+
custom elements for list operations.
|
|
8
|
+
To set the stage for custom elements and list operations, we will
|
|
9
|
+
first implement a much simpler custom element to see how they work.
|
|
10
|
+
|
|
11
|
+
## Using the element
|
|
12
|
+
|
|
13
|
+
We will improve the visit counter to show either "1 time" or "x times".
|
|
14
|
+
The first step is to refer to a new custom element, which we will
|
|
15
|
+
then implement.
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<!-- src/pages/about.page.html -->
|
|
19
|
+
<div lb-query="visits">
|
|
20
|
+
<p>This page has been <visit-count lb-cell="count"></visit-count>.</p>
|
|
21
|
+
<button lb-action="resetVisits">Reset count</button>
|
|
22
|
+
</div>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
When we drop in a custom element, we bind the custom element
|
|
26
|
+
to the cell value `count` the same as we did for the span,
|
|
27
|
+
using `lb-cell="count"`.
|
|
28
|
+
|
|
29
|
+
## Writing the class
|
|
30
|
+
|
|
31
|
+
We put the Javascript class implementation into a file named
|
|
32
|
+
after the tag: `visit-count.ts`. The loadbare builder will recognize
|
|
33
|
+
the file as being associated with a custom element that was used
|
|
34
|
+
in the app, and add its class to `client.js`.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/visit-count.ts
|
|
39
|
+
import { ATTR_VALUE } from "@loadbare/app/constants";
|
|
40
|
+
|
|
41
|
+
class VisitCount extends HTMLElement {
|
|
42
|
+
static observedAttributes = [ATTR_VALUE];
|
|
43
|
+
|
|
44
|
+
attributeChangedCallback(_name: string, _old: string, value: string) {
|
|
45
|
+
const count = Number(value);
|
|
46
|
+
this.textContent = `visited ${count} ${count === 1 ? "time" : "times"}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
customElements.define("visit-count", VisitCount);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A Loadbare custom widget is just a custom element, we follow the standards
|
|
54
|
+
exactly, and we don't use Shadow DOM. For more specifics, see
|
|
55
|
+
[Custom Elements](../reference/custom-elements.md#code).
|
|
56
|
+
|
|
57
|
+
In brief, custom elements can name which attributes should trigger a callback
|
|
58
|
+
when their values change. Here we specify only one, the standard attribute
|
|
59
|
+
used for a cell's value, defined in constant `ATTR_VALUE`. When the
|
|
60
|
+
attribute changes, `attributeChangedCallback` fires and rewrites the text.
|
|
61
|
+
|
|
62
|
+
## Run it
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
npm run dev
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Open the About page. Where the plain number was, it now reads "visited 7
|
|
69
|
+
times" (or "visited 1 time" the first time).
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
Prev: [Actions](./050-actions.md)
|
|
73
|
+
Next: [Conditional Rendering](./065-conditional-rendering.md)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Conditional Rendering
|
|
2
|
+
|
|
3
|
+
Every widget so far has data to display, always. Real apps also need to
|
|
4
|
+
show or hide a whole chunk of markup depending on state — a wizard step,
|
|
5
|
+
a tab, a form that only appears once a checkbox is ticked. In a framework
|
|
6
|
+
that builds the DOM from a template, that's an `if` in the template.
|
|
7
|
+
|
|
8
|
+
Loadbare only ships static HTML, and hydrates elements that are already
|
|
9
|
+
there. We do not have an `if`, but we also do not need one. For
|
|
10
|
+
conditional rendering, all possibilities ship in the HTML, and visibility
|
|
11
|
+
is controlled by the custom element.
|
|
12
|
+
|
|
13
|
+
## Adding a wizard to the About page
|
|
14
|
+
|
|
15
|
+
We'll add a small three-step wizard, unrelated to the notes and visit
|
|
16
|
+
count already on the page. Every step is in the document from the start;
|
|
17
|
+
two of them carry the standard `hidden` attribute.
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<!-- src/pages/about.page.html -->
|
|
21
|
+
<h2>Sign up</h2>
|
|
22
|
+
<lb-wizard lb-query="signup" lb-cell="step">
|
|
23
|
+
<section data-step="name">
|
|
24
|
+
<h3>1. Name</h3>
|
|
25
|
+
<p>This step is not hidden, because it's the one the page ships showing.</p>
|
|
26
|
+
</section>
|
|
27
|
+
<section data-step="address" hidden>
|
|
28
|
+
<h3>2. Address</h3>
|
|
29
|
+
</section>
|
|
30
|
+
<section data-step="confirm" hidden>
|
|
31
|
+
<h3>3. Confirm</h3>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<button data-nav="back" lb-action="wizardBack" disabled>Back</button>
|
|
35
|
+
<button data-nav="next" lb-action="wizardNext">Next</button>
|
|
36
|
+
</lb-wizard>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`lb-cell="step"` on `<lb-wizard>` works exactly the way it did on
|
|
40
|
+
`<visit-count>` in [Custom Element Code](./060-custom-element-code.md):
|
|
41
|
+
because the tag has a hyphen, the value lands on the `lb-value` attribute
|
|
42
|
+
instead of `textContent`, and the class decides what to do with it. Back
|
|
43
|
+
and Next are plain buttons carrying `lb-action`, the same mechanism as the
|
|
44
|
+
Reset button in [Actions](./050-actions.md) — the wizard owns no
|
|
45
|
+
arithmetic of its own.
|
|
46
|
+
|
|
47
|
+
## Writing the class
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/lb-wizard.ts
|
|
51
|
+
import { ATTR_VALUE } from "@loadbare/app/constants";
|
|
52
|
+
|
|
53
|
+
class LbWizard extends HTMLElement {
|
|
54
|
+
static observedAttributes = [ATTR_VALUE];
|
|
55
|
+
|
|
56
|
+
attributeChangedCallback(_name: string, _old: string, value: string) {
|
|
57
|
+
const steps = [...this.querySelectorAll<HTMLElement>("[data-step]")];
|
|
58
|
+
const at = steps.findIndex((step) => step.dataset.step === value);
|
|
59
|
+
if (at === -1) return;
|
|
60
|
+
|
|
61
|
+
for (const step of steps) step.hidden = step.dataset.step !== value;
|
|
62
|
+
this.end("back", at === 0);
|
|
63
|
+
this.end("next", at === steps.length - 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private end(nav: string, disabled: boolean): void {
|
|
67
|
+
const button = this.querySelector<HTMLButtonElement>(`[data-nav="${nav}"]`);
|
|
68
|
+
if (button) button.disabled = disabled;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
customElements.define("lb-wizard", LbWizard);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The whole conditional is one line: `step.hidden = step.dataset.step !==
|
|
76
|
+
value`. Every step is walked on every value change, so the widget never
|
|
77
|
+
needs to remember which one was showing before — it just sets `hidden` on
|
|
78
|
+
all of them from the current value.
|
|
79
|
+
|
|
80
|
+
## Implementing it server-side
|
|
81
|
+
|
|
82
|
+
A multi-step signup form is exactly the kind of thing a user gets
|
|
83
|
+
interrupted out of when they close the tab, the browser crashes, they come
|
|
84
|
+
back an hour later. If `step` were just a private field on the `LbWizard`
|
|
85
|
+
instance, none of that would survive: a reload constructs a fresh element
|
|
86
|
+
with no memory of where the user was, and they'd have to start over from
|
|
87
|
+
Step 1. So the step lives on the server, the same way the visit count
|
|
88
|
+
does, and every reload asks for it again instead of assuming it.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// src/pages/about.queries.ts
|
|
92
|
+
export const queries = {
|
|
93
|
+
// ...visits and notes unchanged...
|
|
94
|
+
signup: async (ctx) => ({ step: await ctx.db.signupStep() }),
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// src/pages/about.hooks.ts
|
|
100
|
+
export const hooks = {
|
|
101
|
+
// ...beforeGet, resetVisits, and crud unchanged...
|
|
102
|
+
actions: {
|
|
103
|
+
resetVisits: { run: (ctx) => ctx.db.resetVisits(), refresh: ["visits"] },
|
|
104
|
+
wizardBack: { run: (ctx) => ctx.db.moveSignup(-1), refresh: ["signup"] },
|
|
105
|
+
wizardNext: { run: (ctx) => ctx.db.moveSignup(1), refresh: ["signup"] },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Back and Next both `refresh: ["signup"]` — a click sends the request, the
|
|
111
|
+
server computes and clamps the new step, and the answer comes back through
|
|
112
|
+
the normal query/cell path. The browser never shows a step the server
|
|
113
|
+
hasn't confirmed, the same rule the visit count follows for its number.
|
|
114
|
+
|
|
115
|
+
## Extending the database
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// src/database.ts
|
|
119
|
+
const STEPS = ["name", "address", "confirm"];
|
|
120
|
+
|
|
121
|
+
export function openDb() {
|
|
122
|
+
return {
|
|
123
|
+
// ...visitCount(), recordVisit(), resetVisits(), notes, etc. unchanged...
|
|
124
|
+
async signupStep() {
|
|
125
|
+
return (await read()).step ?? STEPS[0];
|
|
126
|
+
},
|
|
127
|
+
async moveSignup(delta) {
|
|
128
|
+
const current = await read();
|
|
129
|
+
const at = STEPS.indexOf(current.step ?? STEPS[0]);
|
|
130
|
+
const step = STEPS[Math.min(Math.max(at + delta, 0), STEPS.length - 1)];
|
|
131
|
+
await write({ step });
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Clamping happens here, not in the widget — the widget only ever displays
|
|
138
|
+
a step name it's given, it never computes one.
|
|
139
|
+
|
|
140
|
+
## Run it
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
npm run dev
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Open the About page. Step 1 is showing, Back is disabled. Click Next
|
|
147
|
+
twice — Step 3 shows and Next disables. Click Back — Step 2 reappears and
|
|
148
|
+
both buttons are enabled.
|
|
149
|
+
|
|
150
|
+
Now click Next once more so Step 3 is showing, and reload the page. Step
|
|
151
|
+
3 is still what's showing — not Step 1. If `step` had been client-only
|
|
152
|
+
state instead of a query result, the reload would have built a brand new
|
|
153
|
+
`LbWizard` with nothing to tell it otherwise, and it would have landed
|
|
154
|
+
back on Step 1 like the very first visit.
|
|
155
|
+
|
|
156
|
+
View source: all three `<section>`s are on the page the whole time. Only
|
|
157
|
+
their `hidden` attribute changes.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
Prev: [Custom Element Code](./060-custom-element-code.md)
|
|
161
|
+
Next: [Displaying a List](./070-displaying-a-list.md)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Displaying a List
|
|
2
|
+
|
|
3
|
+
Every query so far has answered with one tuple. Now we add a query that
|
|
4
|
+
answers with many rows, and a widget to show them: `<lb-list>`.
|
|
5
|
+
|
|
6
|
+
## Writing the query
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// src/pages/about.queries.ts
|
|
10
|
+
import { rows } from "@loadbare/app/server";
|
|
11
|
+
|
|
12
|
+
export const queries = {
|
|
13
|
+
visits: async (ctx) => ({ count: String(await ctx.db.visitCount()) }),
|
|
14
|
+
notes: async (ctx) => rows(await ctx.db.notes()),
|
|
15
|
+
};
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Loadbare assumes a query's results are always one tuple. We tell it that
|
|
19
|
+
we are returning a set of rows by wrapping the query in `rows()`.
|
|
20
|
+
|
|
21
|
+
## Extending the database
|
|
22
|
+
|
|
23
|
+
Now let's extend our bespoke json-based database with operations on a list
|
|
24
|
+
of notes.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/database.ts
|
|
28
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
29
|
+
|
|
30
|
+
const DATA_FILE = new URL("./.lb-data.json", import.meta.url);
|
|
31
|
+
|
|
32
|
+
async function read() {
|
|
33
|
+
try {
|
|
34
|
+
return { visitCount: 0, notes: [], ...JSON.parse(await readFile(DATA_FILE, "utf-8")) };
|
|
35
|
+
} catch {
|
|
36
|
+
return { visitCount: 0, notes: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function write(patch) {
|
|
41
|
+
const current = await read();
|
|
42
|
+
await writeFile(DATA_FILE, JSON.stringify({ ...current, ...patch }), "utf-8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function openDb() {
|
|
46
|
+
return {
|
|
47
|
+
async visitCount() {
|
|
48
|
+
return (await read()).visitCount;
|
|
49
|
+
},
|
|
50
|
+
async recordVisit() {
|
|
51
|
+
const current = await read();
|
|
52
|
+
await write({ visitCount: current.visitCount + 1 });
|
|
53
|
+
},
|
|
54
|
+
async resetVisits() {
|
|
55
|
+
await write({ visitCount: 0 });
|
|
56
|
+
},
|
|
57
|
+
async notes() {
|
|
58
|
+
return (await read()).notes;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`write` is now a merge, not a whole-file replace — otherwise saving
|
|
65
|
+
`notes` would erase `visitCount`.
|
|
66
|
+
|
|
67
|
+
## Installing the widget library
|
|
68
|
+
|
|
69
|
+
`<lb-list>` is the first widget we take from a library rather than write
|
|
70
|
+
ourselves. It ships in `@loadbare/widgets`:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
npm install @loadbare/widgets
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The builder scans `src/` on its own, but it will not go looking through
|
|
77
|
+
`node_modules` uninvited. List the package in `src/imports.ts`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/imports.ts
|
|
81
|
+
export default ["@loadbare/widgets"];
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That is the whole of it. List the package, not its tags — the builder finds
|
|
85
|
+
`<lb-list>` by filename, and bundles only the tags a page actually uses.
|
|
86
|
+
[Using Widget Libraries](./090-using-widget-libraries.md) covers the same
|
|
87
|
+
step for anyone else's package.
|
|
88
|
+
|
|
89
|
+
## A list display with a template
|
|
90
|
+
|
|
91
|
+
The `<lb-list>` widget will display a list, but when we use that widget
|
|
92
|
+
we need to tell it what a list item looks like. We do that by
|
|
93
|
+
putting a `<template>` inside of it. The widget itself contains a loop
|
|
94
|
+
to display one template per row, and invokes code in the hub `<lb-hub>` to
|
|
95
|
+
display bound data values.
|
|
96
|
+
|
|
97
|
+
> Loadbare does not use Shadow DOM because Shadow DOM is generally obtuse,
|
|
98
|
+
> interferes with CSS scoping, and makes templates difficult to supply at point
|
|
99
|
+
> of use. Shadow DOM also prevents `closest()` from reaching outside an
|
|
100
|
+
> element's shadow root, and `closest()` is fundamental
|
|
101
|
+
> to identifying the data-binding scope of elements.
|
|
102
|
+
>
|
|
103
|
+
> Rather than mess with the Shadow DOM, we use Light DOM.
|
|
104
|
+
> We take advantage of the fact that `<template>`
|
|
105
|
+
> elements are not rendered, so we drop the `<template>` directly
|
|
106
|
+
> into the light DOM, where it's easy to see how `<lb-list>` is
|
|
107
|
+
> going to render our list.
|
|
108
|
+
|
|
109
|
+
We know already that a DOM subtree can be scoped to a query with
|
|
110
|
+
`lb-query="X"`. Now we see the use of `lb-key="id"`, which further
|
|
111
|
+
scopes a subtree to a specific row within the query, naming the
|
|
112
|
+
primary key field that uniquely identifies the row.
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<!-- src/pages/about.page.html -->
|
|
116
|
+
<h2>Notes</h2>
|
|
117
|
+
<lb-list lb-query="notes">
|
|
118
|
+
<ul>
|
|
119
|
+
<template lb-key="id">
|
|
120
|
+
<li lb-cell="text"></li>
|
|
121
|
+
</template>
|
|
122
|
+
</ul>
|
|
123
|
+
</lb-list>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Run it
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
npm run dev
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Open the About page. `notes` is empty, so the list renders no `<li>` —
|
|
133
|
+
just an empty `<ul>`. Next up we will see how to add to the list.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
Prev: [Conditional Rendering](./065-conditional-rendering.md)
|
|
137
|
+
Next: [Inserting Into a List](./072-inserting-into-a-list.md)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Inserting Into a List
|
|
2
|
+
|
|
3
|
+
So far we have a read-only empty list of notes. Now we will add a form
|
|
4
|
+
that allows a user to add a note.
|
|
5
|
+
|
|
6
|
+
## Adding the form
|
|
7
|
+
|
|
8
|
+
```html
|
|
9
|
+
<!-- src/pages/about.page.html -->
|
|
10
|
+
<h2>Notes</h2>
|
|
11
|
+
<form lb-insert lb-query="notes">
|
|
12
|
+
<input lb-cell="text" placeholder="Write a note" />
|
|
13
|
+
<button type="submit">Add</button>
|
|
14
|
+
</form>
|
|
15
|
+
|
|
16
|
+
<lb-list lb-query="notes">
|
|
17
|
+
<ul>
|
|
18
|
+
<template lb-key="id">
|
|
19
|
+
<li lb-cell="text"></li>
|
|
20
|
+
</template>
|
|
21
|
+
</ul>
|
|
22
|
+
</lb-list>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`lb-insert` on a `<form>` gathers its `lb-cell`s into a values map and
|
|
26
|
+
submits them against `lb-query`. There's no `lb-key` — there's no row
|
|
27
|
+
yet.
|
|
28
|
+
|
|
29
|
+
## Implementing CRUD server-side
|
|
30
|
+
|
|
31
|
+
Our CRUD operations go into the page's hooks file:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/pages/about.hooks.ts
|
|
35
|
+
import { patch } from "@loadbare/app/server";
|
|
36
|
+
|
|
37
|
+
export const hooks = {
|
|
38
|
+
// ...beforeGet and actions unchanged...
|
|
39
|
+
crud: {
|
|
40
|
+
notes: {
|
|
41
|
+
tupleInsert: {
|
|
42
|
+
run: async (ctx, { values }) => {
|
|
43
|
+
const note = await ctx.db.addNote(values.text);
|
|
44
|
+
return { notes: patch({ rows: [note] }) };
|
|
45
|
+
},
|
|
46
|
+
refresh: [],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`tupleInsert` is a CRUD operation, declared under `crud` and keyed by
|
|
54
|
+
query name.
|
|
55
|
+
|
|
56
|
+
Notice that we do not use the `refresh` mechanism, as that would return
|
|
57
|
+
the entire query which would be wasteful. We instead return a `patch`
|
|
58
|
+
with a single new row and the `<lb-list>` element just adds the row.
|
|
59
|
+
|
|
60
|
+
## Extending the database
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// src/database.ts
|
|
64
|
+
export function openDb() {
|
|
65
|
+
return {
|
|
66
|
+
// ...visitCount(), recordVisit(), resetVisits(), notes() unchanged...
|
|
67
|
+
async addNote(text) {
|
|
68
|
+
const current = await read();
|
|
69
|
+
const note = { id: crypto.randomUUID(), text };
|
|
70
|
+
await write({ notes: [...current.notes, note] });
|
|
71
|
+
return note;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Run it
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
npm run dev
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Open the About page. Type a note, click Add — it appears in the list.
|
|
84
|
+
Reload — it's still there.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
Prev: [Displaying a List](./070-displaying-a-list.md)
|
|
88
|
+
Next: [Deleting From a List](./074-deleting-from-a-list.md)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Deleting From a List
|
|
2
|
+
|
|
3
|
+
Now that we can add notes, we need to be able to delete a note.
|
|
4
|
+
|
|
5
|
+
## Adding the button
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<!-- src/pages/about.page.html -->
|
|
9
|
+
<lb-list lb-query="notes">
|
|
10
|
+
<ul>
|
|
11
|
+
<template lb-key="id">
|
|
12
|
+
<li>
|
|
13
|
+
<span lb-cell="text"></span>
|
|
14
|
+
<button lb-delete>Delete</button>
|
|
15
|
+
</li>
|
|
16
|
+
</template>
|
|
17
|
+
</ul>
|
|
18
|
+
</lb-list>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The `<li>` now has two children, so the text moves onto its own `<span>`.
|
|
22
|
+
`lb-delete` needs no form — just the `lb-query` and `lb-key` already in
|
|
23
|
+
scope from its ancestors.
|
|
24
|
+
|
|
25
|
+
## Implementing delete server-side
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// src/pages/about.hooks.ts
|
|
29
|
+
import { patch } from "@loadbare/app/server";
|
|
30
|
+
|
|
31
|
+
export const hooks = {
|
|
32
|
+
// ...beforeGet and actions unchanged...
|
|
33
|
+
crud: {
|
|
34
|
+
notes: {
|
|
35
|
+
// ...tupleInsert unchanged...
|
|
36
|
+
tupleDelete: {
|
|
37
|
+
run: async (ctx, { key }) => {
|
|
38
|
+
await ctx.db.deleteNote(key);
|
|
39
|
+
return { notes: patch({ drop: [key] }) };
|
|
40
|
+
},
|
|
41
|
+
refresh: [],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`patch({ drop: [key] })` removes exactly that row and leaves every other
|
|
49
|
+
one alone.
|
|
50
|
+
|
|
51
|
+
## Extending the database
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// src/database.ts
|
|
55
|
+
export function openDb() {
|
|
56
|
+
return {
|
|
57
|
+
// ...visitCount(), recordVisit(), resetVisits(), notes(), addNote() unchanged...
|
|
58
|
+
async deleteNote(id) {
|
|
59
|
+
const current = await read();
|
|
60
|
+
await write({ notes: current.notes.filter((note) => note.id !== id) });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Run it
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
npm run dev
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add a couple of notes, then click Delete on one — it disappears, the
|
|
73
|
+
other stays put. Reload — it's still gone.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
Prev: [Inserting Into a List](./072-inserting-into-a-list.md)
|
|
77
|
+
Next: [Updating a List Item](./076-updating-a-list-item.md)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Updating a List Item
|
|
2
|
+
|
|
3
|
+
Now we edit a row in place, instead of removing and re-adding it.
|
|
4
|
+
|
|
5
|
+
## Adding the form
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<!-- src/pages/about.page.html -->
|
|
9
|
+
<lb-list lb-query="notes">
|
|
10
|
+
<ul>
|
|
11
|
+
<template lb-key="id">
|
|
12
|
+
<li>
|
|
13
|
+
<form lb-update>
|
|
14
|
+
<input lb-cell="text" />
|
|
15
|
+
<button type="submit">Save</button>
|
|
16
|
+
</form>
|
|
17
|
+
<button lb-delete>Delete</button>
|
|
18
|
+
</li>
|
|
19
|
+
</template>
|
|
20
|
+
</ul>
|
|
21
|
+
</lb-list>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`lb-update` gathers its `lb-cell`s the same way `lb-insert` does, but
|
|
25
|
+
also reads `lb-key` from the row it's inside — the same ancestor
|
|
26
|
+
`lb-delete` already reads.
|
|
27
|
+
|
|
28
|
+
## Implementing update server-side
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// src/pages/about.hooks.ts
|
|
32
|
+
import { patch } from "@loadbare/app/server";
|
|
33
|
+
|
|
34
|
+
export const hooks = {
|
|
35
|
+
// ...beforeGet and actions unchanged...
|
|
36
|
+
crud: {
|
|
37
|
+
notes: {
|
|
38
|
+
// ...tupleInsert, tupleDelete unchanged...
|
|
39
|
+
tupleUpdate: {
|
|
40
|
+
run: async (ctx, { key, values }) => {
|
|
41
|
+
const note = await ctx.db.updateNote(key, values.text);
|
|
42
|
+
return { notes: patch({ rows: [note] }) };
|
|
43
|
+
},
|
|
44
|
+
refresh: [],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With more than one note, `key` is what says which row this request
|
|
52
|
+
means. `patch({ rows: [note] })` looks like insert's response, but since
|
|
53
|
+
this id already exists, `<lb-list>` updates that row instead of adding
|
|
54
|
+
one.
|
|
55
|
+
|
|
56
|
+
## Extending the database
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// src/database.ts
|
|
60
|
+
export function openDb() {
|
|
61
|
+
return {
|
|
62
|
+
// ...visitCount(), recordVisit(), resetVisits(), notes(), addNote(), deleteNote() unchanged...
|
|
63
|
+
async updateNote(id, text) {
|
|
64
|
+
const current = await read();
|
|
65
|
+
const notes = current.notes.map((note) =>
|
|
66
|
+
note.id === id ? { ...note, text } : note,
|
|
67
|
+
);
|
|
68
|
+
await write({ notes });
|
|
69
|
+
return notes.find((note) => note.id === id);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Run it
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npm run dev
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Add a couple of notes, change one's text, click Save — that row updates,
|
|
82
|
+
the other doesn't move. Reload — the edit is still there.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
Prev: [Deleting From a List](./074-deleting-from-a-list.md)
|
|
86
|
+
Next: [Widget Requests](./080-widget-requests.md)
|