@operato/shell 10.29.68 → 10.29.70

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 (34) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/src/app/pages/page-view.d.ts +2 -1
  3. package/dist/src/app/pages/page-view.js +25 -7
  4. package/dist/src/app/pages/page-view.js.map +1 -1
  5. package/dist/src/drafts/draft-controller.d.ts +74 -0
  6. package/dist/src/drafts/draft-controller.js +164 -0
  7. package/dist/src/drafts/draft-controller.js.map +1 -0
  8. package/dist/src/drafts/draft-owner.d.ts +6 -0
  9. package/dist/src/drafts/draft-owner.js +10 -0
  10. package/dist/src/drafts/draft-owner.js.map +1 -0
  11. package/dist/src/drafts/unsaved-draft-controller.d.ts +5 -0
  12. package/dist/src/drafts/unsaved-draft-controller.js +19 -0
  13. package/dist/src/drafts/unsaved-draft-controller.js.map +1 -1
  14. package/dist/src/index.d.ts +2 -0
  15. package/dist/src/index.js +2 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/test/draft-controller.test.d.ts +1 -0
  18. package/dist/test/draft-controller.test.js +168 -0
  19. package/dist/test/draft-controller.test.js.map +1 -0
  20. package/dist/test/draft-owner.test.d.ts +1 -0
  21. package/dist/test/draft-owner.test.js +100 -0
  22. package/dist/test/draft-owner.test.js.map +1 -0
  23. package/dist/test/page-drafts.test.d.ts +1 -0
  24. package/dist/test/page-drafts.test.js +24 -0
  25. package/dist/test/page-drafts.test.js.map +1 -0
  26. package/dist/test/page-refresh-queries.test.d.ts +1 -0
  27. package/dist/test/page-refresh-queries.test.js +89 -0
  28. package/dist/test/page-refresh-queries.test.js.map +1 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/test/draft-controller.test.ts +186 -0
  32. package/test/draft-owner.test.ts +109 -0
  33. package/test/page-drafts.test.ts +23 -0
  34. package/test/page-refresh-queries.test.ts +96 -0
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@operato/shell",
3
3
  "description": "WebApplication architecturing shell following open-wc recommendations",
4
4
  "author": "heartyoh",
5
- "version": "10.29.68",
5
+ "version": "10.29.70",
6
6
  "type": "module",
7
7
  "main": "dist/src/index.js",
8
8
  "module": "dist/src/index.js",
@@ -60,11 +60,11 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@material/web": "^2.0.0",
63
- "@operato/graphql": "^10.29.61",
63
+ "@operato/graphql": "^10.29.69",
64
64
  "@operato/i18n": "^10.29.27",
65
- "@operato/popup": "^10.29.68",
66
- "@operato/styles": "^10.29.68",
67
- "@operato/utils": "^10.29.61",
65
+ "@operato/popup": "^10.29.70",
66
+ "@operato/styles": "^10.29.70",
67
+ "@operato/utils": "^10.29.69",
68
68
  "@webcomponents/scoped-custom-element-registry": "^0.0.9",
69
69
  "lit": "^3.1.2",
70
70
  "lodash-es": "^4.17.21",
@@ -103,5 +103,5 @@
103
103
  "prettier --write"
104
104
  ]
105
105
  },
106
- "gitHead": "28edfb19074463ffa10f36bdc9a3fb899c496e8a"
106
+ "gitHead": "7451d7242ca5e396bbdeb89fc46470d52c89e8cd"
107
107
  }
@@ -0,0 +1,186 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import { html, LitElement } from 'lit'
3
+
4
+ import { QUERY_ANSWERED } from '@operato/graphql'
5
+ import { DraftController, draftsOn } from '../src/drafts/draft-controller.js'
6
+ import { draftKey, readDraft, removeDraft } from '../src/drafts/draft-store.js'
7
+ import { UnsavedDraftController } from '../src/drafts/unsaved-draft-controller.js'
8
+
9
+ /**
10
+ * An edit form over what was read (ADR-0190 decision 2): a field shows its edit, else the read; a save clears only
11
+ * the edits still as sent; another record drops the edits.
12
+ */
13
+ type Rule = { name: string; body: string }
14
+
15
+ class RuleForm extends LitElement {
16
+ static properties = { read: { attribute: false }, recordId: {} }
17
+ read?: Rule
18
+ recordId = 'a'
19
+ draft = new DraftController<Rule>(this, { read: () => this.read, key: () => this.recordId })
20
+ }
21
+ customElements.define('rule-form', RuleForm)
22
+
23
+ const open = async () => {
24
+ const form = await fixture<RuleForm>(html`<rule-form .read=${{ name: 'A', body: 'old' }}></rule-form>`)
25
+ await form.updateComplete
26
+ return form
27
+ }
28
+
29
+ describe('DraftController', () => {
30
+ it('shows an edit, else what was read', async () => {
31
+ const form = await open()
32
+ expect(form.draft.value('name')).to.equal('A')
33
+ form.draft.set('name', 'A2')
34
+ expect(form.draft.value('name')).to.equal('A2')
35
+ expect(form.draft.value('body')).to.equal('old')
36
+ expect(form.draft.dirty).to.equal(true)
37
+ })
38
+
39
+ it('a save clears only what it sent and is still as sent; a failed save clears nothing', async () => {
40
+ const form = await open()
41
+ form.draft.set('name', 'A2')
42
+ let release!: (answer: string | undefined) => void
43
+ const saving = form.draft.submit(sent => {
44
+ expect(sent).to.deep.equal({ name: 'A2' })
45
+ return new Promise<string | undefined>(resolve => (release = resolve))
46
+ })
47
+ form.draft.set('body', 'typed while saving')
48
+ release('saved')
49
+ await saving
50
+ expect(form.draft.edits).to.deep.equal({ body: 'typed while saving' })
51
+
52
+ form.draft.set('name', 'A3')
53
+ await form.draft.submit(async () => undefined)
54
+ expect(form.draft.edits).to.deep.equal({ body: 'typed while saving', name: 'A3' })
55
+ })
56
+
57
+ it('a value put back to what was read is no edit', async () => {
58
+ const form = await open()
59
+ form.draft.set('name', 'A2')
60
+ form.draft.set('name', 'A')
61
+ expect(form.draft.dirty).to.equal(false)
62
+ expect(form.draft.value('name')).to.equal('A')
63
+ })
64
+
65
+ it('a field typed back to what was read while a save is out stays an edit after the answer', async () => {
66
+ const form = await open()
67
+ form.draft.set('name', 'B')
68
+ let release!: (answer: string | undefined) => void
69
+ const saving = form.draft.submit(() => new Promise<string | undefined>(resolve => (release = resolve)))
70
+ form.draft.set('name', 'A')
71
+ release('saved')
72
+ await saving
73
+ expect(form.draft.edits).to.deep.equal({ name: 'A' })
74
+ expect(form.draft.value('name')).to.equal('A')
75
+ })
76
+
77
+ it('another record drops the edits', async () => {
78
+ const form = await open()
79
+ form.draft.set('name', 'A2')
80
+ form.recordId = 'b'
81
+ await form.updateComplete
82
+ expect(form.draft.dirty).to.equal(false)
83
+ })
84
+
85
+ it('is known to its host while connected', async () => {
86
+ const form = await open()
87
+ expect(draftsOn(form)).to.deep.equal([form.draft])
88
+ form.remove()
89
+ expect(draftsOn(form)).to.deep.equal([])
90
+ })
91
+ })
92
+
93
+ class KeptForm extends LitElement {
94
+ static properties = { read: { attribute: false }, recordId: {} }
95
+ read?: Rule
96
+ recordId = 'a'
97
+ draft = new DraftController<Rule>(this, {
98
+ read: () => this.read,
99
+ key: () => this.recordId,
100
+ keep: { address: () => ({ user: 'u', domain: 'd', screen: '/rules', target: this.recordId }) }
101
+ })
102
+ }
103
+ customElements.define('kept-rule-form', KeptForm)
104
+
105
+ describe('DraftController keep', () => {
106
+ const at = (target: string) => draftKey({ user: 'u', domain: 'd', screen: '/rules', target })
107
+ let wait: number
108
+ beforeEach(async () => {
109
+ wait = UnsavedDraftController.WAIT_MS
110
+ UnsavedDraftController.WAIT_MS = 0
111
+ await removeDraft(at('a'))
112
+ await removeDraft(at('b'))
113
+ })
114
+ afterEach(() => (UnsavedDraftController.WAIT_MS = wait))
115
+ const settle = () => new Promise(resolve => setTimeout(resolve, 50))
116
+
117
+ it('writes the edits for the record, and keeps them when the form moves to another', async () => {
118
+ const form = await fixture<KeptForm>(html`<kept-rule-form .read=${{ name: 'A', body: 'old' }}></kept-rule-form>`)
119
+ form.draft.set('name', 'A2')
120
+ await settle()
121
+ expect((await readDraft(at('a')))?.value).to.deep.equal({ name: 'A2' })
122
+ expect(form.draft.kept).to.equal(true)
123
+ /* Typed and moved at once: what was typed is written for the record left, not lost or written for the next. */
124
+ form.draft.set('name', 'A3')
125
+ form.recordId = 'b'
126
+ await form.updateComplete
127
+ await settle()
128
+ expect(form.draft.dirty).to.equal(false)
129
+ expect((await readDraft(at('a')))?.value).to.deep.equal({ name: 'A3' })
130
+ expect(await readDraft(at('b'))).to.equal(undefined)
131
+ })
132
+
133
+ it('a save that clears the edits drops what was kept', async () => {
134
+ const form = await fixture<KeptForm>(html`<kept-rule-form .read=${{ name: 'A', body: 'old' }}></kept-rule-form>`)
135
+ form.draft.set('name', 'A2')
136
+ await settle()
137
+ await form.draft.submit(async () => 'saved')
138
+ await settle()
139
+ expect(await readDraft(at('a'))).to.equal(undefined)
140
+ })
141
+
142
+ it('a save that answers after a move clears what was kept for the record it was sent from, and nothing else', async () => {
143
+ const form = await fixture<KeptForm>(html`<kept-rule-form .read=${{ name: 'A', body: 'old' }}></kept-rule-form>`)
144
+ form.draft.set('name', 'A2')
145
+ await settle()
146
+ let answer!: (value: string) => void
147
+ const saving = form.draft.submit(() => new Promise<string>(resolve => (answer = resolve)))
148
+ form.recordId = 'b'
149
+ await form.updateComplete
150
+ form.draft.set('name', 'A2')
151
+ await settle()
152
+ answer('saved')
153
+ await saving
154
+ await settle()
155
+ expect(await readDraft(at('a'))).to.equal(undefined)
156
+ expect(form.draft.edits).to.deep.equal({ name: 'A2' }, "b's edit equal to what was sent stays")
157
+ expect((await readDraft(at('b')))?.value).to.deep.equal({ name: 'A2' })
158
+ })
159
+
160
+ it('a save that answers after the form came back to its record leaves what is edited there now kept', async () => {
161
+ const form = await fixture<KeptForm>(html`<kept-rule-form .read=${{ name: 'A', body: 'old' }}></kept-rule-form>`)
162
+ form.draft.set('name', 'A2')
163
+ await settle()
164
+ let answer!: (value: string) => void
165
+ const saving = form.draft.submit(() => new Promise<string>(resolve => (answer = resolve)))
166
+ form.recordId = 'b'
167
+ await form.updateComplete
168
+ form.recordId = 'a'
169
+ await form.updateComplete
170
+ form.draft.set('name', 'A2')
171
+ await settle()
172
+ answer('saved')
173
+ await saving
174
+ await settle()
175
+ expect(form.draft.edits).to.deep.equal({ name: 'A2' })
176
+ expect((await readDraft(at('a')))?.value).to.deep.equal({ name: 'A2' })
177
+ expect(form.draft.kept).to.equal(true)
178
+ })
179
+
180
+ it('offers nothing when the record answers with nothing kept', async () => {
181
+ const form = await fixture<KeptForm>(html`<kept-rule-form .read=${{ name: 'A', body: 'old' }}></kept-rule-form>`)
182
+ form.dispatchEvent(new Event(QUERY_ANSWERED))
183
+ await settle()
184
+ expect(form.draft.dirty).to.equal(false)
185
+ })
186
+ })
@@ -0,0 +1,109 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import { html, LitElement } from 'lit'
3
+ import { OxPrompt } from '@operato/popup'
4
+
5
+ import { QUERY_ANSWERED } from '@operato/graphql'
6
+ import { DraftController } from '../src/drafts/draft-controller.js'
7
+ import { draftOwner } from '../src/drafts/draft-owner.js'
8
+ import { draftKey, readDraft, removeDraftsOf } from '../src/drafts/draft-store.js'
9
+ import { UnsavedDraftController } from '../src/drafts/unsaved-draft-controller.js'
10
+ import { store } from '../src/store.js'
11
+
12
+ /**
13
+ * A draft belongs to the signed-in person (ADR-0190): kept and offered back to them, never to someone else, and
14
+ * taken away when they sign out. With no one signed in nothing is written.
15
+ */
16
+ let signedIn: { id?: string } | null = null
17
+ store.addReducers({ auth: () => ({ user: signedIn }) })
18
+ const signIn = (user: { id?: string } | null) => {
19
+ signedIn = user
20
+ store.dispatch({ type: 'test-sign-in' })
21
+ }
22
+
23
+ type Note = { body: string }
24
+
25
+ class OwnedForm extends LitElement {
26
+ read?: Note = { body: 'old' }
27
+ draft = new DraftController<Note>(this, {
28
+ read: () => this.read,
29
+ key: () => 'n-1',
30
+ keep: {
31
+ address: () => {
32
+ const user = draftOwner()
33
+ return user ? { user, domain: 'd', screen: '/notes', target: 'n-1' } : undefined
34
+ }
35
+ }
36
+ })
37
+ }
38
+ customElements.define('owned-note-form', OwnedForm)
39
+
40
+ describe('draftOwner', () => {
41
+ const settle = () => new Promise(resolve => setTimeout(resolve, 50))
42
+ const keyOf = (user: string) => draftKey({ user, domain: 'd', screen: '/notes', target: 'n-1' })
43
+ const open = OxPrompt.open
44
+ let wait: number
45
+ beforeEach(async () => {
46
+ wait = UnsavedDraftController.WAIT_MS
47
+ UnsavedDraftController.WAIT_MS = 0
48
+ await removeDraftsOf('kim')
49
+ await removeDraftsOf('lee')
50
+ })
51
+ afterEach(() => {
52
+ UnsavedDraftController.WAIT_MS = wait
53
+ ;(OxPrompt as any).open = open
54
+ signIn(null)
55
+ })
56
+
57
+ it('is the signed-in id, and nobody while no one is signed in', () => {
58
+ signIn({ id: 'kim' })
59
+ expect(draftOwner()).to.equal('kim')
60
+ signIn({})
61
+ expect(draftOwner()).to.equal(undefined)
62
+ signIn(null)
63
+ expect(draftOwner()).to.equal(undefined)
64
+ })
65
+
66
+ it('a draft is written for its owner and offered back to them', async () => {
67
+ signIn({ id: 'kim' })
68
+ const form = await fixture<OwnedForm>(html`<owned-note-form></owned-note-form>`)
69
+ form.draft.set('body', 'typed')
70
+ await settle()
71
+ expect((await readDraft(keyOf('kim')))?.value).to.deep.equal({ body: 'typed' })
72
+ form.remove()
73
+
74
+ ;(OxPrompt as any).open = async () => true
75
+ const again = await fixture<OwnedForm>(html`<owned-note-form></owned-note-form>`)
76
+ again.dispatchEvent(new Event(QUERY_ANSWERED))
77
+ await settle()
78
+ expect(again.draft.value('body')).to.equal('typed')
79
+ })
80
+
81
+ it('someone else is offered nothing, and signing out takes the draft away', async () => {
82
+ signIn({ id: 'kim' })
83
+ const form = await fixture<OwnedForm>(html`<owned-note-form></owned-note-form>`)
84
+ form.draft.set('body', 'typed')
85
+ await settle()
86
+ form.remove()
87
+
88
+ signIn({ id: 'lee' })
89
+ let asked = false
90
+ ;(OxPrompt as any).open = async () => (asked = true)
91
+ const theirs = await fixture<OwnedForm>(html`<owned-note-form></owned-note-form>`)
92
+ theirs.dispatchEvent(new Event(QUERY_ANSWERED))
93
+ await settle()
94
+ expect(asked).to.equal(false)
95
+ expect(theirs.draft.value('body')).to.equal('old')
96
+
97
+ await removeDraftsOf('kim')
98
+ expect(await readDraft(keyOf('kim'))).to.equal(undefined)
99
+ })
100
+
101
+ it('with no one signed in nothing is written', async () => {
102
+ signIn(null)
103
+ const form = await fixture<OwnedForm>(html`<owned-note-form></owned-note-form>`)
104
+ form.draft.set('body', 'typed')
105
+ await settle()
106
+ expect(await readDraft(keyOf('kim'))).to.equal(undefined)
107
+ expect(form.draft.kept).to.equal(false)
108
+ })
109
+ })
@@ -0,0 +1,23 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import { html } from 'lit'
3
+
4
+ import { DraftController } from '../src/drafts/draft-controller.js'
5
+ import { PageView } from '../src/app/pages/page-view.js'
6
+
7
+ /** A page's drafts (ADR-0190) are its unsaved state: the shell asks before they are lost, and drops them on discard. */
8
+ class EditingPage extends PageView {
9
+ draft = new DraftController<{ name: string }>(this, { read: () => ({ name: 'A' }) })
10
+ }
11
+ customElements.define('editing-page', EditingPage)
12
+
13
+ describe('page drafts', () => {
14
+ it('an edited draft makes the page dirty; a discard drops it', async () => {
15
+ const page = await fixture<EditingPage>(html`<editing-page></editing-page>`)
16
+ expect(page.addressDirty).to.equal(false)
17
+ page.draft.set('name', 'A2')
18
+ expect(page.addressDirty).to.equal(true)
19
+ page.discardAddressEdits({ route: false })
20
+ expect(page.addressDirty).to.equal(false)
21
+ expect(page.draft.value('name')).to.equal('A')
22
+ })
23
+ })
@@ -0,0 +1,96 @@
1
+ import { expect, fixture } from '@open-wc/testing'
2
+ import gql from 'graphql-tag'
3
+ import { html } from 'lit'
4
+
5
+ import { QueryController } from '@operato/graphql'
6
+ import { PageView } from '../src/app/pages/page-view.js'
7
+
8
+ /** A page refresh (ADR-0174) reads again what the page declared it reads (ADR-0190), as it reads its grids again. */
9
+
10
+ const frame = () => new Promise(resolve => requestAnimationFrame(resolve))
11
+ const settle = () => new Promise(resolve => setTimeout(resolve, 20))
12
+
13
+ class DeclaringPage extends PageView {
14
+ contexts: string[] = []
15
+ get context() {
16
+ return { title: this.rule?.data?.rule?.name ?? '' }
17
+ }
18
+ updateContext() {
19
+ this.contexts.push(this.context.title)
20
+ }
21
+ rule = new QueryController(
22
+ this,
23
+ gql`
24
+ query {
25
+ rule {
26
+ name
27
+ }
28
+ }
29
+ `
30
+ )
31
+ render() {
32
+ return html`${this.rule.data?.rule?.name ?? ''}`
33
+ }
34
+ }
35
+ customElements.define('declaring-page', DeclaringPage)
36
+
37
+ describe('page refresh and declared queries', () => {
38
+ let asked = 0
39
+ let fetched: typeof window.fetch
40
+ beforeEach(() => {
41
+ asked = 0
42
+ fetched = window.fetch
43
+ window.fetch = async () => {
44
+ asked++
45
+ return new Response(JSON.stringify({ data: { rule: { name: `read ${asked}` } } }), {
46
+ headers: { 'content-type': 'application/json' }
47
+ })
48
+ }
49
+ })
50
+ afterEach(() => (window.fetch = fetched))
51
+
52
+ it('reads the declared query again', async () => {
53
+ const page = await fixture<DeclaringPage>(html`<declaring-page></declaring-page>`)
54
+ page.lifecycle = { active: true, params: {} }
55
+ page.active = true
56
+ await page.updateComplete
57
+ await frame()
58
+ await settle()
59
+ expect(asked).to.equal(1)
60
+ await page.refresh()
61
+ await settle()
62
+ expect(asked).to.equal(2)
63
+ expect(page.rule.data?.rule.name).to.equal('read 2')
64
+ })
65
+
66
+ it('says its context again once a declared query answered - a title read from the data', async () => {
67
+ const answer = window.fetch
68
+ window.fetch = async (...args) => (await new Promise(resolve => setTimeout(resolve, 150)), answer(...args))
69
+ const page = await fixture<DeclaringPage>(html`<declaring-page></declaring-page>`)
70
+ page.lifecycle = { active: true, params: {} }
71
+ page.active = true
72
+ await page.updateComplete
73
+ await frame()
74
+ expect(page.contexts[page.contexts.length - 1]).to.equal('')
75
+ await new Promise(resolve => setTimeout(resolve, 300))
76
+ expect(page.contexts[page.contexts.length - 1]).to.equal('read 1')
77
+ })
78
+
79
+ it('reads the declared query again when the page is shown again, not on its first opening', async () => {
80
+ const page = await fixture<DeclaringPage>(html`<declaring-page></declaring-page>`)
81
+ page.lifecycle = { active: true, params: {} }
82
+ page.active = true
83
+ await page.updateComplete
84
+ await frame()
85
+ await settle()
86
+ expect(asked).to.equal(1)
87
+ page.active = false
88
+ await page.updateComplete
89
+ await settle()
90
+ expect(asked).to.equal(1)
91
+ page.active = true
92
+ await page.updateComplete
93
+ await settle()
94
+ expect(asked).to.equal(2)
95
+ })
96
+ })