@excom/fetchable-element 0.1.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.
- package/.rush/temp/chunked-rush-logs/fetchable-element.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/fetchable-element.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/fetchable-element.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +399 -0
- package/package.json +48 -0
- package/rush-logs/fetchable-element.apply-exports.cache.log +1 -0
- package/rush-logs/fetchable-element.apply-exports.log +1 -0
- package/rush-logs/fetchable-element.build_docs.cache.log +1 -0
- package/rush-logs/fetchable-element.build_docs.log +1 -0
- package/rush-logs/fetchable-element.build_package-metas.cache.log +1 -0
- package/rush-logs/fetchable-element.build_package-metas.log +1 -0
- package/support/custom-elements.json +368 -0
- package/support/dist-docs/fetchable-element.md +120 -0
- package/support/docs/README.md +67 -0
- package/support/package-meta.json +218 -0
- package/support/tests/fetch-lifecycle.test.ts +401 -0
- package/support/tests/fetchable-element.test.ts +184 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
{
|
|
2
|
+
"shortName": "fetchable-element",
|
|
3
|
+
"package": {
|
|
4
|
+
"name": "@excom/fetchable-element",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "FetchableElement base for Neutron elements",
|
|
7
|
+
"peerDependencies": {},
|
|
8
|
+
"excom": {
|
|
9
|
+
"packageType": "element-base"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"demos": {},
|
|
13
|
+
"readme": "<h1 id=\"md-fetchable-element\">fetchable-element</h1>\n<p>Composition base that owns the <code>fetch()</code> lifecycle for Neutron elements —\nbuild the request from attributes, a <code><form></code>, or a custom override, then\ntrack loading / success / error state automatically.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><p><strong>Shared lifecycle</strong> States, <code>provision</code> and events come from <spa-a route-href=\"/nucleus/packages/loadable-element\" role=\"link\">loadable-element</spa-a></p>\n</li>\n<li><p><strong>Attribute-driven requests</strong> URL, method, headers, redirect, and\ncredentials all configurable declaratively</p>\n</li>\n<li><p><strong>Form-aware</strong> Point <code>form-ref</code> at a <code><form></code> to source action, method,\nenctype, and field values</p>\n</li>\n<li><p><strong>Merged payloads</strong> Attributes, form, and custom args deep-merge\n(lowest → highest priority)</p>\n</li>\n<li><p><strong>Lifecycle state</strong> <code>is-loading</code> / <code>is-success</code> / <code>is-error</code> managed\nfor you</p>\n</li>\n<li><p><strong>Provision</strong> <code>provision</code> is the parsed response (or error payload) for\nQuark <code>prop("provision")</code> — not a reflected attribute</p>\n</li>\n<li><p><strong>Cancel-safe</strong> Superseded or disconnected requests are aborted via\n<code>AbortableElement</code></p>\n</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>FetchableElement</code>, then call <code>doFetch(url, requestInit)</code> —\nusually built via <code>getFetchArgs(customFetchArgs?)</code> — whenever the\nsubclass decides a request should run. Concrete consumers include\n<code><provider-fetch></code> (fetch on attribute change), <code><super-form></code> (fetch on\nsubmit), and <code><web-authn></code> (WebAuthn ceremonies that still round-trip to\na server).</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { FetchableElement } from \"@excom/fetchable-element\";\n\nexport const RefreshOnClick = Neutron.compose([\n FetchableElement,\n Neutron({ tag: \"refresh-on-click\" }),\n])\n .onEvent(\"click\", ({ getFetchArgs }) => ({\n doFetch: getFetchArgs(),\n }));\n\nRefreshOnClick.define();</template></include-content>\n<include-content data-language=\"html\"><template><refresh-on-click api-url=\"/api/status\"></refresh-on-click></template></include-content>\n<p>Every prop, state field, and event documented below is inherited\nverbatim by any element that composes <code>FetchableElement</code> — it flattens\ndirectly into that element's own generated docs, so <code><provider-fetch></code>\nand friends don't redeclare it.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<p>There are no demos for this package — see\n<spa-a route-href=\"/nucleus/packages/provider-fetch\" role=\"link\">provider-fetch</spa-a> for <code>FetchableElement</code> in\naction against a real endpoint.</p>\n",
|
|
14
|
+
"docs": {
|
|
15
|
+
"readme": "<h1 id=\"md-fetchable-element\">fetchable-element</h1>\n<p>Composition base that owns the <code>fetch()</code> lifecycle for Neutron elements —\nbuild the request from attributes, a <code><form></code>, or a custom override, then\ntrack loading / success / error state automatically.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><p><strong>Shared lifecycle</strong> States, <code>provision</code> and events come from <spa-a route-href=\"/nucleus/packages/loadable-element\" role=\"link\">loadable-element</spa-a></p>\n</li>\n<li><p><strong>Attribute-driven requests</strong> URL, method, headers, redirect, and\ncredentials all configurable declaratively</p>\n</li>\n<li><p><strong>Form-aware</strong> Point <code>form-ref</code> at a <code><form></code> to source action, method,\nenctype, and field values</p>\n</li>\n<li><p><strong>Merged payloads</strong> Attributes, form, and custom args deep-merge\n(lowest → highest priority)</p>\n</li>\n<li><p><strong>Lifecycle state</strong> <code>is-loading</code> / <code>is-success</code> / <code>is-error</code> managed\nfor you</p>\n</li>\n<li><p><strong>Provision</strong> <code>provision</code> is the parsed response (or error payload) for\nQuark <code>prop("provision")</code> — not a reflected attribute</p>\n</li>\n<li><p><strong>Cancel-safe</strong> Superseded or disconnected requests are aborted via\n<code>AbortableElement</code></p>\n</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>Compose <code>FetchableElement</code>, then call <code>doFetch(url, requestInit)</code> —\nusually built via <code>getFetchArgs(customFetchArgs?)</code> — whenever the\nsubclass decides a request should run. Concrete consumers include\n<code><provider-fetch></code> (fetch on attribute change), <code><super-form></code> (fetch on\nsubmit), and <code><web-authn></code> (WebAuthn ceremonies that still round-trip to\na server).</p>\n<include-content data-language=\"ts\"><template>import { Neutron } from \"@excom/neutron\";\nimport { FetchableElement } from \"@excom/fetchable-element\";\n\nexport const RefreshOnClick = Neutron.compose([\n FetchableElement,\n Neutron({ tag: \"refresh-on-click\" }),\n])\n .onEvent(\"click\", ({ getFetchArgs }) => ({\n doFetch: getFetchArgs(),\n }));\n\nRefreshOnClick.define();</template></include-content>\n<include-content data-language=\"html\"><template><refresh-on-click api-url=\"/api/status\"></refresh-on-click></template></include-content>\n<p>Every prop, state field, and event documented below is inherited\nverbatim by any element that composes <code>FetchableElement</code> — it flattens\ndirectly into that element's own generated docs, so <code><provider-fetch></code>\nand friends don't redeclare it.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<p>There are no demos for this package — see\n<spa-a route-href=\"/nucleus/packages/provider-fetch\" role=\"link\">provider-fetch</spa-a> for <code>FetchableElement</code> in\naction against a real endpoint.</p>\n"
|
|
16
|
+
},
|
|
17
|
+
"installation": {
|
|
18
|
+
"name": "@excom/fetchable-element",
|
|
19
|
+
"shortName": "fetchable-element",
|
|
20
|
+
"version": "0.1.0",
|
|
21
|
+
"description": "FetchableElement base for Neutron elements",
|
|
22
|
+
"packageType": "element-base",
|
|
23
|
+
"install": {
|
|
24
|
+
"npm": "npm install @excom/fetchable-element"
|
|
25
|
+
},
|
|
26
|
+
"imports": {
|
|
27
|
+
"js": "import { /* … */ } from \"@excom/fetchable-element\";"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": []
|
|
30
|
+
},
|
|
31
|
+
"elementApis": [
|
|
32
|
+
{
|
|
33
|
+
"kind": "mixin",
|
|
34
|
+
"attributes": [
|
|
35
|
+
{
|
|
36
|
+
"name": "api-method",
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "HTTP method. Always uppercased before the request is sent.",
|
|
39
|
+
"fieldName": "apiMethod",
|
|
40
|
+
"surface": "option",
|
|
41
|
+
"default": "\"GET\""
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "api-url",
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Endpoint URL. When the request has no body, the JSON payload (from <code>form-ref</code> or custom <code>doFetch()</code> args) is merged in as query params instead.",
|
|
47
|
+
"fieldName": "apiUrl",
|
|
48
|
+
"surface": "option",
|
|
49
|
+
"default": "\"\""
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "fetch-credentials",
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "<code>RequestInit.credentials</code> mode.",
|
|
55
|
+
"fieldName": "fetchCredentials",
|
|
56
|
+
"surface": "option",
|
|
57
|
+
"default": "\"include\"",
|
|
58
|
+
"values": "\"omit\" | \"same-origin\" | \"include\""
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "fetch-redirect",
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "<code>RequestInit.redirect</code> mode. Unset defers to the browser default (<code>follow</code>).",
|
|
64
|
+
"fieldName": "fetchRedirect",
|
|
65
|
+
"surface": "option",
|
|
66
|
+
"values": "\"follow\" | \"error\" | \"manual\""
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "form-ref",
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "CSS selector for a <code><form></code> to source the request from — its <code>action</code> (URL), <code>method</code>, <code>enctype</code> (Content-Type), and field values (as the JSON payload) all take priority over the matching attributes below. Omit to build the request entirely from attributes / custom <code>doFetch()</code> args.",
|
|
72
|
+
"fieldName": "formRef",
|
|
73
|
+
"surface": "option",
|
|
74
|
+
"values": "<CSS Selector>"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "has-body",
|
|
78
|
+
"type": "boolean",
|
|
79
|
+
"description": "Force a request body even for methods that don't imply one (<code>GET</code> / <code>HEAD</code>). Already implied for <code>POST</code> / <code>PUT</code> / <code>PATCH</code>.",
|
|
80
|
+
"fieldName": "hasBody",
|
|
81
|
+
"surface": "option"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "header-accept",
|
|
85
|
+
"type": "string",
|
|
86
|
+
"description": "<code>Accept</code> request header.",
|
|
87
|
+
"fieldName": "headerAccept",
|
|
88
|
+
"surface": "option",
|
|
89
|
+
"default": "\"application/json\""
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"name": "header-cache-control",
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "<code>Cache-Control</code> request header. Unset by default (browser default caching applies).",
|
|
95
|
+
"fieldName": "headerCacheControl",
|
|
96
|
+
"surface": "option"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"name": "header-content-type",
|
|
100
|
+
"type": "string",
|
|
101
|
+
"description": "<code>Content-Type</code> request header. Dropped entirely when the request has no body.",
|
|
102
|
+
"fieldName": "headerContentType",
|
|
103
|
+
"surface": "option",
|
|
104
|
+
"default": "\"application/json\""
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "is-error",
|
|
108
|
+
"type": "boolean",
|
|
109
|
+
"description": "The most recent request failed (non-2xx status, network error, or a thrown error other than <code>AbortError</code>). Fires with the <code>error</code> event.",
|
|
110
|
+
"fieldName": "isError",
|
|
111
|
+
"surface": "state"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "is-error",
|
|
115
|
+
"type": "boolean",
|
|
116
|
+
"description": "The most recent work failed. Mutually exclusive with <code>is-success</code>.",
|
|
117
|
+
"fieldName": "isError",
|
|
118
|
+
"surface": "state",
|
|
119
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "is-loading",
|
|
123
|
+
"type": "boolean",
|
|
124
|
+
"description": "A request is currently in flight.",
|
|
125
|
+
"fieldName": "isLoading",
|
|
126
|
+
"surface": "state"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "is-loading",
|
|
130
|
+
"type": "boolean",
|
|
131
|
+
"description": "Work is in flight.",
|
|
132
|
+
"fieldName": "isLoading",
|
|
133
|
+
"surface": "state",
|
|
134
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"name": "is-success",
|
|
138
|
+
"type": "boolean",
|
|
139
|
+
"description": "The most recent request resolved successfully. Mutually exclusive with <code>is-error</code>.",
|
|
140
|
+
"fieldName": "isSuccess",
|
|
141
|
+
"surface": "state"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"name": "is-success",
|
|
145
|
+
"type": "boolean",
|
|
146
|
+
"description": "The most recent work finished successfully. Mutually exclusive with <code>is-error</code>.",
|
|
147
|
+
"fieldName": "isSuccess",
|
|
148
|
+
"surface": "state",
|
|
149
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"events": [
|
|
153
|
+
{
|
|
154
|
+
"name": "{tag}-error",
|
|
155
|
+
"description": "Dispatched when the request fails — non-2xx status, network error, or a thrown error. <code>event.detail</code> is the error payload (see <code>provision</code>). Not dispatched for aborted requests.",
|
|
156
|
+
"type": "FetchableErrorEvent",
|
|
157
|
+
"typeExpanded": "CustomEvent & { type: \"{tag}-error\"; detail: { bodyUsed: boolean; headers: [string, string][]; ok: boolean; redirected: boolean; status: number; statusText: string; type: ResponseType; url: string; body: unknown; } | { message: string; stack?: string }; bubbles: true; cancelable: true; composed: true }"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"name": "{tag}-error",
|
|
161
|
+
"description": "After <code>is-error</code> is set. <code>event.detail</code> is the error payload (also stored as <code>provision</code>).",
|
|
162
|
+
"type": "LoadableErrorEvent",
|
|
163
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "{tag}-loading",
|
|
167
|
+
"description": "Dispatched immediately before the request is sent.",
|
|
168
|
+
"type": "FetchableLoadingEvent",
|
|
169
|
+
"typeExpanded": "CustomEvent & { type: \"{tag}-loading\"; detail: void; bubbles: true; cancelable: true; composed: true }"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"name": "{tag}-loading",
|
|
173
|
+
"description": "After <code>is-loading</code> is set (work started).",
|
|
174
|
+
"type": "LoadableLoadingEvent",
|
|
175
|
+
"typeExpanded": "CustomEvent & { type: \"{tag}-loading\"; detail: void; bubbles: true; cancelable: true; composed: true }",
|
|
176
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "{tag}-success",
|
|
180
|
+
"description": "Dispatched when the request resolves successfully. <code>event.detail</code> is the parsed response (see <code>provision</code>).",
|
|
181
|
+
"type": "FetchableSuccessEvent",
|
|
182
|
+
"typeExpanded": "CustomEvent & { type: \"{tag}-success\"; detail: { bodyUsed: boolean; headers: [string, string][]; ok: boolean; redirected: boolean; status: number; statusText: string; type: ResponseType; url: string; body: unknown; }; bubbles: true; cancelable: true; composed: true }"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"name": "{tag}-success",
|
|
186
|
+
"description": "After <code>is-success</code> is set. <code>event.detail</code> is the new <code>provision</code>.",
|
|
187
|
+
"type": "LoadableSuccessEvent",
|
|
188
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
189
|
+
}
|
|
190
|
+
],
|
|
191
|
+
"slots": [],
|
|
192
|
+
"cssProperties": [],
|
|
193
|
+
"cssClasses": [],
|
|
194
|
+
"cssAliases": [],
|
|
195
|
+
"listens": [],
|
|
196
|
+
"commands": [],
|
|
197
|
+
"defaultActions": [],
|
|
198
|
+
"expectedChildren": [],
|
|
199
|
+
"provisions": [
|
|
200
|
+
{
|
|
201
|
+
"name": "provision",
|
|
202
|
+
"type": "FetchResponse",
|
|
203
|
+
"typeExpanded": "{ bodyUsed: boolean; headers: [string, string][]; ok: boolean; redirected: boolean; status: number; statusText: string; type: ResponseType; url: string; body: unknown; }",
|
|
204
|
+
"description": "Response payload on success, or error payload on failure. Success shape: <code>{ status, statusText, ok, headers, url, redirected, bodyUsed, type, body }</code>. Failure shape is either that same response shape (server responded with an error status) or <code>{ message, stack }</code> (request never completed). Not reflected as an attribute.",
|
|
205
|
+
"fieldName": "provision"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"name": "provision",
|
|
209
|
+
"type": "unknown",
|
|
210
|
+
"description": "The result of the most recent work on success, or the error payload on failure. Shape is defined by the composing element. Not reflected as an attribute.",
|
|
211
|
+
"fieldName": "provision",
|
|
212
|
+
"inheritedFrom": "@excom/loadable-element"
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
],
|
|
217
|
+
"exportedFiles": {}
|
|
218
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { FetchableElement } from "../../index";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
fixture,
|
|
7
|
+
it,
|
|
8
|
+
spyFetch,
|
|
9
|
+
vi,
|
|
10
|
+
wait,
|
|
11
|
+
waitForEvent,
|
|
12
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
13
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
14
|
+
|
|
15
|
+
const TAG = "fetchable-lifecycle-test";
|
|
16
|
+
if (!customElements.get(TAG)) {
|
|
17
|
+
FetchableElement.define(TAG);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Lifecycle events use the base's config tag (`noop-tag`), not the defined tag.
|
|
21
|
+
const EVT = (name: string) => `noop-tag-${name}`;
|
|
22
|
+
|
|
23
|
+
const abortError = () =>
|
|
24
|
+
new DOMException("The operation was aborted.", "AbortError");
|
|
25
|
+
|
|
26
|
+
/** A fetch stub that only settles when its signal aborts (rejects `AbortError`). */
|
|
27
|
+
const spyAbortableFetch = () =>
|
|
28
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
29
|
+
(_url, init) =>
|
|
30
|
+
new Promise((_resolve, reject) => {
|
|
31
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const jsonResponse = (body: unknown, status = 200) =>
|
|
36
|
+
new Response(JSON.stringify(body), {
|
|
37
|
+
status,
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("FetchableElement fetch lifecycle", () => {
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
document.body.innerHTML = "";
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
KitLogger.unsuppress();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("emits loading, then success with the parsed JSON provision", async () => {
|
|
49
|
+
spyFetch({ body: JSON.stringify({ ok: 1 }) });
|
|
50
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
51
|
+
|
|
52
|
+
const loading = waitForEvent(el, EVT("loading"));
|
|
53
|
+
const provisioned = waitForEvent(el, "neutron-provision");
|
|
54
|
+
let successDetail: any;
|
|
55
|
+
el.addEventListener(EVT("success"), (e: CustomEvent) => {
|
|
56
|
+
successDetail = e.detail;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
60
|
+
el.doFetch(["/api/json-ok", { method: "GET" }]);
|
|
61
|
+
});
|
|
62
|
+
await loading;
|
|
63
|
+
await provisioned;
|
|
64
|
+
|
|
65
|
+
expect(el).dom.to.equalTag(`<${TAG} is-success></${TAG}>`);
|
|
66
|
+
expect(el.isLoading).toBe(false);
|
|
67
|
+
expect(el.fetchPromise).toBeNull();
|
|
68
|
+
expect(el.provision.status).toBe(200);
|
|
69
|
+
expect(el.provision.ok).toBe(true);
|
|
70
|
+
expect(el.provision.body).toEqual({ ok: 1 });
|
|
71
|
+
expect(el.provision.headers).toEqual(
|
|
72
|
+
expect.arrayContaining([["content-type", "application/json"]]),
|
|
73
|
+
);
|
|
74
|
+
expect(successDetail).toBe(el.provision);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reads the body as text when the response is not JSON", async () => {
|
|
78
|
+
spyFetch({
|
|
79
|
+
body: "plain text",
|
|
80
|
+
headers: new Headers({ "content-type": "text/plain" }),
|
|
81
|
+
});
|
|
82
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
83
|
+
|
|
84
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
85
|
+
el.doFetch(["/api/text", {}]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(el.provision.body).toBe("plain text");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("reads the body as text when the response has no content-type", async () => {
|
|
92
|
+
spyFetch({ body: "untyped", headers: new Headers() });
|
|
93
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
94
|
+
|
|
95
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
96
|
+
el.doFetch(["/api/untyped", {}]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(el.provision.body).toBe("untyped");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sets is-loading while the request is in flight", () => {
|
|
103
|
+
spyAbortableFetch();
|
|
104
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
105
|
+
el.doFetch(["/api/slow", {}]);
|
|
106
|
+
expect(el).dom.to.equalTag(`<${TAG} is-loading></${TAG}>`);
|
|
107
|
+
expect(el.fetchPromise).toBeInstanceOf(Promise);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("emits error with the response payload on a non-2xx status", async () => {
|
|
111
|
+
KitLogger.suppress();
|
|
112
|
+
spyFetch({ status: 500, body: JSON.stringify({ reason: "boom" }) });
|
|
113
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
114
|
+
|
|
115
|
+
let errorDetail: any;
|
|
116
|
+
el.addEventListener(EVT("error"), (e: CustomEvent) => {
|
|
117
|
+
errorDetail = e.detail;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await waitForEvent(el, EVT("error"), () => {
|
|
121
|
+
el.doFetch(["/api/server-error", {}]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(el).dom.to.equalTag(`<${TAG} is-error></${TAG}>`);
|
|
125
|
+
expect(el.isLoading).toBe(false);
|
|
126
|
+
expect(el.fetchPromise).toBeNull();
|
|
127
|
+
expect(el.provision.status).toBe(500);
|
|
128
|
+
expect(el.provision.ok).toBe(false);
|
|
129
|
+
expect(el.provision.body).toEqual({ reason: "boom" });
|
|
130
|
+
expect(errorDetail).toBe(el.provision);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("emits error with { message, stack } when fetch itself throws", async () => {
|
|
134
|
+
KitLogger.suppress();
|
|
135
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
136
|
+
Promise.reject(new TypeError("Failed to fetch")),
|
|
137
|
+
);
|
|
138
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
139
|
+
|
|
140
|
+
await waitForEvent(el, EVT("error"), () => {
|
|
141
|
+
el.doFetch(["/api/network-down", {}]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(el).dom.to.equalTag(`<${TAG} is-error></${TAG}>`);
|
|
145
|
+
expect(el.provision.message).toBe("Failed to fetch");
|
|
146
|
+
expect(el.provision.stack).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("cancelling an in-flight request clears state without an error event", async () => {
|
|
150
|
+
spyAbortableFetch();
|
|
151
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
152
|
+
const onError = vi.fn();
|
|
153
|
+
const onSuccess = vi.fn();
|
|
154
|
+
el.addEventListener(EVT("error"), onError);
|
|
155
|
+
el.addEventListener(EVT("success"), onSuccess);
|
|
156
|
+
|
|
157
|
+
el.doFetch(["/api/cancel-me", {}]);
|
|
158
|
+
const { signal } = el.abortController;
|
|
159
|
+
expect(el.isLoading).toBe(true);
|
|
160
|
+
|
|
161
|
+
el.setCanceledState();
|
|
162
|
+
await wait(10);
|
|
163
|
+
|
|
164
|
+
expect(signal.aborted).toBe(true);
|
|
165
|
+
expect(el).dom.to.equalTag(`<${TAG}></${TAG}>`);
|
|
166
|
+
expect(el.fetchPromise).toBeNull();
|
|
167
|
+
expect(el.provision).toBeFalsy();
|
|
168
|
+
expect(onError).not.toHaveBeenCalled();
|
|
169
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a replacing request aborts the first and resolves with the second response", async () => {
|
|
173
|
+
let nthCall = 0;
|
|
174
|
+
const fetchSpy = vi
|
|
175
|
+
.spyOn(globalThis, "fetch")
|
|
176
|
+
.mockImplementation((_url, init) => {
|
|
177
|
+
nthCall++;
|
|
178
|
+
if (nthCall === 1) {
|
|
179
|
+
return new Promise((_resolve, reject) => {
|
|
180
|
+
init?.signal?.addEventListener("abort", () =>
|
|
181
|
+
reject(abortError()),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve(jsonResponse({ call: nthCall }));
|
|
186
|
+
});
|
|
187
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
188
|
+
|
|
189
|
+
el.doFetch(["/api/first", {}]);
|
|
190
|
+
const firstSignal = el.abortController.signal;
|
|
191
|
+
|
|
192
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
193
|
+
el.doFetch(["/api/second", {}]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
197
|
+
expect(firstSignal.aborted).toBe(true);
|
|
198
|
+
expect(el.provision.body).toEqual({ call: 2 });
|
|
199
|
+
expect(el).dom.to.equalTag(`<${TAG} is-success></${TAG}>`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("aborts the in-flight request when disconnected", async () => {
|
|
203
|
+
spyAbortableFetch();
|
|
204
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
205
|
+
const onError = vi.fn();
|
|
206
|
+
el.addEventListener(EVT("error"), onError);
|
|
207
|
+
|
|
208
|
+
el.doFetch(["/api/disconnect", {}]);
|
|
209
|
+
const { signal } = el.abortController;
|
|
210
|
+
expect(el.isLoading).toBe(true);
|
|
211
|
+
|
|
212
|
+
el.remove();
|
|
213
|
+
await wait(10);
|
|
214
|
+
|
|
215
|
+
expect(signal.aborted).toBe(true);
|
|
216
|
+
expect(el.fetchPromise).toBeNull();
|
|
217
|
+
expect(el.isLoading).toBe(false);
|
|
218
|
+
expect(onError).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("serialises the body and forwards method, headers and signal to fetch", async () => {
|
|
222
|
+
const fetchSpy = spyFetch({ body: JSON.stringify({ saved: true }) });
|
|
223
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
224
|
+
|
|
225
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
226
|
+
el.doFetch([
|
|
227
|
+
"/api/items",
|
|
228
|
+
{
|
|
229
|
+
method: "POST",
|
|
230
|
+
body: { name: "widget" } as unknown as BodyInit,
|
|
231
|
+
headers: { "X-Test": "1" },
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
237
|
+
expect(url).toBe("/api/items");
|
|
238
|
+
expect(init.method).toBe("POST");
|
|
239
|
+
expect(init.body).toBe(JSON.stringify({ name: "widget" }));
|
|
240
|
+
expect(init.headers).toEqual({ "X-Test": "1" });
|
|
241
|
+
expect(init.signal).toBeDefined();
|
|
242
|
+
expect(init.signal!.aborted).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("sends no body when the request init has none", async () => {
|
|
246
|
+
const fetchSpy = spyFetch({ body: "{}" });
|
|
247
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
248
|
+
|
|
249
|
+
await waitForEvent(el, EVT("success"), () => {
|
|
250
|
+
el.doFetch(["/api/no-body", { method: "GET" }]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
254
|
+
expect(init.body).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("FetchableElement getFetchArgs", () => {
|
|
259
|
+
afterEach(() => {
|
|
260
|
+
document.body.innerHTML = "";
|
|
261
|
+
vi.restoreAllMocks();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("defaults api-url to an empty string resolved against the origin", () => {
|
|
265
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
266
|
+
expect(el.apiUrl).toBe("");
|
|
267
|
+
const [url] = el.getFetchArgs();
|
|
268
|
+
expect(url).toBe(new URL("", window.location.origin).toString());
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("custom fetch args take priority over attributes", () => {
|
|
272
|
+
const el = fixture<any>(
|
|
273
|
+
`<${TAG} api-url="/api/base" api-method="get"></${TAG}>`,
|
|
274
|
+
);
|
|
275
|
+
const [url, init] = el.getFetchArgs([
|
|
276
|
+
"/api/custom",
|
|
277
|
+
{ method: "post", headers: { "X-Custom": "1" } },
|
|
278
|
+
]);
|
|
279
|
+
expect(url).toContain("/api/custom");
|
|
280
|
+
expect(url).not.toContain("/api/base");
|
|
281
|
+
expect(init.method).toBe("POST");
|
|
282
|
+
expect(init.headers["X-Custom"]).toBe("1");
|
|
283
|
+
expect(init.headers.Accept).toBe("application/json");
|
|
284
|
+
expect(init.body).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("custom init without a url keeps the attribute url", () => {
|
|
288
|
+
const el = fixture<any>(`<${TAG} api-url="/api/base"></${TAG}>`);
|
|
289
|
+
const [url, init] = el.getFetchArgs([undefined, { redirect: "error" }]);
|
|
290
|
+
expect(url).toContain("/api/base");
|
|
291
|
+
expect(init.redirect).toBe("error");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("keeps a body supplied through custom fetch args", () => {
|
|
295
|
+
const el = fixture<any>(
|
|
296
|
+
`<${TAG} api-url="/api/items" form-ref=":scope form">
|
|
297
|
+
<form><input name="ignored" value="1" /></form>
|
|
298
|
+
</${TAG}>`,
|
|
299
|
+
);
|
|
300
|
+
const [, init] = el.getFetchArgs([
|
|
301
|
+
undefined,
|
|
302
|
+
{ method: "post", body: "raw-body" },
|
|
303
|
+
]);
|
|
304
|
+
expect(init.method).toBe("POST");
|
|
305
|
+
expect(init.body).toBe("raw-body");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("an empty api-method sends no body", () => {
|
|
309
|
+
const el = fixture<any>(`<${TAG} api-url="/api/items"></${TAG}>`);
|
|
310
|
+
el.apiMethod = "";
|
|
311
|
+
const [, init] = el.getFetchArgs();
|
|
312
|
+
expect(init.method).toBe("");
|
|
313
|
+
expect(init.body).toBeUndefined();
|
|
314
|
+
expect(init.headers["Content-Type"]).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("has-body forces a body (and Content-Type) on a GET request", () => {
|
|
318
|
+
const el = fixture<any>(
|
|
319
|
+
`<${TAG} api-url="/api/items" has-body form-ref=":scope form">
|
|
320
|
+
<form><input name="q" value="1" /></form>
|
|
321
|
+
</${TAG}>`,
|
|
322
|
+
);
|
|
323
|
+
const [url, init] = el.getFetchArgs();
|
|
324
|
+
expect(init.method).toBe("GET");
|
|
325
|
+
expect(init.body).toEqual({ q: "1" });
|
|
326
|
+
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
327
|
+
expect(url).not.toContain("q=1");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("merges the payload into query params when there is no body", () => {
|
|
331
|
+
const el = fixture<any>(
|
|
332
|
+
`<${TAG} api-url="/api/items" form-ref=":scope form">
|
|
333
|
+
<form><input name="q" value="1" /><input name="page" value="2" /></form>
|
|
334
|
+
</${TAG}>`,
|
|
335
|
+
);
|
|
336
|
+
const [url, init] = el.getFetchArgs();
|
|
337
|
+
expect(url).toContain("q=1");
|
|
338
|
+
expect(url).toContain("page=2");
|
|
339
|
+
expect(init.body).toBeUndefined();
|
|
340
|
+
expect(init.headers["Content-Type"]).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("drops headers and credentials that are cleared to empty strings", () => {
|
|
344
|
+
const el = fixture<any>(
|
|
345
|
+
`<${TAG} api-url="/api/items" api-method="post"></${TAG}>`,
|
|
346
|
+
);
|
|
347
|
+
el.headerAccept = "";
|
|
348
|
+
el.headerContentType = "";
|
|
349
|
+
el.fetchCredentials = "";
|
|
350
|
+
const [, init] = el.getFetchArgs();
|
|
351
|
+
expect(init.method).toBe("POST");
|
|
352
|
+
expect(init.headers.Accept).toBeUndefined();
|
|
353
|
+
expect(init.headers["Content-Type"]).toBeUndefined();
|
|
354
|
+
expect(init.credentials).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("passes fetch-redirect and a custom Content-Type through", () => {
|
|
358
|
+
const el = fixture<any>(
|
|
359
|
+
`<${TAG} api-url="/api/items" api-method="put" fetch-redirect="manual" header-content-type="text/plain"></${TAG}>`,
|
|
360
|
+
);
|
|
361
|
+
const [, init] = el.getFetchArgs();
|
|
362
|
+
expect(init.redirect).toBe("manual");
|
|
363
|
+
expect(init.headers["Content-Type"]).toBe("text/plain");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("sources action, method, enctype and fields from an external form-ref", () => {
|
|
367
|
+
document.body.innerHTML = `
|
|
368
|
+
<form id="ext-form" action="/api/from-form" method="post" enctype="multipart/form-data">
|
|
369
|
+
<input name="title" value="hello" />
|
|
370
|
+
</form>
|
|
371
|
+
<${TAG} api-url="/api/ignored" form-ref="#ext-form"></${TAG}>
|
|
372
|
+
`;
|
|
373
|
+
const el = document.querySelector(TAG) as any;
|
|
374
|
+
expect(el.getFormElement()).toBe(document.getElementById("ext-form"));
|
|
375
|
+
|
|
376
|
+
const [url, init] = el.getFetchArgs();
|
|
377
|
+
expect(url).toContain("/api/from-form");
|
|
378
|
+
expect(url).not.toContain("/api/ignored");
|
|
379
|
+
expect(init.method).toBe("POST");
|
|
380
|
+
expect(init.headers["Content-Type"]).toBe("multipart/form-data");
|
|
381
|
+
expect(init.body).toEqual({ title: "hello" });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("falls back to attribute url and method when the form has neither", () => {
|
|
385
|
+
const el = fixture<any>(
|
|
386
|
+
`<${TAG} api-url="/api/attr" api-method="patch" form-ref=":scope form">
|
|
387
|
+
<form><input name="a" value="b" /></form>
|
|
388
|
+
</${TAG}>`,
|
|
389
|
+
);
|
|
390
|
+
const [url, init] = el.getFetchArgs();
|
|
391
|
+
expect(url).toContain("/api/attr");
|
|
392
|
+
expect(init.method).toBe("PATCH");
|
|
393
|
+
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
394
|
+
expect(init.body).toEqual({ a: "b" });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("getFormElement returns null when the form-ref selector matches nothing", () => {
|
|
398
|
+
const el = fixture<any>(`<${TAG} form-ref="#no-such-form"></${TAG}>`);
|
|
399
|
+
expect(el.getFormElement()).toBeNull();
|
|
400
|
+
});
|
|
401
|
+
});
|