@operato/list-state 10.15.0 → 10.16.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/CHANGELOG.md +9 -0
- package/coverage/lcov-report/index.html +28 -28
- package/coverage/lcov.info +875 -631
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/ox-list-state.d.ts +1 -0
- package/dist/src/ox-list-state.js +8 -0
- package/dist/src/ox-list-state.js.map +1 -1
- package/dist/src/ox-page-state.d.ts +37 -0
- package/dist/src/ox-page-state.js +131 -0
- package/dist/src/ox-page-state.js.map +1 -0
- package/dist/test/ox-page-state.test.d.ts +2 -0
- package/dist/test/ox-page-state.test.js +54 -0
- package/dist/test/ox-page-state.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -4
- package/test/ox-page-state.test.ts +69 -0
- package/translations/en.json +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
package/dist/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA","sourcesContent":["export * from './ox-empty-note.js'\nexport * from './ox-list-state.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA","sourcesContent":["export * from './ox-empty-note.js'\nexport * from './ox-list-state.js'\nexport * from './ox-page-state.js'\n"]}
|
|
@@ -6,6 +6,7 @@ import { ListScale } from '@operato/utils/list-state.js';
|
|
|
6
6
|
* Says what a list is doing, next to the rows its parent draws.
|
|
7
7
|
*
|
|
8
8
|
* loading nothing held yet: a spinner
|
|
9
|
+
* forbidden the server refused for lack of a privilege: said so, no retry
|
|
9
10
|
* error nothing held: "could not load the list"; retry
|
|
10
11
|
* rows held: "could not refresh the list"; retry
|
|
11
12
|
* empty-filtered "nothing matches" and a way to clear the filters - what to do differs from empty
|
|
@@ -9,6 +9,7 @@ import { hasMore, listPhase } from '@operato/utils/list-state.js';
|
|
|
9
9
|
* Says what a list is doing, next to the rows its parent draws.
|
|
10
10
|
*
|
|
11
11
|
* loading nothing held yet: a spinner
|
|
12
|
+
* forbidden the server refused for lack of a privilege: said so, no retry
|
|
12
13
|
* error nothing held: "could not load the list"; retry
|
|
13
14
|
* rows held: "could not refresh the list"; retry
|
|
14
15
|
* empty-filtered "nothing matches" and a way to clear the filters - what to do differs from empty
|
|
@@ -109,6 +110,13 @@ let OxListState = class OxListState extends LitElement {
|
|
|
109
110
|
if (phase === 'loading') {
|
|
110
111
|
return html `<span class="spin" role="status" aria-label=${this.t('text.list-loading', 'loading…')}></span>`;
|
|
111
112
|
}
|
|
113
|
+
if (phase === 'forbidden') {
|
|
114
|
+
return this.scale.loaded === 0
|
|
115
|
+
? html `<ox-empty-note role="alert" icon="lock" title=${this.t('text.list-forbidden', 'you are not allowed to see this list')}></ox-empty-note>`
|
|
116
|
+
: html `<p class="band" failed role="alert">
|
|
117
|
+
<span>${this.t('text.list-refresh-forbidden', 'you are not allowed to refresh this list')}</span>
|
|
118
|
+
</p>`;
|
|
119
|
+
}
|
|
112
120
|
if (phase === 'error' && this.scale.loaded === 0) {
|
|
113
121
|
return html `
|
|
114
122
|
<ox-empty-note
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ox-list-state.js","sourceRoot":"","sources":["../../src/ox-list-state.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/text-button.js'\nimport './ox-empty-note.js'\n\nimport { css, html, LitElement, nothing, TemplateResult } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next } from '@operato/i18n'\nimport { hasMore, ListScale, listPhase } from '@operato/utils/list-state.js'\n\n/**\n * Says what a list is doing, next to the rows its parent draws.\n *\n * loading nothing held yet: a spinner\n * error nothing held: \"could not load the list\"; retry\n * rows held: \"could not refresh the list\"; retry\n * empty-filtered \"nothing matches\" and a way to clear the filters - what to do differs from empty\n * empty the screen's own note (what is missing here), and the reason when one is known\n * ready only what the rows cannot say: the list was cut, or there is more to load\n *\n * Events: `retry`, `reset-filters`, `load-more` (bubbling, composed).\n *\n * Moved from operato-twin (twin-list-state), the one of three forks that told all of these apart.\n */\n@customElement('ox-list-state')\nexport class OxListState extends LitElement {\n static styles = css`\n :host {\n display: block;\n color: var(--ui-muted, #57676f);\n }\n\n ox-empty-note {\n padding: var(--spacing-large, 16px) 0;\n }\n\n .spin {\n display: block;\n margin: var(--spacing-large, 16px) auto;\n width: 16px;\n height: 16px;\n border: 2px solid var(--ui-line, #d5dce0);\n border-top-color: var(--ui-accent, var(--md-sys-color-primary, #00727e));\n border-radius: 50%;\n animation: spin 0.7s linear infinite;\n }\n\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .spin {\n animation: none;\n }\n }\n\n .band {\n display: flex;\n gap: var(--spacing-medium, 8px);\n align-items: center;\n flex-wrap: wrap;\n margin: var(--spacing-medium, 8px) 0 0;\n padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);\n border-left: 3px solid var(--ui-warn, #9f5e02);\n background: var(--ui-panel-2, #eef1f3);\n font-size: var(--ui-t-small, 11.5px);\n line-height: 1.55;\n }\n\n .band[failed] {\n border-left-color: var(--ui-crit, var(--md-sys-color-error, #be0c14));\n }\n\n .band b {\n color: var(--ui-ink, #17242c);\n font-weight: 600;\n }\n\n .why {\n display: block;\n margin-top: var(--spacing-small, 4px);\n color: var(--ui-warn, #9f5e02);\n }\n\n .more {\n display: flex;\n justify-content: center;\n padding: var(--spacing-medium, 8px) 0 0;\n }\n `\n\n @property({ type: Object }) scale: ListScale = { shown: 0, loaded: 0 }\n @property({ type: Boolean }) loading = false\n @property({ attribute: false }) error: unknown = null\n\n /** What this screen says when it has nothing yet - already translated by the screen. */\n @property({ type: Object }) emptyNote?: { icon?: string; title?: string; description?: string }\n\n /**\n * Why the list is empty - only when known. An empty list whose source could not be reached is not\n * \"nothing\": say so, so people look at the connection instead of doubting the data. Unknown stays blank.\n */\n @property({ type: String }) reason = ''\n\n private emit(name: string) {\n this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true }))\n }\n\n private t(key: string, defaultValue: string) {\n return i18next.t(key, { defaultValue })\n }\n\n render(): TemplateResult | typeof nothing {\n const phase = listPhase(this.scale, { loading: this.loading, error: this.error })\n\n if (phase === 'loading') {\n return html`<span class=\"spin\" role=\"status\" aria-label=${this.t('text.list-loading', 'loading…')}></span>`\n }\n\n if (phase === 'error' && this.scale.loaded === 0) {\n return html`\n <ox-empty-note\n role=\"alert\"\n icon=\"error\"\n title=${this.t('text.list-load-failed', 'could not load the list')}\n >\n <md-text-button @click=${() => this.emit('retry')}>${this.t('text.list-retry', 'retry')}</md-text-button>\n </ox-empty-note>\n `\n }\n\n if (phase === 'error') {\n /* The rows above are what was loaded before; do not let them pass for a fresh result. */\n return html`\n <p class=\"band\" failed role=\"alert\">\n <span>${this.t('text.list-refresh-failed', 'could not refresh the list')}</span>\n <md-text-button @click=${() => this.emit('retry')}>${this.t('text.list-retry', 'retry')}</md-text-button>\n </p>\n `\n }\n\n if (phase === 'empty-filtered') {\n return html`\n <ox-empty-note\n icon=\"search_off\"\n title=${this.t('text.list-nothing-found', 'nothing matches')}\n >\n <md-text-button @click=${() => this.emit('reset-filters')}\n >${this.t('text.list-clear-filters', 'clear filters')}</md-text-button\n >\n </ox-empty-note>\n `\n }\n\n if (phase === 'empty') {\n return html`\n <ox-empty-note\n icon=${this.emptyNote?.icon ?? nothing}\n title=${this.emptyNote?.title ?? this.t('text.list-empty', 'nothing here yet.')}\n description=${this.emptyNote?.description ?? nothing}\n >\n ${this.reason ? html`<span class=\"why\">${this.reason}</span>` : nothing}\n </ox-empty-note>\n `\n }\n\n const more = hasMore(this.scale)\n\n return html`\n ${this.scale.capped\n ? html`<p class=\"band\">\n <span\n ><b>${this.t('text.list-capped-title', 'showing a partial result.')}</b>\n ${this.t('text.list-capped-detail', 'narrow by search or filter to see the rest.')}</span\n >\n </p>`\n : nothing}\n ${more\n ? html`<div class=\"more\">\n <md-text-button ?disabled=${this.loading} @click=${() => this.emit('load-more')}>\n ${this.loading ? this.t('text.list-loading', 'loading…') : this.t('text.list-load-more', 'load more')}\n </md-text-button>\n </div>`\n : nothing}\n `\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'ox-list-state': OxListState\n 'ox-empty-note': import('./ox-empty-note.js').EmptyNote\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ox-list-state.js","sourceRoot":"","sources":["../../src/ox-list-state.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/text-button.js'\nimport './ox-empty-note.js'\n\nimport { css, html, LitElement, nothing, TemplateResult } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next } from '@operato/i18n'\nimport { hasMore, ListScale, listPhase } from '@operato/utils/list-state.js'\n\n/**\n * Says what a list is doing, next to the rows its parent draws.\n *\n * loading nothing held yet: a spinner\n * forbidden the server refused for lack of a privilege: said so, no retry\n * error nothing held: \"could not load the list\"; retry\n * rows held: \"could not refresh the list\"; retry\n * empty-filtered \"nothing matches\" and a way to clear the filters - what to do differs from empty\n * empty the screen's own note (what is missing here), and the reason when one is known\n * ready only what the rows cannot say: the list was cut, or there is more to load\n *\n * Events: `retry`, `reset-filters`, `load-more` (bubbling, composed).\n *\n * Moved from operato-twin (twin-list-state), the one of three forks that told all of these apart.\n */\n@customElement('ox-list-state')\nexport class OxListState extends LitElement {\n static styles = css`\n :host {\n display: block;\n color: var(--ui-muted, #57676f);\n }\n\n ox-empty-note {\n padding: var(--spacing-large, 16px) 0;\n }\n\n .spin {\n display: block;\n margin: var(--spacing-large, 16px) auto;\n width: 16px;\n height: 16px;\n border: 2px solid var(--ui-line, #d5dce0);\n border-top-color: var(--ui-accent, var(--md-sys-color-primary, #00727e));\n border-radius: 50%;\n animation: spin 0.7s linear infinite;\n }\n\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .spin {\n animation: none;\n }\n }\n\n .band {\n display: flex;\n gap: var(--spacing-medium, 8px);\n align-items: center;\n flex-wrap: wrap;\n margin: var(--spacing-medium, 8px) 0 0;\n padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);\n border-left: 3px solid var(--ui-warn, #9f5e02);\n background: var(--ui-panel-2, #eef1f3);\n font-size: var(--ui-t-small, 11.5px);\n line-height: 1.55;\n }\n\n .band[failed] {\n border-left-color: var(--ui-crit, var(--md-sys-color-error, #be0c14));\n }\n\n .band b {\n color: var(--ui-ink, #17242c);\n font-weight: 600;\n }\n\n .why {\n display: block;\n margin-top: var(--spacing-small, 4px);\n color: var(--ui-warn, #9f5e02);\n }\n\n .more {\n display: flex;\n justify-content: center;\n padding: var(--spacing-medium, 8px) 0 0;\n }\n `\n\n @property({ type: Object }) scale: ListScale = { shown: 0, loaded: 0 }\n @property({ type: Boolean }) loading = false\n @property({ attribute: false }) error: unknown = null\n\n /** What this screen says when it has nothing yet - already translated by the screen. */\n @property({ type: Object }) emptyNote?: { icon?: string; title?: string; description?: string }\n\n /**\n * Why the list is empty - only when known. An empty list whose source could not be reached is not\n * \"nothing\": say so, so people look at the connection instead of doubting the data. Unknown stays blank.\n */\n @property({ type: String }) reason = ''\n\n private emit(name: string) {\n this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true }))\n }\n\n private t(key: string, defaultValue: string) {\n return i18next.t(key, { defaultValue })\n }\n\n render(): TemplateResult | typeof nothing {\n const phase = listPhase(this.scale, { loading: this.loading, error: this.error })\n\n if (phase === 'loading') {\n return html`<span class=\"spin\" role=\"status\" aria-label=${this.t('text.list-loading', 'loading…')}></span>`\n }\n\n if (phase === 'forbidden') {\n return this.scale.loaded === 0\n ? html`<ox-empty-note role=\"alert\" icon=\"lock\" title=${this.t('text.list-forbidden', 'you are not allowed to see this list')}></ox-empty-note>`\n : html`<p class=\"band\" failed role=\"alert\">\n <span>${this.t('text.list-refresh-forbidden', 'you are not allowed to refresh this list')}</span>\n </p>`\n }\n\n if (phase === 'error' && this.scale.loaded === 0) {\n return html`\n <ox-empty-note\n role=\"alert\"\n icon=\"error\"\n title=${this.t('text.list-load-failed', 'could not load the list')}\n >\n <md-text-button @click=${() => this.emit('retry')}>${this.t('text.list-retry', 'retry')}</md-text-button>\n </ox-empty-note>\n `\n }\n\n if (phase === 'error') {\n /* The rows above are what was loaded before; do not let them pass for a fresh result. */\n return html`\n <p class=\"band\" failed role=\"alert\">\n <span>${this.t('text.list-refresh-failed', 'could not refresh the list')}</span>\n <md-text-button @click=${() => this.emit('retry')}>${this.t('text.list-retry', 'retry')}</md-text-button>\n </p>\n `\n }\n\n if (phase === 'empty-filtered') {\n return html`\n <ox-empty-note\n icon=\"search_off\"\n title=${this.t('text.list-nothing-found', 'nothing matches')}\n >\n <md-text-button @click=${() => this.emit('reset-filters')}\n >${this.t('text.list-clear-filters', 'clear filters')}</md-text-button\n >\n </ox-empty-note>\n `\n }\n\n if (phase === 'empty') {\n return html`\n <ox-empty-note\n icon=${this.emptyNote?.icon ?? nothing}\n title=${this.emptyNote?.title ?? this.t('text.list-empty', 'nothing here yet.')}\n description=${this.emptyNote?.description ?? nothing}\n >\n ${this.reason ? html`<span class=\"why\">${this.reason}</span>` : nothing}\n </ox-empty-note>\n `\n }\n\n const more = hasMore(this.scale)\n\n return html`\n ${this.scale.capped\n ? html`<p class=\"band\">\n <span\n ><b>${this.t('text.list-capped-title', 'showing a partial result.')}</b>\n ${this.t('text.list-capped-detail', 'narrow by search or filter to see the rest.')}</span\n >\n </p>`\n : nothing}\n ${more\n ? html`<div class=\"more\">\n <md-text-button ?disabled=${this.loading} @click=${() => this.emit('load-more')}>\n ${this.loading ? this.t('text.list-loading', 'loading…') : this.t('text.list-load-more', 'load more')}\n </md-text-button>\n </div>`\n : nothing}\n `\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'ox-list-state': OxListState\n 'ox-empty-note': import('./ox-empty-note.js').EmptyNote\n }\n}\n"]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import '@material/web/button/text-button.js';
|
|
2
|
+
import './ox-empty-note.js';
|
|
3
|
+
import { LitElement } from 'lit';
|
|
4
|
+
/**
|
|
5
|
+
* Says what a whole screen is doing until it has something to show, then shows the screen.
|
|
6
|
+
*
|
|
7
|
+
* loading a spinner
|
|
8
|
+
* forbidden "you are not allowed to see this screen", and the privilege it needs when the server
|
|
9
|
+
* named it (extensions.requires) - no retry, retrying will not help
|
|
10
|
+
* error "could not load this screen", the server's own sentence, retry
|
|
11
|
+
* ready the slotted content
|
|
12
|
+
*
|
|
13
|
+
* <ox-page-state .loading=${this.loading} .error=${this.error} @retry=${this.load}>
|
|
14
|
+
* …the screen…
|
|
15
|
+
* </ox-page-state>
|
|
16
|
+
*
|
|
17
|
+
* Screens used to write this per page - `.wsfail`, `.why`, `.failed`, `.loading`, or nothing at all, so
|
|
18
|
+
* a refused or failed load looked like an empty screen (ADR-0060 decision 8). The phase comes from the
|
|
19
|
+
* same model as ox-list-state (loadPhase), so a screen and a list use one vocabulary.
|
|
20
|
+
*
|
|
21
|
+
* Event: `retry` (bubbling, composed).
|
|
22
|
+
*/
|
|
23
|
+
export declare class OxPageState extends LitElement {
|
|
24
|
+
static styles: import("lit").CSSResult;
|
|
25
|
+
loading: boolean;
|
|
26
|
+
error: unknown;
|
|
27
|
+
private t;
|
|
28
|
+
private retry;
|
|
29
|
+
/** The first GraphQL error the server sent, in whatever shape the error arrived. */
|
|
30
|
+
private first;
|
|
31
|
+
render(): import("lit-html").TemplateResult<1>;
|
|
32
|
+
}
|
|
33
|
+
declare global {
|
|
34
|
+
interface HTMLElementTagNameMap {
|
|
35
|
+
'ox-page-state': OxPageState;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { __decorate } from "tslib";
|
|
2
|
+
import '@material/web/button/text-button.js';
|
|
3
|
+
import './ox-empty-note.js';
|
|
4
|
+
import { css, html, LitElement, nothing } from 'lit';
|
|
5
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
6
|
+
import { i18next } from '@operato/i18n';
|
|
7
|
+
import { loadPhase } from '@operato/utils/list-state.js';
|
|
8
|
+
/**
|
|
9
|
+
* Says what a whole screen is doing until it has something to show, then shows the screen.
|
|
10
|
+
*
|
|
11
|
+
* loading a spinner
|
|
12
|
+
* forbidden "you are not allowed to see this screen", and the privilege it needs when the server
|
|
13
|
+
* named it (extensions.requires) - no retry, retrying will not help
|
|
14
|
+
* error "could not load this screen", the server's own sentence, retry
|
|
15
|
+
* ready the slotted content
|
|
16
|
+
*
|
|
17
|
+
* <ox-page-state .loading=${this.loading} .error=${this.error} @retry=${this.load}>
|
|
18
|
+
* …the screen…
|
|
19
|
+
* </ox-page-state>
|
|
20
|
+
*
|
|
21
|
+
* Screens used to write this per page - `.wsfail`, `.why`, `.failed`, `.loading`, or nothing at all, so
|
|
22
|
+
* a refused or failed load looked like an empty screen (ADR-0060 decision 8). The phase comes from the
|
|
23
|
+
* same model as ox-list-state (loadPhase), so a screen and a list use one vocabulary.
|
|
24
|
+
*
|
|
25
|
+
* Event: `retry` (bubbling, composed).
|
|
26
|
+
*/
|
|
27
|
+
let OxPageState = class OxPageState extends LitElement {
|
|
28
|
+
constructor() {
|
|
29
|
+
super(...arguments);
|
|
30
|
+
this.loading = false;
|
|
31
|
+
this.error = null;
|
|
32
|
+
}
|
|
33
|
+
static { this.styles = css `
|
|
34
|
+
:host {
|
|
35
|
+
display: contents;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.state {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
min-height: 240px;
|
|
44
|
+
padding: 40px 20px;
|
|
45
|
+
box-sizing: border-box;
|
|
46
|
+
color: var(--ui-muted, #57676f);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.spin {
|
|
50
|
+
width: 20px;
|
|
51
|
+
height: 20px;
|
|
52
|
+
border: 2px solid var(--ui-line, #d5dce0);
|
|
53
|
+
border-top-color: var(--ui-accent, var(--md-sys-color-primary, #00727e));
|
|
54
|
+
border-radius: 50%;
|
|
55
|
+
animation: spin 0.7s linear infinite;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@keyframes spin {
|
|
59
|
+
to {
|
|
60
|
+
transform: rotate(360deg);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (prefers-reduced-motion: reduce) {
|
|
65
|
+
.spin {
|
|
66
|
+
animation: none;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.said {
|
|
71
|
+
max-width: 60ch;
|
|
72
|
+
margin: 0;
|
|
73
|
+
text-align: center;
|
|
74
|
+
font-size: var(--ui-t-body, 13px);
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
}
|
|
77
|
+
`; }
|
|
78
|
+
t(key, defaultValue, params = {}) {
|
|
79
|
+
return i18next.t(key, { defaultValue, ...params });
|
|
80
|
+
}
|
|
81
|
+
retry() {
|
|
82
|
+
this.dispatchEvent(new CustomEvent('retry', { bubbles: true, composed: true }));
|
|
83
|
+
}
|
|
84
|
+
/** The first GraphQL error the server sent, in whatever shape the error arrived. */
|
|
85
|
+
first() {
|
|
86
|
+
const e = this.error;
|
|
87
|
+
const list = Array.isArray(e) ? e : e?.graphQLErrors?.length ? e.graphQLErrors : e?.errors?.length ? e.errors : [e];
|
|
88
|
+
return list?.[0] || {};
|
|
89
|
+
}
|
|
90
|
+
render() {
|
|
91
|
+
const phase = loadPhase({ loading: this.loading, error: this.error });
|
|
92
|
+
if (phase === 'loading') {
|
|
93
|
+
return html `<div class="state" role="status" aria-label=${this.t('text.page-loading', 'loading…')}>
|
|
94
|
+
<span class="spin"></span>
|
|
95
|
+
</div>`;
|
|
96
|
+
}
|
|
97
|
+
if (phase === 'forbidden') {
|
|
98
|
+
const requires = this.first().extensions?.requires;
|
|
99
|
+
const privilege = requires ? [requires.category, requires.privilege].filter(Boolean).join(' · ') : '';
|
|
100
|
+
return html `<div class="state">
|
|
101
|
+
<ox-empty-note
|
|
102
|
+
role="alert"
|
|
103
|
+
icon="lock"
|
|
104
|
+
title=${this.t('text.page-forbidden', 'you are not allowed to see this screen')}
|
|
105
|
+
description=${privilege ? this.t('text.page-forbidden-requires', 'needs {privilege}', { privilege }) : nothing}
|
|
106
|
+
></ox-empty-note>
|
|
107
|
+
</div>`;
|
|
108
|
+
}
|
|
109
|
+
if (phase === 'error') {
|
|
110
|
+
const said = this.first().message || (this.error instanceof Error ? this.error.message : '');
|
|
111
|
+
return html `<div class="state">
|
|
112
|
+
<ox-empty-note role="alert" icon="error" title=${this.t('text.page-load-failed', 'could not load this screen')}>
|
|
113
|
+
${said ? html `<p class="said">${said}</p>` : nothing}
|
|
114
|
+
<md-text-button @click=${this.retry}>${this.t('text.list-retry', 'retry')}</md-text-button>
|
|
115
|
+
</ox-empty-note>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
return html `<slot></slot>`;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
__decorate([
|
|
122
|
+
property({ type: Boolean })
|
|
123
|
+
], OxPageState.prototype, "loading", void 0);
|
|
124
|
+
__decorate([
|
|
125
|
+
property({ attribute: false })
|
|
126
|
+
], OxPageState.prototype, "error", void 0);
|
|
127
|
+
OxPageState = __decorate([
|
|
128
|
+
customElement('ox-page-state')
|
|
129
|
+
], OxPageState);
|
|
130
|
+
export { OxPageState };
|
|
131
|
+
//# sourceMappingURL=ox-page-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ox-page-state.js","sourceRoot":"","sources":["../../src/ox-page-state.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/button/text-button.js'\nimport './ox-empty-note.js'\n\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next } from '@operato/i18n'\nimport { loadPhase } from '@operato/utils/list-state.js'\n\n/**\n * Says what a whole screen is doing until it has something to show, then shows the screen.\n *\n * loading a spinner\n * forbidden \"you are not allowed to see this screen\", and the privilege it needs when the server\n * named it (extensions.requires) - no retry, retrying will not help\n * error \"could not load this screen\", the server's own sentence, retry\n * ready the slotted content\n *\n * <ox-page-state .loading=${this.loading} .error=${this.error} @retry=${this.load}>\n * …the screen…\n * </ox-page-state>\n *\n * Screens used to write this per page - `.wsfail`, `.why`, `.failed`, `.loading`, or nothing at all, so\n * a refused or failed load looked like an empty screen (ADR-0060 decision 8). The phase comes from the\n * same model as ox-list-state (loadPhase), so a screen and a list use one vocabulary.\n *\n * Event: `retry` (bubbling, composed).\n */\n@customElement('ox-page-state')\nexport class OxPageState extends LitElement {\n static styles = css`\n :host {\n display: contents;\n }\n\n .state {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n min-height: 240px;\n padding: 40px 20px;\n box-sizing: border-box;\n color: var(--ui-muted, #57676f);\n }\n\n .spin {\n width: 20px;\n height: 20px;\n border: 2px solid var(--ui-line, #d5dce0);\n border-top-color: var(--ui-accent, var(--md-sys-color-primary, #00727e));\n border-radius: 50%;\n animation: spin 0.7s linear infinite;\n }\n\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .spin {\n animation: none;\n }\n }\n\n .said {\n max-width: 60ch;\n margin: 0;\n text-align: center;\n font-size: var(--ui-t-body, 13px);\n line-height: 1.5;\n }\n `\n\n @property({ type: Boolean }) loading = false\n @property({ attribute: false }) error: unknown = null\n\n private t(key: string, defaultValue: string, params: Record<string, unknown> = {}) {\n return i18next.t(key, { defaultValue, ...params })\n }\n\n private retry() {\n this.dispatchEvent(new CustomEvent('retry', { bubbles: true, composed: true }))\n }\n\n /** The first GraphQL error the server sent, in whatever shape the error arrived. */\n private first(): { message?: string; extensions?: { requires?: { privilege?: string; category?: string } } } {\n const e = this.error as any\n const list = Array.isArray(e) ? e : e?.graphQLErrors?.length ? e.graphQLErrors : e?.errors?.length ? e.errors : [e]\n return list?.[0] || {}\n }\n\n render() {\n const phase = loadPhase({ loading: this.loading, error: this.error })\n\n if (phase === 'loading') {\n return html`<div class=\"state\" role=\"status\" aria-label=${this.t('text.page-loading', 'loading…')}>\n <span class=\"spin\"></span>\n </div>`\n }\n\n if (phase === 'forbidden') {\n const requires = this.first().extensions?.requires\n const privilege = requires ? [requires.category, requires.privilege].filter(Boolean).join(' · ') : ''\n return html`<div class=\"state\">\n <ox-empty-note\n role=\"alert\"\n icon=\"lock\"\n title=${this.t('text.page-forbidden', 'you are not allowed to see this screen')}\n description=${privilege ? this.t('text.page-forbidden-requires', 'needs {privilege}', { privilege }) : nothing}\n ></ox-empty-note>\n </div>`\n }\n\n if (phase === 'error') {\n const said = this.first().message || (this.error instanceof Error ? this.error.message : '')\n return html`<div class=\"state\">\n <ox-empty-note role=\"alert\" icon=\"error\" title=${this.t('text.page-load-failed', 'could not load this screen')}>\n ${said ? html`<p class=\"said\">${said}</p>` : nothing}\n <md-text-button @click=${this.retry}>${this.t('text.list-retry', 'retry')}</md-text-button>\n </ox-empty-note>\n </div>`\n }\n\n return html`<slot></slot>`\n }\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'ox-page-state': OxPageState\n }\n}\n"]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* Elements are compared by presence, not by value: a failing element comparison makes chai render the DOM and hang the runner. */
|
|
2
|
+
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
|
3
|
+
import '../src/ox-page-state.js';
|
|
4
|
+
import '../src/ox-list-state.js';
|
|
5
|
+
/**
|
|
6
|
+
* A whole screen says what it is doing until it has something to show (ADR-0060 decision 8). The
|
|
7
|
+
* phase comes from the same model as ox-list-state, and a refusal for lack of privilege is never shown
|
|
8
|
+
* as a failure to retry.
|
|
9
|
+
*/
|
|
10
|
+
async function page(props) {
|
|
11
|
+
const el = await fixture(html `<ox-page-state><p id="content">the screen</p></ox-page-state>`);
|
|
12
|
+
Object.assign(el, props);
|
|
13
|
+
await el.updateComplete;
|
|
14
|
+
return el;
|
|
15
|
+
}
|
|
16
|
+
const root = (el) => el.renderRoot;
|
|
17
|
+
const note = (el) => root(el).querySelector('ox-empty-note');
|
|
18
|
+
const slotted = (el) => root(el).querySelector('slot')?.assignedElements() ?? [];
|
|
19
|
+
const refused = {
|
|
20
|
+
errors: [{ message: 'no', extensions: { code: 'FORBIDDEN', requires: { category: 'mes', privilege: 'query' } } }]
|
|
21
|
+
};
|
|
22
|
+
describe('ox-page-state', () => {
|
|
23
|
+
it('shows the screen once ready', async () => {
|
|
24
|
+
const el = await page({});
|
|
25
|
+
expect(slotted(el).map(e => e.id)).to.deep.equal(['content']);
|
|
26
|
+
});
|
|
27
|
+
it('shows a spinner, not the screen, while loading', async () => {
|
|
28
|
+
const el = await page({ loading: true });
|
|
29
|
+
expect(root(el).querySelector('[role="status"]') !== null).to.equal(true);
|
|
30
|
+
expect(root(el).querySelector('slot') === null).to.equal(true);
|
|
31
|
+
});
|
|
32
|
+
it('says a failure, keeps the server sentence, and offers retry', async () => {
|
|
33
|
+
const el = await page({ error: { errors: [{ message: 'database is down' }] } });
|
|
34
|
+
expect(note(el)?.getAttribute('title')).to.equal('could not load this screen');
|
|
35
|
+
expect(root(el).querySelector('.said')?.textContent).to.equal('database is down');
|
|
36
|
+
const retry = note(el).querySelector('md-text-button');
|
|
37
|
+
setTimeout(() => retry.click());
|
|
38
|
+
await oneEvent(el, 'retry');
|
|
39
|
+
});
|
|
40
|
+
it('★ a privilege refusal says so, names what is needed, and offers no retry', async () => {
|
|
41
|
+
const el = await page({ error: refused });
|
|
42
|
+
expect(note(el)?.getAttribute('title')).to.equal('you are not allowed to see this screen');
|
|
43
|
+
expect(note(el)?.getAttribute('description')).to.equal('needs mes · query');
|
|
44
|
+
expect(note(el).querySelector('md-text-button') === null).to.equal(true);
|
|
45
|
+
});
|
|
46
|
+
it('a list uses the same vocabulary for a refusal', async () => {
|
|
47
|
+
const el = await fixture(html `<ox-list-state></ox-list-state>`);
|
|
48
|
+
Object.assign(el, { error: refused, scale: { shown: 0, loaded: 0 } });
|
|
49
|
+
await el.updateComplete;
|
|
50
|
+
expect(note(el)?.getAttribute('title')).to.equal('you are not allowed to see this list');
|
|
51
|
+
expect(note(el).querySelector('md-text-button') === null).to.equal(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=ox-page-state.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ox-page-state.test.js","sourceRoot":"","sources":["../../test/ox-page-state.test.ts"],"names":[],"mappings":"AAAA,kIAAkI;AAClI,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAElE,OAAO,yBAAyB,CAAA;AAChC,OAAO,yBAAyB,CAAA;AAIhC;;;;GAIG;AAEH,KAAK,UAAU,IAAI,CAAC,KAA2B;IAC7C,MAAM,EAAE,GAAG,MAAM,OAAO,CAAc,IAAI,CAAA,+DAA+D,CAAC,CAAA;IAC1G,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,KAAK,CAAC,CAAA;IACxB,MAAM,EAAE,CAAC,cAAc,CAAA;IACvB,OAAO,EAAE,CAAA;AACX,CAAC;AAED,MAAM,IAAI,GAAG,CAAC,EAAW,EAAE,EAAE,CAAE,EAAkB,CAAC,UAAwB,CAAA;AAC1E,MAAM,IAAI,GAAG,CAAC,EAAW,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,aAAa,CAAC,eAAe,CAAuB,CAAA;AAC3F,MAAM,OAAO,GAAG,CAAC,EAAe,EAAE,EAAE,CAAE,IAAI,CAAC,EAAE,CAAC,CAAC,aAAa,CAAC,MAAM,CAA4B,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAA;AAEzH,MAAM,OAAO,GAAG;IACd,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;CAClH,CAAA;AAED,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;IAC7B,EAAE,CAAC,6BAA6B,EAAE,KAAK,IAAI,EAAE;QAC3C,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,CAAA;QACzB,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IAC/D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QACxC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,aAAa,CAAC,iBAAiB,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACzE,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAChE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;QAC3E,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,kBAAkB,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;QAE/E,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,4BAA4B,CAAC,CAAA;QAC9E,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAA;QAEjF,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAE,CAAC,aAAa,CAAC,gBAAgB,CAAgB,CAAA;QACtE,UAAU,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAA;QAC/B,MAAM,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;IAC7B,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,0EAA0E,EAAE,KAAK,IAAI,EAAE;QACxF,MAAM,EAAE,GAAG,MAAM,IAAI,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAA;QAEzC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,wCAAwC,CAAC,CAAA;QAC1F,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAA;QAC3E,MAAM,CAAC,IAAI,CAAC,EAAE,CAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC3E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+CAA+C,EAAE,KAAK,IAAI,EAAE;QAC7D,MAAM,EAAE,GAAG,MAAM,OAAO,CAAc,IAAI,CAAA,iCAAiC,CAAC,CAAA;QAC5E,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;QACrE,MAAM,EAAE,CAAC,cAAc,CAAA;QAEvB,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAA;QACxF,MAAM,CAAC,IAAI,CAAC,EAAE,CAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IAC3E,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["/* Elements are compared by presence, not by value: a failing element comparison makes chai render the DOM and hang the runner. */\nimport { expect, fixture, html, oneEvent } from '@open-wc/testing'\n\nimport '../src/ox-page-state.js'\nimport '../src/ox-list-state.js'\nimport type { OxPageState } from '../src/ox-page-state.js'\nimport type { OxListState } from '../src/ox-list-state.js'\n\n/**\n * A whole screen says what it is doing until it has something to show (ADR-0060 decision 8). The\n * phase comes from the same model as ox-list-state, and a refusal for lack of privilege is never shown\n * as a failure to retry.\n */\n\nasync function page(props: Partial<OxPageState>) {\n const el = await fixture<OxPageState>(html`<ox-page-state><p id=\"content\">the screen</p></ox-page-state>`)\n Object.assign(el, props)\n await el.updateComplete\n return el\n}\n\nconst root = (el: Element) => (el as OxPageState).renderRoot as ShadowRoot\nconst note = (el: Element) => root(el).querySelector('ox-empty-note') as HTMLElement | null\nconst slotted = (el: OxPageState) => (root(el).querySelector('slot') as HTMLSlotElement | null)?.assignedElements() ?? []\n\nconst refused = {\n errors: [{ message: 'no', extensions: { code: 'FORBIDDEN', requires: { category: 'mes', privilege: 'query' } } }]\n}\n\ndescribe('ox-page-state', () => {\n it('shows the screen once ready', async () => {\n const el = await page({})\n expect(slotted(el).map(e => e.id)).to.deep.equal(['content'])\n })\n\n it('shows a spinner, not the screen, while loading', async () => {\n const el = await page({ loading: true })\n expect(root(el).querySelector('[role=\"status\"]') !== null).to.equal(true)\n expect(root(el).querySelector('slot') === null).to.equal(true)\n })\n\n it('says a failure, keeps the server sentence, and offers retry', async () => {\n const el = await page({ error: { errors: [{ message: 'database is down' }] } })\n\n expect(note(el)?.getAttribute('title')).to.equal('could not load this screen')\n expect(root(el).querySelector('.said')?.textContent).to.equal('database is down')\n\n const retry = note(el)!.querySelector('md-text-button') as HTMLElement\n setTimeout(() => retry.click())\n await oneEvent(el, 'retry')\n })\n\n it('★ a privilege refusal says so, names what is needed, and offers no retry', async () => {\n const el = await page({ error: refused })\n\n expect(note(el)?.getAttribute('title')).to.equal('you are not allowed to see this screen')\n expect(note(el)?.getAttribute('description')).to.equal('needs mes · query')\n expect(note(el)!.querySelector('md-text-button') === null).to.equal(true)\n })\n\n it('a list uses the same vocabulary for a refusal', async () => {\n const el = await fixture<OxListState>(html`<ox-list-state></ox-list-state>`)\n Object.assign(el, { error: refused, scale: { shown: 0, loaded: 0 } })\n await el.updateComplete\n\n expect(note(el)?.getAttribute('title')).to.equal('you are not allowed to see this list')\n expect(note(el)!.querySelector('md-text-button') === null).to.equal(true)\n })\n})\n"]}
|