@things-factory/announcement 10.1.256 → 10.1.257

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.
@@ -6,17 +6,19 @@ import '@material/web/button/filled-button.js'
6
6
  import '@material/web/button/text-button.js'
7
7
  import '@operato/popup/ox-popup-actions.js'
8
8
 
9
+ import gql from 'graphql-tag'
9
10
  import { css, html, LitElement, nothing } from 'lit'
10
11
  import { customElement, property, state } from 'lit/decorators.js'
11
12
 
13
+ import { MutationController, QueryController } from '@operato/graphql'
12
14
  import { i18next, localize } from '@operato/i18n'
13
15
  import { notify } from '@operato/layout'
14
16
  import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
15
17
 
16
- import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
17
- import { reasonOf, t, when } from '../utils/announcement-words.js'
18
+ import { type AnnouncementItem } from '../utils/announcement-api.js'
19
+ import { t, when } from '../utils/announcement-words.js'
18
20
 
19
- type Row = Awaited<ReturnType<typeof announcementApi.receipts>>[number]
21
+ type Row = { userId: string; name?: string; email?: string; listed: boolean; notifiedAt?: string; readAt?: string; acknowledgedAt?: string }
20
22
 
21
23
  const FormStyles = css`
22
24
  :host {
@@ -74,21 +76,25 @@ const FormStyles = css`
74
76
  export class AnnouncementReceipts extends localize(i18next)(LitElement) {
75
77
  static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
76
78
  @property({ attribute: false }) announcement!: AnnouncementItem
77
- @state() private rows: Row[] = []
78
79
  @state() private which: 'all' | 'unread' | 'unacknowledged' | 'later' = 'all'
79
- @state() private busy = false
80
80
 
81
- connectedCallback() {
82
- super.connectedCallback()
83
- this.load()
81
+ /* Whom it reached and what each did with it, read for the announcement shown (ADR-0190). */
82
+ private receipts = new QueryController(
83
+ this,
84
+ gql`query ($id: String!) { announcementReceipts(id: $id) { userId name email listed notifiedAt readAt acknowledgedAt } }`,
85
+ { variables: () => (this.announcement?.id ? { id: this.announcement.id } : undefined) }
86
+ )
87
+
88
+ private reminder = new MutationController(this, gql`mutation ($id: String!) { remindAnnouncement(id: $id) }`, {
89
+ refetch: () => [this.receipts]
90
+ })
91
+
92
+ private get rows(): Row[] {
93
+ return this.receipts.data?.announcementReceipts ?? []
84
94
  }
85
95
 
86
- private async load() {
87
- try {
88
- this.rows = await announcementApi.receipts(this.announcement.id)
89
- } catch (error) {
90
- notify({ level: 'error', message: reasonOf(error) })
91
- }
96
+ private get busy() {
97
+ return this.reminder.pending
92
98
  }
93
99
 
94
100
  render() {
@@ -140,16 +146,8 @@ export class AnnouncementReceipts extends localize(i18next)(LitElement) {
140
146
  }
141
147
 
142
148
  private async remind() {
143
- this.busy = true
144
- try {
145
- const told = await announcementApi.remind(this.announcement.id)
146
- notify({ message: t('announcement.text.reminded', { count: told }) })
147
- await this.load()
148
- } catch (error) {
149
- notify({ level: 'error', message: reasonOf(error) })
150
- } finally {
151
- this.busy = false
152
- }
149
+ const done = await this.reminder.run({ id: this.announcement.id })
150
+ if (done) notify({ message: t('announcement.text.reminded', { count: done.remindAnnouncement }) })
153
151
  }
154
152
 
155
153
  private close() {
@@ -163,7 +161,11 @@ export class AnnouncementWithdrawForm extends localize(i18next)(LitElement) {
163
161
  static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
164
162
  @property({ attribute: false }) announcement!: AnnouncementItem
165
163
  @state() private reason = ''
166
- @state() private busy = false
164
+ private withdrawal = new MutationController(this, gql`mutation ($id: String!, $reason: String!) { withdrawAnnouncement(id: $id, reason: $reason) { id status } }`)
165
+
166
+ private get busy() {
167
+ return this.withdrawal.pending
168
+ }
167
169
 
168
170
  render() {
169
171
  return html`<div class="body">
@@ -177,17 +179,10 @@ export class AnnouncementWithdrawForm extends localize(i18next)(LitElement) {
177
179
  }
178
180
 
179
181
  private async withdraw() {
180
- this.busy = true
181
- try {
182
- await announcementApi.withdraw(this.announcement.id, this.reason)
183
- notify({ message: t('announcement.text.withdrawn-done') })
184
- this.dispatchEvent(new CustomEvent('announcement-withdrawn', { bubbles: true, composed: true }))
185
- this.close()
186
- } catch (error) {
187
- notify({ level: 'error', message: reasonOf(error) })
188
- } finally {
189
- this.busy = false
190
- }
182
+ if (!(await this.withdrawal.run({ id: this.announcement.id, reason: this.reason }))) return
183
+ notify({ message: t('announcement.text.withdrawn-done') })
184
+ this.dispatchEvent(new CustomEvent('announcement-withdrawn', { bubbles: true, composed: true }))
185
+ this.close()
191
186
  }
192
187
 
193
188
  private close() {
@@ -5,17 +5,18 @@
5
5
  import '@material/web/icon/icon.js'
6
6
  import '@material/web/button/filled-button.js'
7
7
 
8
+ import gql from 'graphql-tag'
8
9
  import { css, html, LitElement, nothing } from 'lit'
9
- import { customElement, property, state } from 'lit/decorators.js'
10
+ import { customElement, property } from 'lit/decorators.js'
10
11
  import { unsafeHTML } from 'lit/directives/unsafe-html.js'
11
12
 
13
+ import { MutationController } from '@operato/graphql'
12
14
  import { i18next, localize } from '@operato/i18n'
13
- import { notify } from '@operato/layout'
14
15
  import { navigate } from '@operato/shell'
15
16
  import { renderMarkdown } from '@things-factory/ai-assistant/dist-client/index.js'
16
17
 
17
- import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
18
- import { categoryWord, importanceWord, reasonOf, t, when } from '../utils/announcement-words.js'
18
+ import { announcementsChanged, type AnnouncementItem } from '../utils/announcement-api.js'
19
+ import { categoryWord, importanceWord, t, when } from '../utils/announcement-words.js'
19
20
 
20
21
  /** A screen of the application opens in place; an https page opens apart. */
21
22
  export function followLink(url: string) {
@@ -143,7 +144,14 @@ export class AnnouncementSheet extends localize(i18next)(LitElement) {
143
144
  `
144
145
 
145
146
  @property({ attribute: false }) announcement!: AnnouncementItem
146
- @state() private busy = false
147
+ private acknowledgement = new MutationController(
148
+ this,
149
+ gql`mutation ($id: String!) { acknowledgeAnnouncement(id: $id) { id readerState mine { readAt acknowledgedAt } } }`
150
+ )
151
+
152
+ private get busy() {
153
+ return this.acknowledgement.pending
154
+ }
147
155
 
148
156
  render() {
149
157
  const one = this.announcement
@@ -200,15 +208,10 @@ export class AnnouncementSheet extends localize(i18next)(LitElement) {
200
208
  }
201
209
 
202
210
  private async acknowledge() {
203
- this.busy = true
204
- try {
205
- const done = await announcementApi.acknowledge(this.announcement.id)
206
- this.announcement = { ...this.announcement, ...done }
207
- this.dispatchEvent(new CustomEvent('announcement-acknowledged', { bubbles: true, composed: true, detail: this.announcement }))
208
- } catch (error) {
209
- notify({ level: 'error', message: reasonOf(error) })
210
- } finally {
211
- this.busy = false
212
- }
211
+ const done = await this.acknowledgement.run({ id: this.announcement.id })
212
+ if (!done) return
213
+ announcementsChanged()
214
+ this.announcement = { ...this.announcement, ...done.acknowledgeAnnouncement }
215
+ this.dispatchEvent(new CustomEvent('announcement-acknowledged', { bubbles: true, composed: true, detail: this.announcement }))
213
216
  }
214
217
  }
@@ -6,15 +6,18 @@ import '@material/web/icon/icon.js'
6
6
  import '@operato/input/ox-select.js'
7
7
  import '@operato/popup/ox-popup-list.js'
8
8
 
9
+ import gql from 'graphql-tag'
9
10
  import { css, html, LitElement, nothing } from 'lit'
10
11
  import { customElement, property, state } from 'lit/decorators.js'
11
12
 
13
+ import { QueryController } from '@operato/graphql'
12
14
  import { i18next, localize } from '@operato/i18n'
13
15
 
14
- import { announcementApi, type Audience } from '../utils/announcement-api.js'
16
+ import { type Audience } from '../utils/announcement-api.js'
15
17
  import { t } from '../utils/announcement-words.js'
16
18
 
17
- type Choices = Awaited<ReturnType<typeof announcementApi.choices>>
19
+ type Choices = { domainId: string; domainName: string; departments: { id: string; name: string; note?: string }[]; roles: { id: string; name: string; note?: string }[] }
20
+ type Person = { id: string; name: string; note?: string }
18
21
 
19
22
  const ICON: Record<string, string> = { DOMAIN: 'domain', DEPARTMENT: 'account_tree', ROLE: 'badge', USER: 'person' }
20
23
 
@@ -113,20 +116,41 @@ export class AudiencePicker extends localize(i18next)(LitElement) {
113
116
 
114
117
  @property({ attribute: false }) value: Audience[] = []
115
118
  @property({ type: Boolean }) disabled = false
116
- @state() private choices?: Choices
117
119
  @state() private text = ''
118
- @state() private found: { id: string; name: string; note?: string }[] = []
119
- @state() private size?: number
120
+ /* The words searched for and the audience counted - each a moment after it stops changing. */
121
+ @state() private searched = ''
122
+ @state() private counted: Audience[] = []
120
123
  private searching?: ReturnType<typeof setTimeout>
121
124
  private counting?: ReturnType<typeof setTimeout>
122
125
 
123
- async connectedCallback() {
124
- super.connectedCallback()
125
- try {
126
- this.choices = await announcementApi.choices()
127
- } catch {
128
- this.choices = { domainId: '', domainName: '', departments: [], roles: [] }
129
- }
126
+ /* What may be chosen, the people the words find, and how many the audience names now (ADR-0190). */
127
+ private choicesRead = new QueryController(
128
+ this,
129
+ gql`query { announcementAudienceChoices { domainId domainName departments { id name note } roles { id name note } } }`
130
+ )
131
+
132
+ private people = new QueryController(this, gql`query ($text: String!) { announcementPeople(text: $text) { id name note } }`, {
133
+ variables: () => (this.searched ? { text: this.searched } : undefined),
134
+ keepPrevious: true
135
+ })
136
+
137
+ private sizeRead = new QueryController(this, gql`query ($audiences: [AnnouncementAudienceInput!]!) { announcementAudienceSize(audiences: $audiences) }`, {
138
+ variables: () => (this.counted.length ? { audiences: this.counted } : undefined),
139
+ keepPrevious: true
140
+ })
141
+
142
+ private get choices(): Choices | undefined {
143
+ if (this.choicesRead.error) return { domainId: '', domainName: '', departments: [], roles: [] }
144
+ return this.choicesRead.data?.announcementAudienceChoices
145
+ }
146
+
147
+ private get found(): Person[] {
148
+ return (this.searched && !this.people.error && this.people.data?.announcementPeople) || []
149
+ }
150
+
151
+ private get size(): number | undefined {
152
+ if (!this.value.length) return 0
153
+ return this.sizeRead.error ? undefined : this.sizeRead.data?.announcementAudienceSize
130
154
  }
131
155
 
132
156
  updated(changes: Map<string, unknown>) {
@@ -183,8 +207,9 @@ export class AudiencePicker extends localize(i18next)(LitElement) {
183
207
  }
184
208
 
185
209
  private give(target: Audience) {
210
+ clearTimeout(this.searching)
186
211
  this.text = ''
187
- this.found = []
212
+ this.searched = ''
188
213
  this.tell([...this.value, target])
189
214
  }
190
215
 
@@ -200,28 +225,16 @@ export class AudiencePicker extends localize(i18next)(LitElement) {
200
225
  private search(text: string) {
201
226
  this.text = text
202
227
  clearTimeout(this.searching)
203
- this.searching = setTimeout(async () => {
204
- try {
205
- this.found = text.trim() ? await announcementApi.people(text.trim()) : []
206
- } catch {
207
- this.found = []
208
- }
209
- }, 250)
228
+ this.searching = setTimeout(() => (this.searched = text.trim()), 250)
210
229
  }
211
230
 
212
231
  /** How many the audience names now - asked a moment after it stops changing. */
213
232
  private count() {
214
233
  clearTimeout(this.counting)
215
234
  if (!this.value.length) {
216
- this.size = 0
235
+ this.counted = []
217
236
  return
218
237
  }
219
- this.counting = setTimeout(async () => {
220
- try {
221
- this.size = await announcementApi.audienceSize(this.value.map(({ scope, targetId }) => ({ scope, targetId })))
222
- } catch {
223
- this.size = undefined
224
- }
225
- }, 300)
238
+ this.counting = setTimeout(() => (this.counted = this.value.map(({ scope, targetId }) => ({ scope, targetId }))), 300)
226
239
  }
227
240
  }
@@ -3,20 +3,22 @@ import '@operato/input/ox-input-file.js'
3
3
  import '@operato/list-state/ox-page-state.js'
4
4
  import '../components/audience-picker.js'
5
5
 
6
+ import gql from 'graphql-tag'
6
7
  import { css, html, nothing } from 'lit'
7
8
  import { customElement, state } from 'lit/decorators.js'
8
9
  import { unsafeHTML } from 'lit/directives/unsafe-html.js'
9
10
 
11
+ import { MutationController, QueryController, uploadContext } from '@operato/graphql'
10
12
  import { i18next, localize } from '@operato/i18n'
11
13
  import { OxPrompt } from '@operato/popup/ox-prompt.js'
12
14
  import { notify } from '@operato/layout'
13
- import { navigate, PageView } from '@operato/shell'
15
+ import { navigate, PageView, DraftController } from '@operato/shell'
14
16
  import { renderMarkdown } from '@things-factory/ai-assistant/dist-client/index.js'
15
17
  import { ButtonStyles, DocumentPageStyles, FormFieldStyles } from '@operato/styles'
16
18
 
17
19
  import { chooser } from '../components/audience-picker.js'
18
- import { announcementApi, type AnnouncementInput, type AnnouncementItem, type AnnouncementLink, type Audience, type FileRow } from '../utils/announcement-api.js'
19
- import { CATEGORIES, categoryWord, IMPORTANCES, importanceWord, reasonOf, statusWord, t } from '../utils/announcement-words.js'
20
+ import { FULL_FIELDS, type AnnouncementInput, type AnnouncementItem, type AnnouncementLink, type Audience, type FileRow } from '../utils/announcement-api.js'
21
+ import { CATEGORIES, categoryWord, IMPORTANCES, importanceWord, statusWord, t } from '../utils/announcement-words.js'
20
22
 
21
23
  type Form = {
22
24
  title: string
@@ -32,6 +34,10 @@ type Form = {
32
34
  sendEmail: boolean
33
35
  links: AnnouncementLink[]
34
36
  audiences: Audience[]
37
+ /** Files picked to attach, attached ones to take off, and whether a changed one tells everyone again. */
38
+ added: File[]
39
+ removed: string[]
40
+ notifyAgain: boolean
35
41
  }
36
42
 
37
43
  const blank = (): Form => ({
@@ -47,7 +53,10 @@ const blank = (): Form => ({
47
53
  popup: false,
48
54
  sendEmail: false,
49
55
  links: [],
50
- audiences: []
56
+ audiences: [],
57
+ added: [],
58
+ removed: [],
59
+ notifyAgain: false
51
60
  })
52
61
 
53
62
  /** `datetime-local` speaks the reader's own clock. */
@@ -74,7 +83,10 @@ function formOf(one: AnnouncementItem): Form {
74
83
  popup: one.popup,
75
84
  sendEmail: !!one.sendEmail,
76
85
  links: (one.links ?? []).map(link => ({ ...link })),
77
- audiences: (one.audiences ?? []).map(({ scope, targetId, targetName }) => ({ scope, targetId, targetName }))
86
+ audiences: (one.audiences ?? []).map(({ scope, targetId, targetName }) => ({ scope, targetId, targetName })),
87
+ added: [],
88
+ removed: [],
89
+ notifyAgain: false
78
90
  }
79
91
  }
80
92
 
@@ -200,17 +212,74 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
200
212
  `
201
213
  ]
202
214
 
203
- @state() announcement: AnnouncementItem | null = null
204
- @state() form: Form = blank()
205
- @state() files: FileRow[] = []
206
- @state() added: File[] = []
207
- @state() removed: string[] = []
208
- @state() notifyAgain = false
215
+ /** What the address opens: an announcement by id, a new one copied `from` another, or a new one. */
216
+ @state() private address?: { id?: string; from?: string }
209
217
  @state() preview = false
210
218
  @state() progress?: number
211
- @state() loading = false
212
- @state() error: unknown = null
213
- @state() busy = false
219
+
220
+ /* The announcement edited, or the one a new one is copied from (ADR-0190). */
221
+ private record = new QueryController(this, gql(`query ($id: String!) { announcement(id: $id) { ${FULL_FIELDS} readCount acknowledgedCount } }`), {
222
+ variables: () => {
223
+ const id = this.address?.id || this.address?.from
224
+ return id ? { id } : undefined
225
+ }
226
+ })
227
+
228
+ /* What is typed over the announcement read - a copy starts without its days, a new one blank. */
229
+ private draft = new DraftController<Form>(this, {
230
+ read: () => {
231
+ if (!this.address) return undefined
232
+ if (!this.address.id && !this.address.from) return blank()
233
+ const one = this.record.data?.announcement
234
+ if (!one) return undefined
235
+ return this.address.from && !this.address.id ? { ...formOf(one), startsAt: '', endsAt: '' } : formOf(one)
236
+ },
237
+ key: () => this.address
238
+ })
239
+
240
+ /* A change reads the announcement again once it is done; one that goes out or away leaves the editor. */
241
+ private saves = {
242
+ create: new MutationController(this, gql`mutation ($input: AnnouncementInput!, $attach: [Upload!]) { createAnnouncement(input: $input, attach: $attach) { id } }`),
243
+ update: new MutationController(
244
+ this,
245
+ gql`
246
+ mutation ($id: String!, $input: AnnouncementInput!, $attach: [Upload!], $remove: [String!], $notifyAgain: Boolean) {
247
+ updateAnnouncement(id: $id, input: $input, attach: $attach, remove: $remove, notifyAgain: $notifyAgain) { id status }
248
+ }
249
+ `,
250
+ { refetch: () => [this.record] }
251
+ ),
252
+ publish: new MutationController(this, gql`mutation ($id: String!) { publishAnnouncement(id: $id) { id status startsAt } }`),
253
+ remove: new MutationController(this, gql`mutation ($id: String!) { deleteAnnouncement(id: $id) }`)
254
+ }
255
+
256
+ get announcement(): AnnouncementItem | null {
257
+ return (this.address?.id && this.record.data?.announcement) || null
258
+ }
259
+
260
+ /** The form as it shows: each field's edit, else what was read (blank while it is read). */
261
+ get form(): Form {
262
+ return Object.fromEntries(Object.entries(blank()).map(([name, given]) => [name, this.draft.value(name as keyof Form) ?? given])) as Form
263
+ }
264
+
265
+ /** The files attached so far - a copy starts with none. */
266
+ get files(): FileRow[] {
267
+ return this.announcement?.attachments ?? []
268
+ }
269
+
270
+ /** A failed read, or an announcement the address names that is not there. */
271
+ get error(): unknown {
272
+ if (this.record.error) return this.record.error
273
+ return this.record.data && !this.record.data.announcement ? new Error(t('announcement.refusal.unknown-announcement')) : null
274
+ }
275
+
276
+ get loading() {
277
+ return this.record.loading
278
+ }
279
+
280
+ get busy() {
281
+ return Object.values(this.saves).some(save => save.pending)
282
+ }
214
283
 
215
284
  get out() {
216
285
  return this.announcement?.status === 'PUBLISHED'
@@ -241,52 +310,20 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
241
310
  async pageUpdated(changes: any, lifecycle: any) {
242
311
  if (!this.active || !(changes.active || changes.params)) return
243
312
  const { id, from } = lifecycle?.params ?? {}
244
- if (id) {
245
- if (id !== this.announcement?.id) await this.load(id, false)
246
- } else if (from) {
247
- await this.load(from, true)
248
- } else {
249
- this.announcement = null
250
- this.form = blank()
251
- this.files = []
252
- this.reset()
253
- this.updateContext()
254
- }
255
- }
256
-
257
- private reset() {
258
- this.added = []
259
- this.removed = []
260
- this.notifyAgain = false
313
+ const address = id ? { id } : from ? { from } : {}
314
+ if (JSON.stringify(address) === JSON.stringify(this.address)) return
315
+ this.address = address
261
316
  this.preview = false
262
317
  }
263
318
 
264
- private async load(id: string, asCopy: boolean) {
265
- this.loading = true
266
- this.error = null
267
- try {
268
- const one = await announcementApi.one(id)
269
- if (!one) throw new Error(t('announcement.refusal.unknown-announcement'))
270
- this.announcement = asCopy ? null : one
271
- this.form = asCopy ? { ...formOf(one), startsAt: '', endsAt: '' } : formOf(one)
272
- this.files = asCopy ? [] : (one.attachments ?? [])
273
- this.reset()
274
- } catch (error) {
275
- this.error = error
276
- } finally {
277
- this.loading = false
278
- this.updateContext()
279
- }
280
- }
281
-
282
319
  render() {
283
320
  const form = this.form
284
321
  const set = (patch: Partial<Form>) => {
285
- this.form = { ...form, ...patch }
322
+ for (const [name, value] of Object.entries(patch)) this.draft.set(name as keyof Form, value)
286
323
  this.updateContext()
287
324
  }
288
325
  const editable = !this.busy && (!this.announcement || !!this.announcement.editable)
289
- return html`<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => this.announcement && this.load(this.announcement.id, false)}>
326
+ return html`<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => this.record.refetch()}>
290
327
  <div class="page">
291
328
  <section class="page-section">
292
329
  <header class="page-section-header"><h3>${t('announcement.text.section-what')}</h3></header>
@@ -374,19 +411,19 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
374
411
  <header class="page-section-header"><h3>${t('announcement.field.attachments')}</h3></header>
375
412
  <ul class="files">
376
413
  ${this.files.map(
377
- file => html`<li class=${this.removed.includes(file.id) ? 'removed' : ''}>
414
+ file => html`<li class=${form.removed.includes(file.id) ? 'removed' : ''}>
378
415
  <md-icon>attach_file</md-icon>${file.name}
379
416
  ${editable
380
- ? this.removed.includes(file.id)
381
- ? html`<button @click=${() => (this.removed = this.removed.filter(id => id !== file.id))}><md-icon>undo</md-icon></button>`
382
- : html`<button aria-label=${t('announcement.button.remove')} @click=${() => (this.removed = [...this.removed, file.id])}><md-icon>close</md-icon></button>`
417
+ ? form.removed.includes(file.id)
418
+ ? html`<button @click=${() => set({ removed: form.removed.filter(id => id !== file.id) })}><md-icon>undo</md-icon></button>`
419
+ : html`<button aria-label=${t('announcement.button.remove')} @click=${() => set({ removed: [...form.removed, file.id] })}><md-icon>close</md-icon></button>`
383
420
  : nothing}
384
421
  </li>`
385
422
  )}
386
- ${this.added.map(
423
+ ${form.added.map(
387
424
  (file, index) => html`<li>
388
425
  <md-icon>upload_file</md-icon>${file.name}
389
- <button aria-label=${t('announcement.button.remove')} @click=${() => (this.added = this.added.filter((_, i) => i !== index))}><md-icon>close</md-icon></button>
426
+ <button aria-label=${t('announcement.button.remove')} @click=${() => set({ added: form.added.filter((_, i) => i !== index) })}><md-icon>close</md-icon></button>
390
427
  </li>`
391
428
  )}
392
429
  </ul>
@@ -424,7 +461,7 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
424
461
 
425
462
  ${this.out
426
463
  ? html`<section class="page-section">
427
- <label class="check"><input type="checkbox" .checked=${this.notifyAgain} @change=${(e: any) => (this.notifyAgain = e.target.checked)} />${t('announcement.text.notify-again')}</label>
464
+ <label class="check"><input type="checkbox" .checked=${form.notifyAgain} @change=${(e: any) => set({ notifyAgain: e.target.checked })} />${t('announcement.text.notify-again')}</label>
428
465
  <span class="hint">${t('announcement.text.new-version', { version: (this.announcement?.version ?? 1) + 1 })}</span>
429
466
  </section>`
430
467
  : nothing}
@@ -437,7 +474,7 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
437
474
  /* The picker's own reset says `change` again, with nothing - that one is let go. */
438
475
  if (!picked.length) return
439
476
  ;(e.target as any).reset?.()
440
- this.added = [...this.added, ...picked]
477
+ this.draft.set('added', [...this.form.added, ...picked])
441
478
  }
442
479
 
443
480
  private input(): AnnouncementInput {
@@ -459,31 +496,36 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
459
496
  }
460
497
  }
461
498
 
462
- /** Keeps it, then sends it out when asked - a draft that cannot go out stays kept with the reason told. */
499
+ /**
500
+ * Keeps it, then sends it out when asked - a draft that cannot go out stays kept, opened as one. Failures are told by
501
+ * the GraphQL link.
502
+ */
463
503
  private async save(publish: boolean) {
464
- this.busy = true
504
+ if (this.busy) return
505
+ const id = this.announcement?.id
506
+ const { added, removed, notifyAgain } = this.form
507
+ const context = added.length ? uploadContext(fraction => (this.progress = fraction)) : undefined
508
+ const saving = this.draft.submit(() =>
509
+ id
510
+ ? this.saves.update.run({ id, input: this.input(), attach: added, remove: removed, notifyAgain }, { context })
511
+ : this.saves.create.run({ input: this.input(), attach: added }, { context })
512
+ )
513
+ /* The actions wait while it is out. */
514
+ this.updateContext()
515
+ const saved = await saving
516
+ this.progress = undefined
465
517
  this.updateContext()
466
- const uploading = (fraction: number) => (this.progress = fraction)
467
- try {
468
- let id = this.announcement?.id
469
- if (id) await announcementApi.update(id, this.input(), this.added, this.removed, this.notifyAgain, uploading)
470
- else id = (await announcementApi.create(this.input(), this.added, uploading)).id
471
- if (publish) {
472
- const sent = await announcementApi.publish(id)
473
- notify({ message: sent.status === 'SCHEDULED' ? t('announcement.text.scheduled-done') : t('announcement.text.published-done') })
474
- navigate(`announcement-view?id=${id}`)
518
+ if (!saved) return
519
+ const kept = id ?? saved.createAnnouncement.id
520
+ if (publish) {
521
+ const sent = await this.saves.publish.run({ id: kept })
522
+ if (sent) {
523
+ notify({ message: sent.publishAnnouncement.status === 'SCHEDULED' ? t('announcement.text.scheduled-done') : t('announcement.text.published-done') })
524
+ navigate(`announcement-view?id=${kept}`)
475
525
  return
476
526
  }
477
- notify({ message: t('announcement.text.saved') })
478
- if (this.announcement?.id === id) await this.load(id, false)
479
- else navigate(`announcement-editor?id=${id}`)
480
- } catch (error) {
481
- notify({ level: 'error', message: reasonOf(error) })
482
- } finally {
483
- this.progress = undefined
484
- this.busy = false
485
- this.updateContext()
486
- }
527
+ } else notify({ message: t('announcement.text.saved') })
528
+ if (!id) navigate(`announcement-editor?id=${kept}`)
487
529
  }
488
530
 
489
531
  private async deleteDraft() {
@@ -494,12 +536,8 @@ export class AnnouncementEditor extends localize(i18next)(PageView) {
494
536
  cancelButton: { text: t('announcement.button.cancel') }
495
537
  })
496
538
  if (!sure) return
497
- try {
498
- await announcementApi.remove(this.announcement!.id)
499
- notify({ message: t('announcement.text.deleted') })
500
- navigate('announcement-manage-list')
501
- } catch (error) {
502
- notify({ level: 'error', message: reasonOf(error) })
503
- }
539
+ if (!(await this.saves.remove.run({ id: this.announcement!.id }))) return
540
+ notify({ message: t('announcement.text.deleted') })
541
+ navigate('announcement-manage-list')
504
542
  }
505
543
  }