@loadbare/app 0.4.0 → 0.5.0

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 (160) hide show
  1. package/README.md +53 -82
  2. package/dist/build/assemble.d.ts +7 -5
  3. package/dist/build/assemble.d.ts.map +1 -1
  4. package/dist/build/assemble.js +29 -9
  5. package/dist/build/cli.d.ts +20 -12
  6. package/dist/build/cli.d.ts.map +1 -1
  7. package/dist/build/cli.js +34 -16
  8. package/dist/build/elements.d.ts +15 -29
  9. package/dist/build/elements.d.ts.map +1 -1
  10. package/dist/build/elements.js +25 -111
  11. package/dist/build/expand.d.ts +1 -1
  12. package/dist/build/expand.js +1 -1
  13. package/dist/build/locations.d.ts +14 -37
  14. package/dist/build/locations.d.ts.map +1 -1
  15. package/dist/build/locations.js +24 -67
  16. package/dist/build/origins.d.ts +109 -0
  17. package/dist/build/origins.d.ts.map +1 -0
  18. package/dist/build/origins.js +270 -0
  19. package/dist/core/lb-constants.d.ts +1 -0
  20. package/dist/core/lb-constants.d.ts.map +1 -1
  21. package/dist/core/lb-constants.js +15 -8
  22. package/dist/core/lb-types.d.ts +2 -2
  23. package/dist/core/lb-types.d.ts.map +1 -1
  24. package/dist/hub/lb-apply.js +1 -1
  25. package/dist/hub/lb-hub.d.ts.map +1 -1
  26. package/dist/hub/lb-hub.js +44 -17
  27. package/dist/hub/lb-rows.js +3 -3
  28. package/dist/server/lb-server.d.ts +5 -4
  29. package/dist/server/lb-server.d.ts.map +1 -1
  30. package/dist/tests/assemble.test.js +11 -4
  31. package/dist/tests/elements.test.js +47 -51
  32. package/dist/tests/expand.test.d.ts +1 -1
  33. package/dist/tests/expand.test.js +2 -2
  34. package/dist/tests/fixtures/elements/collision/imports.d.ts +3 -0
  35. package/dist/tests/fixtures/elements/collision/imports.d.ts.map +1 -0
  36. package/dist/tests/fixtures/elements/collision/imports.js +1 -0
  37. package/dist/tests/fixtures/elements/manifest/imports.d.ts +3 -0
  38. package/dist/tests/fixtures/elements/manifest/imports.d.ts.map +1 -0
  39. package/dist/tests/fixtures/elements/manifest/imports.js +1 -0
  40. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts +3 -0
  41. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts.map +1 -0
  42. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.js +1 -0
  43. package/dist/tests/fixtures/elements/{collision/elements.d.ts → manifest-not-array/imports.d.ts} +1 -1
  44. package/dist/tests/fixtures/elements/manifest-not-array/imports.d.ts.map +1 -0
  45. package/dist/tests/fixtures/elements/manifest-not-array/imports.js +1 -0
  46. package/dist/tests/fixtures/elements/pkg/acme-widget.d.ts +2 -0
  47. package/dist/tests/fixtures/elements/pkg/acme-widget.d.ts.map +1 -0
  48. package/dist/tests/fixtures/elements/pkg/acme-widget.js +1 -0
  49. package/dist/tests/lb-express.test.js +1 -1
  50. package/dist/tests/origins.test.d.ts +10 -0
  51. package/dist/tests/origins.test.d.ts.map +1 -0
  52. package/dist/tests/origins.test.js +326 -0
  53. package/dist/tests/pages.test.js +3 -3
  54. package/dist/tests/styles.test.js +7 -4
  55. package/docs/reference/builder.md +128 -0
  56. package/docs/reference/chrome.md +75 -0
  57. package/docs/reference/css.md +44 -0
  58. package/docs/reference/custom-elements.md +327 -0
  59. package/docs/reference/data-binding.md +240 -0
  60. package/docs/reference/overview.md +38 -0
  61. package/docs/reference/page-files.md +175 -0
  62. package/docs/reference/server.md +123 -0
  63. package/docs/reference/widgets.md +163 -0
  64. package/docs/roadmap.md +130 -0
  65. package/docs/testing.md +228 -0
  66. package/docs/theory.md +344 -223
  67. package/docs/tutorials/000-getting-started.md +86 -0
  68. package/docs/tutorials/010-pages-and-navigation.md +129 -0
  69. package/docs/tutorials/020-css.md +103 -0
  70. package/docs/tutorials/030-html-decomposition.md +79 -0
  71. package/docs/tutorials/040-displaying-data.md +169 -0
  72. package/docs/tutorials/050-actions.md +77 -0
  73. package/docs/tutorials/060-custom-element-code.md +73 -0
  74. package/docs/tutorials/065-conditional-rendering.md +161 -0
  75. package/docs/tutorials/070-displaying-a-list.md +137 -0
  76. package/docs/tutorials/072-inserting-into-a-list.md +88 -0
  77. package/docs/tutorials/074-deleting-from-a-list.md +77 -0
  78. package/docs/tutorials/076-updating-a-list-item.md +86 -0
  79. package/docs/tutorials/080-widget-requests.md +124 -0
  80. package/docs/tutorials/090-using-widget-libraries.md +75 -0
  81. package/package.json +4 -12
  82. package/dist/client.js +0 -522
  83. package/dist/demo-static/src/widgets/app-box.d.ts +0 -15
  84. package/dist/demo-static/src/widgets/app-box.d.ts.map +0 -1
  85. package/dist/demo-static/src/widgets/app-box.js +0 -19
  86. package/dist/tests/fixtures/elements/collision/elements.d.ts.map +0 -1
  87. package/dist/tests/fixtures/elements/collision/elements.js +0 -3
  88. package/dist/tests/fixtures/elements/manifest/elements.d.ts +0 -5
  89. package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +0 -1
  90. package/dist/tests/fixtures/elements/manifest/elements.js +0 -3
  91. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +0 -5
  92. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +0 -1
  93. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +0 -3
  94. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +0 -5
  95. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +0 -1
  96. package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +0 -3
  97. package/dist/tests/golden.test.d.ts +0 -19
  98. package/dist/tests/golden.test.d.ts.map +0 -1
  99. package/dist/tests/golden.test.js +0 -60
  100. package/dist/tests/helpers/window.d.ts +0 -43
  101. package/dist/tests/helpers/window.d.ts.map +0 -1
  102. package/dist/tests/helpers/window.js +0 -78
  103. package/dist/tests/lb-input.test.d.ts +0 -9
  104. package/dist/tests/lb-input.test.d.ts.map +0 -1
  105. package/dist/tests/lb-input.test.js +0 -78
  106. package/dist/tests/lb-list.test.d.ts +0 -12
  107. package/dist/tests/lb-list.test.d.ts.map +0 -1
  108. package/dist/tests/lb-list.test.js +0 -44
  109. package/dist/tests/lb-options.test.d.ts +0 -10
  110. package/dist/tests/lb-options.test.d.ts.map +0 -1
  111. package/dist/tests/lb-options.test.js +0 -121
  112. package/dist/tests/lb-picker.test.d.ts +0 -14
  113. package/dist/tests/lb-picker.test.d.ts.map +0 -1
  114. package/dist/tests/lb-picker.test.js +0 -59
  115. package/dist/tests/lb-select.test.d.ts +0 -9
  116. package/dist/tests/lb-select.test.d.ts.map +0 -1
  117. package/dist/tests/lb-select.test.js +0 -71
  118. package/dist/tests/lb-table.test.d.ts +0 -15
  119. package/dist/tests/lb-table.test.d.ts.map +0 -1
  120. package/dist/tests/lb-table.test.js +0 -205
  121. package/dist/widgets/index.d.ts +0 -7
  122. package/dist/widgets/index.d.ts.map +0 -1
  123. package/dist/widgets/index.js +0 -6
  124. package/dist/widgets/lb-input.d.ts +0 -2
  125. package/dist/widgets/lb-input.d.ts.map +0 -1
  126. package/dist/widgets/lb-input.js +0 -48
  127. package/dist/widgets/lb-list.d.ts +0 -2
  128. package/dist/widgets/lb-list.d.ts.map +0 -1
  129. package/dist/widgets/lb-list.js +0 -17
  130. package/dist/widgets/lb-options.d.ts +0 -26
  131. package/dist/widgets/lb-options.d.ts.map +0 -1
  132. package/dist/widgets/lb-options.js +0 -72
  133. package/dist/widgets/lb-picker.d.ts +0 -2
  134. package/dist/widgets/lb-picker.d.ts.map +0 -1
  135. package/dist/widgets/lb-picker.js +0 -25
  136. package/dist/widgets/lb-select.d.ts +0 -2
  137. package/dist/widgets/lb-select.d.ts.map +0 -1
  138. package/dist/widgets/lb-select.js +0 -43
  139. package/dist/widgets/lb-table.d.ts +0 -2
  140. package/dist/widgets/lb-table.d.ts.map +0 -1
  141. package/dist/widgets/lb-table.js +0 -113
  142. package/docs/application-chrome.md +0 -36
  143. package/docs/building-html-pages.md +0 -130
  144. package/docs/getting-started.md +0 -120
  145. package/docs/guide.md +0 -1164
  146. package/docs/hosting.md +0 -218
  147. package/docs/latent-risks.md +0 -20
  148. package/widgets/index.ts +0 -6
  149. package/widgets/lb-input.html +0 -1
  150. package/widgets/lb-input.ts +0 -64
  151. package/widgets/lb-list.html +0 -1
  152. package/widgets/lb-list.ts +0 -21
  153. package/widgets/lb-options.html +0 -4
  154. package/widgets/lb-options.ts +0 -88
  155. package/widgets/lb-picker.html +0 -7
  156. package/widgets/lb-picker.ts +0 -27
  157. package/widgets/lb-select.html +0 -4
  158. package/widgets/lb-select.ts +0 -55
  159. package/widgets/lb-table.html +0 -8
  160. 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)