@things-factory/inquiry 0.0.0-stage → 10.1.251

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 (105) hide show
  1. package/client/bootstrap.ts +27 -0
  2. package/client/components/inquiry-thread.ts +277 -0
  3. package/client/components/inquiry-todo.ts +36 -0
  4. package/client/components/inquiry-writer.ts +208 -0
  5. package/client/index.ts +2 -0
  6. package/client/inquiry-watch.ts +45 -0
  7. package/client/pages/inquiry-list.ts +218 -0
  8. package/client/route.ts +8 -0
  9. package/client/tsconfig.json +13 -0
  10. package/client/utils/inquiry-api.ts +103 -0
  11. package/client/utils/inquiry-badge.ts +46 -0
  12. package/client/utils/inquiry-words.ts +26 -0
  13. package/dist-client/bootstrap.d.ts +2 -0
  14. package/dist-client/bootstrap.js +25 -0
  15. package/dist-client/bootstrap.js.map +1 -0
  16. package/dist-client/components/inquiry-thread.d.ts +28 -0
  17. package/dist-client/components/inquiry-thread.js +303 -0
  18. package/dist-client/components/inquiry-thread.js.map +1 -0
  19. package/dist-client/components/inquiry-todo.d.ts +9 -0
  20. package/dist-client/components/inquiry-todo.js +43 -0
  21. package/dist-client/components/inquiry-todo.js.map +1 -0
  22. package/dist-client/components/inquiry-writer.d.ts +23 -0
  23. package/dist-client/components/inquiry-writer.js +230 -0
  24. package/dist-client/components/inquiry-writer.js.map +1 -0
  25. package/dist-client/index.d.ts +2 -0
  26. package/dist-client/index.js +3 -0
  27. package/dist-client/index.js.map +1 -0
  28. package/dist-client/inquiry-watch.d.ts +2 -0
  29. package/dist-client/inquiry-watch.js +41 -0
  30. package/dist-client/inquiry-watch.js.map +1 -0
  31. package/dist-client/pages/inquiry-list.d.ts +56 -0
  32. package/dist-client/pages/inquiry-list.js +241 -0
  33. package/dist-client/pages/inquiry-list.js.map +1 -0
  34. package/dist-client/route.d.ts +1 -0
  35. package/dist-client/route.js +9 -0
  36. package/dist-client/route.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -0
  38. package/dist-client/utils/inquiry-api.d.ts +73 -0
  39. package/dist-client/utils/inquiry-api.js +34 -0
  40. package/dist-client/utils/inquiry-api.js.map +1 -0
  41. package/dist-client/utils/inquiry-badge.d.ts +4 -0
  42. package/dist-client/utils/inquiry-badge.js +47 -0
  43. package/dist-client/utils/inquiry-badge.js.map +1 -0
  44. package/dist-client/utils/inquiry-words.d.ts +13 -0
  45. package/dist-client/utils/inquiry-words.js +22 -0
  46. package/dist-client/utils/inquiry-words.js.map +1 -0
  47. package/dist-server/attachment-reader.d.ts +1 -0
  48. package/dist-server/attachment-reader.js +21 -0
  49. package/dist-server/attachment-reader.js.map +1 -0
  50. package/dist-server/index.d.ts +2 -0
  51. package/dist-server/index.js +22 -0
  52. package/dist-server/index.js.map +1 -0
  53. package/dist-server/inquiry-activity.d.ts +38 -0
  54. package/dist-server/inquiry-activity.js +38 -0
  55. package/dist-server/inquiry-activity.js.map +1 -0
  56. package/dist-server/role-tasks.d.ts +2 -0
  57. package/dist-server/role-tasks.js +14 -0
  58. package/dist-server/role-tasks.js.map +1 -0
  59. package/dist-server/service/index.d.ts +7 -0
  60. package/dist-server/service/index.js +24 -0
  61. package/dist-server/service/index.js.map +1 -0
  62. package/dist-server/service/inquiry/index.d.ts +4 -0
  63. package/dist-server/service/inquiry/index.js +8 -0
  64. package/dist-server/service/inquiry/index.js.map +1 -0
  65. package/dist-server/service/inquiry/inquiry-resolver.d.ts +29 -0
  66. package/dist-server/service/inquiry/inquiry-resolver.js +407 -0
  67. package/dist-server/service/inquiry/inquiry-resolver.js.map +1 -0
  68. package/dist-server/service/inquiry/inquiry-rules.d.ts +88 -0
  69. package/dist-server/service/inquiry/inquiry-rules.js +157 -0
  70. package/dist-server/service/inquiry/inquiry-rules.js.map +1 -0
  71. package/dist-server/service/inquiry/inquiry-service.d.ts +35 -0
  72. package/dist-server/service/inquiry/inquiry-service.js +143 -0
  73. package/dist-server/service/inquiry/inquiry-service.js.map +1 -0
  74. package/dist-server/service/inquiry/inquiry-types.d.ts +18 -0
  75. package/dist-server/service/inquiry/inquiry-types.js +69 -0
  76. package/dist-server/service/inquiry/inquiry-types.js.map +1 -0
  77. package/dist-server/service/inquiry/inquiry.d.ts +48 -0
  78. package/dist-server/service/inquiry/inquiry.js +232 -0
  79. package/dist-server/service/inquiry/inquiry.js.map +1 -0
  80. package/dist-server/tsconfig.tsbuildinfo +1 -0
  81. package/package.json +51 -4
  82. package/server/attachment-reader.ts +18 -0
  83. package/server/index.ts +18 -0
  84. package/server/inquiry-activity.ts +35 -0
  85. package/server/role-tasks.ts +13 -0
  86. package/server/service/index.ts +19 -0
  87. package/server/service/inquiry/index.ts +5 -0
  88. package/server/service/inquiry/inquiry-resolver.ts +321 -0
  89. package/server/service/inquiry/inquiry-rules.ts +173 -0
  90. package/server/service/inquiry/inquiry-service.ts +143 -0
  91. package/server/service/inquiry/inquiry-types.ts +45 -0
  92. package/server/service/inquiry/inquiry.ts +180 -0
  93. package/server/tsconfig.json +11 -0
  94. package/test/inquiry-persistence.test.ts +218 -0
  95. package/test/inquiry-rules.test.ts +121 -0
  96. package/test/open-doors.test.ts +44 -0
  97. package/test/translations.test.ts +53 -0
  98. package/things-factory.config.js +8 -0
  99. package/translations/en.json +93 -0
  100. package/translations/es.json +93 -0
  101. package/translations/ja.json +93 -0
  102. package/translations/ko.json +93 -0
  103. package/translations/ms.json +93 -0
  104. package/translations/zh.json +93 -0
  105. package/README.md +0 -3
@@ -0,0 +1,27 @@
1
+ import './components/inquiry-todo.js'
2
+
3
+ import { store } from '@operato/shell'
4
+ import { auth } from '@things-factory/auth-base/dist-client/auth.js'
5
+ import { ADD_USER_MENU } from '@things-factory/apptool-base/client'
6
+
7
+ import { openInquiryWriter } from './components/inquiry-writer.js'
8
+ import { startInquiryWatch, stopInquiryWatch } from './inquiry-watch.js'
9
+
10
+ /*
11
+ * The way to write one is in the menu the header's avatar opens (ADR-0184 decision 9) - on every screen of every
12
+ * application, carrying the screen it was opened on. The badge follows the person.
13
+ */
14
+ export default function bootstrap() {
15
+ store.dispatch({
16
+ type: ADD_USER_MENU,
17
+ item: {
18
+ name: 'inquiry',
19
+ icon: 'contact_support',
20
+ label: 'inquiry.button.send',
21
+ order: 30,
22
+ action: () => openInquiryWriter()
23
+ }
24
+ })
25
+ auth.on('profile', () => startInquiryWatch())
26
+ auth.on('presignout', () => stopInquiryWatch())
27
+ }
@@ -0,0 +1,277 @@
1
+ /*
2
+ * One inquiry as its thread (ADR-0184 decisions 2-4): what was asked, from which screen and application, the files, and
3
+ * the replies between its writer and the handlers. Below it, the reply box and the moves the person may make.
4
+ */
5
+ import '@material/web/icon/icon.js'
6
+ import '@material/web/button/filled-button.js'
7
+ import '@material/web/button/outlined-button.js'
8
+ import '@material/web/button/text-button.js'
9
+ import '@operato/input/ox-input-file.js'
10
+
11
+ import { css, html, LitElement, nothing } from 'lit'
12
+ import { customElement, property, state } from 'lit/decorators.js'
13
+
14
+ import { i18next, localize } from '@operato/i18n'
15
+ import { notify } from '@operato/layout'
16
+ import { ButtonStyles, FormFieldStyles } from '@operato/styles'
17
+
18
+ import { inquiriesChanged, inquiryApi, type FileRow, type InquiryItem } from '../utils/inquiry-api.js'
19
+ import { KIND_ICONS, kindWord, reasonOf, statusWord, t, visibilityWord, when } from '../utils/inquiry-words.js'
20
+
21
+ /** The words of a move button - a move to CLOSED by its writer and by a handler read the same. */
22
+ const moveWord = (status: string) => t(`inquiry.move.${status}`, { defaultValue: statusWord(status) })
23
+
24
+ @customElement('inquiry-thread')
25
+ export class InquiryThread extends localize(i18next)(LitElement) {
26
+ static styles = [
27
+ FormFieldStyles,
28
+ ButtonStyles,
29
+ css`
30
+ :host {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--spacing-medium, 8px);
34
+ color: var(--md-sys-color-on-surface);
35
+ }
36
+ h2 {
37
+ margin: 0;
38
+ font-size: var(--ui-t-title, 16px);
39
+ }
40
+ .meta {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ align-items: center;
44
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
45
+ font-size: var(--ui-t-small, 12px);
46
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
47
+ }
48
+ .chip {
49
+ display: inline-flex;
50
+ align-items: center;
51
+ gap: 2px;
52
+ padding: 0 var(--spacing-small, 4px);
53
+ border-radius: var(--border-radius, 4px);
54
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
55
+ line-height: 1.6;
56
+ }
57
+ .chip md-icon {
58
+ --md-icon-size: 14px;
59
+ }
60
+ .chip.status {
61
+ color: var(--md-sys-color-primary);
62
+ border-color: currentColor;
63
+ }
64
+ .text {
65
+ white-space: pre-wrap;
66
+ word-break: break-word;
67
+ line-height: 1.6;
68
+ }
69
+ .context {
70
+ font-size: var(--ui-t-small, 12px);
71
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
72
+ word-break: break-all;
73
+ }
74
+ .context a {
75
+ color: inherit;
76
+ }
77
+ ul.files {
78
+ margin: 0;
79
+ padding: 0;
80
+ list-style: none;
81
+ display: flex;
82
+ flex-wrap: wrap;
83
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
84
+ font-size: var(--ui-t-small, 12px);
85
+ }
86
+ ul.files a {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 2px;
90
+ color: var(--md-sys-color-primary);
91
+ }
92
+ ul.files md-icon {
93
+ --md-icon-size: 14px;
94
+ }
95
+ ol.replies {
96
+ margin: 0;
97
+ padding: 0;
98
+ list-style: none;
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: var(--spacing-medium, 8px);
102
+ }
103
+ ol.replies li {
104
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
105
+ border-radius: var(--border-radius, 4px);
106
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 4px;
110
+ }
111
+ ol.replies li.handler {
112
+ background: var(--ui-panel-2, var(--md-sys-color-surface-container));
113
+ }
114
+ ol.replies li.answer {
115
+ border-color: var(--md-sys-color-primary);
116
+ }
117
+ .move {
118
+ font-size: var(--ui-t-small, 12px);
119
+ color: var(--md-sys-color-primary);
120
+ font-weight: 600;
121
+ }
122
+ .box {
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: var(--spacing-small, 4px);
126
+ }
127
+ textarea {
128
+ min-height: 96px;
129
+ font-family: inherit;
130
+ box-sizing: border-box;
131
+ width: 100%;
132
+ }
133
+ .hint {
134
+ font-size: var(--ui-t-small, 12px);
135
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
136
+ }
137
+ .row {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ align-items: center;
141
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
142
+ }
143
+ .row .spacer {
144
+ flex: 1;
145
+ }
146
+ `
147
+ ]
148
+
149
+ @property() inquiryId = ''
150
+ @state() one: InquiryItem | null = null
151
+ @state() missing = false
152
+ @state() text = ''
153
+ @state() files: File[] = []
154
+ @state() busy = false
155
+ /* Set only while files go up - the picker shows its bar then. */
156
+ @state() progress?: number
157
+
158
+ updated(changes: Map<string, unknown>) {
159
+ if (changes.has('inquiryId')) this.load(true)
160
+ }
161
+
162
+ async load(opened = false) {
163
+ if (!this.inquiryId) return
164
+ try {
165
+ this.one = await inquiryApi.one(this.inquiryId)
166
+ this.missing = !this.one
167
+ /* Its writer opening it reads the handlers' words - the badge counts again. */
168
+ if (opened && this.one?.mine && this.one.isNew) {
169
+ await inquiryApi.read(this.one.id)
170
+ inquiriesChanged()
171
+ }
172
+ } catch (error) {
173
+ notify({ level: 'error', message: reasonOf(error) })
174
+ }
175
+ }
176
+
177
+ render() {
178
+ if (this.missing) return html`<p class="hint">${t('inquiry.text.not-found')}</p>`
179
+ const one = this.one
180
+ if (!one) return nothing
181
+ const replies = one.replies ?? []
182
+ const handlerMoves = (one.moves ?? []).filter(status => status !== 'CLOSED')
183
+ return html`
184
+ <div class="meta">
185
+ <span class="chip"><md-icon>${KIND_ICONS[one.kind] ?? 'help'}</md-icon>${kindWord(one.kind)}</span>
186
+ <span class="chip status">${statusWord(one.status)}</span>
187
+ ${one.visibility === 'PRIVATE' ? html`<span class="chip"><md-icon>lock</md-icon>${visibilityWord(one.visibility)}</span>` : nothing}
188
+ <span>${one.creator?.name ?? ''} · ${when(one.createdAt)}</span>
189
+ ${one.assignee ? html`<span>${t('inquiry.text.assignee', { name: one.assignee.name ?? '' })}</span>` : nothing}
190
+ </div>
191
+ <h2>${one.title}</h2>
192
+ ${one.body ? html`<div class="text">${one.body}</div>` : nothing} ${this.renderFiles(one.attachments)}
193
+ <div class="context">
194
+ ${one.pageUrl ? html`<div>${t('inquiry.field.page')}: ${/^https?:\/\//i.test(one.pageUrl) ? html`<a href=${one.pageUrl} target="_blank" rel="noopener">${one.pageUrl}</a>` : one.pageUrl}</div>` : nothing}
195
+ ${one.appName ? html`<div>${t('inquiry.field.app')}: ${one.appName}${one.appVersion ? ` ${one.appVersion}` : ''}</div>` : nothing}
196
+ </div>
197
+
198
+ ${replies.length
199
+ ? html`<ol class="replies">
200
+ ${replies.map(
201
+ reply => html`<li class=${[reply.byHandler ? 'handler' : '', reply.answer ? 'answer' : ''].join(' ')}>
202
+ <div class="meta">
203
+ <span>${reply.creator?.name ?? ''}</span>
204
+ ${reply.byHandler ? html`<span class="chip">${t('inquiry.text.handler')}</span>` : nothing}
205
+ ${reply.answer ? html`<span class="chip status"><md-icon>check</md-icon>${t('inquiry.text.answer')}</span>` : nothing}
206
+ <span>${when(reply.createdAt)}</span>
207
+ </div>
208
+ ${reply.status && !reply.answer ? html`<span class="move">${t('inquiry.text.moved', { status: statusWord(reply.status) })}</span>` : nothing}
209
+ ${reply.body ? html`<div class="text">${reply.body}</div>` : nothing} ${this.renderFiles(reply.attachments)}
210
+ </li>`
211
+ )}
212
+ </ol>`
213
+ : nothing}
214
+ ${one.canReply ? this.renderBox(one, handlerMoves) : one.status === 'CLOSED' ? html`<p class="hint">${t('inquiry.text.closed-note')}</p>` : nothing}
215
+ `
216
+ }
217
+
218
+ private renderFiles(files?: FileRow[]) {
219
+ if (!files?.length) return nothing
220
+ return html`<ul class="files">
221
+ ${files.map(file => html`<li><a href=${file.fullpath} target="_blank" rel="noopener" download=${file.name}><md-icon>attach_file</md-icon>${file.name}</a></li>`)}
222
+ </ul>`
223
+ }
224
+
225
+ private renderBox(one: InquiryItem, handlerMoves: string[]) {
226
+ const said = !!this.text.trim() || this.files.length > 0
227
+ const canClose = (one.moves ?? []).includes('CLOSED')
228
+ return html`<div class="box">
229
+ <textarea .value=${this.text} placeholder=${t(one.asHandler ? 'inquiry.text.reply-placeholder-handler' : 'inquiry.text.reply-placeholder')} @input=${(e: any) => (this.text = e.target.value)}></textarea>
230
+ <div class="row">
231
+ ${this.files.map(file => html`<span class="hint">${file.name}</span>`)}
232
+ <ox-input-file quiet hide-filelist multiple icon="attach_file" .progress=${this.progress} @change=${(e: CustomEvent) => this.pick(e)}></ox-input-file>
233
+ <span class="spacer"></span>
234
+ ${one.asHandler && !one.assignee ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.act(() => inquiryApi.take(one.id))}>${t('inquiry.button.take')}</md-text-button>` : nothing}
235
+ ${one.asHandler
236
+ ? handlerMoves.map(status => html`<md-text-button ?disabled=${this.busy} @click=${() => this.move(one, status)}>${moveWord(status)}</md-text-button>`)
237
+ : nothing}
238
+ ${canClose ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.move(one, 'CLOSED')}>${moveWord('CLOSED')}</md-text-button>` : nothing}
239
+ <md-outlined-button ?disabled=${!said || this.busy} @click=${() => this.reply(one, false)}>${t('inquiry.button.reply')}</md-outlined-button>
240
+ ${one.asHandler ? html`<md-filled-button ?disabled=${!said || this.busy} @click=${() => this.reply(one, true)}>${t('inquiry.button.answer')}</md-filled-button>` : nothing}
241
+ </div>
242
+ <span class="hint">${t('inquiry.text.move-hint')}</span>
243
+ </div>`
244
+ }
245
+
246
+ private pick(e: CustomEvent) {
247
+ const picked = [...((e.detail as File[]) ?? [])]
248
+ if (!picked.length) return
249
+ ;(e.target as any).reset?.()
250
+ this.files = [...this.files, ...picked]
251
+ }
252
+
253
+ private async act(work: () => Promise<unknown>) {
254
+ this.busy = true
255
+ try {
256
+ await work()
257
+ this.text = ''
258
+ this.files = []
259
+ inquiriesChanged()
260
+ await this.load()
261
+ } catch (error) {
262
+ notify({ level: 'error', message: reasonOf(error) })
263
+ } finally {
264
+ this.busy = false
265
+ this.progress = undefined
266
+ }
267
+ }
268
+
269
+ private reply(one: InquiryItem, asAnswer: boolean) {
270
+ return this.act(() => inquiryApi.reply(one.id, this.text.trim(), asAnswer, this.files, fraction => (this.progress = fraction)))
271
+ }
272
+
273
+ /* What is written in the box goes with the move as its reason. */
274
+ private move(one: InquiryItem, status: string) {
275
+ return this.act(() => inquiryApi.move(one.id, status, this.text.trim() || undefined))
276
+ }
277
+ }
@@ -0,0 +1,36 @@
1
+ import '@material/web/button/filled-button.js'
2
+
3
+ import { css, html, LitElement } from 'lit'
4
+ import { customElement, property } from 'lit/decorators.js'
5
+
6
+ import { i18next } from '@operato/i18n'
7
+ import { navigate } from '@operato/shell'
8
+
9
+ /*
10
+ * 문의 처리의 할 일 (ADR-0184 decision 7) - what the worklist to-do shows: the way to the inquiry. Answering, deciding or
11
+ * closing it there closes the to-do; nothing is answered here.
12
+ */
13
+ @customElement('inquiry-todo')
14
+ export class InquiryTodo extends LitElement {
15
+ static styles = css`
16
+ :host {
17
+ display: block;
18
+ padding: var(--spacing-large, 12px);
19
+ font-size: var(--ui-t-body, 13px);
20
+ line-height: 1.6;
21
+ color: var(--ui-ink);
22
+ }
23
+
24
+ p {
25
+ margin: 0 0 var(--spacing-medium, 8px);
26
+ }
27
+ `
28
+
29
+ @property({ attribute: false }) input: { inquiryId?: string } = {}
30
+
31
+ render() {
32
+ const id = this.input?.inquiryId ?? ''
33
+ return html`<p>${i18next.t('inquiry.text.todo-body')}</p>
34
+ <md-filled-button ?disabled=${!id} @click=${() => navigate(`inquiry-list?id=${id}`)}>${i18next.t('inquiry.button.open')}</md-filled-button>`
35
+ }
36
+ }
@@ -0,0 +1,208 @@
1
+ /*
2
+ * 문의 · 제안 보내기 (ADR-0184 decisions 1, 4, 5, 9) - the window the avatar menu opens on any screen: the kind, the title,
3
+ * the words, files, and who reads it. The screen it was opened on goes with it; the application, its version, the
4
+ * writer and the domain are the server's to take.
5
+ */
6
+ import '@material/web/icon/icon.js'
7
+ import '@material/web/button/filled-button.js'
8
+ import '@material/web/button/text-button.js'
9
+ import '@operato/input/ox-input-file.js'
10
+
11
+ import { css, html, LitElement, nothing } from 'lit'
12
+ import { customElement, property, state } from 'lit/decorators.js'
13
+
14
+ import { i18next, localize } from '@operato/i18n'
15
+ import { notify, openPopup } from '@operato/layout'
16
+ import { ButtonStyles, FormFieldStyles } from '@operato/styles'
17
+
18
+ import { inquiriesChanged, inquiryApi } from '../utils/inquiry-api.js'
19
+ import { KIND_ICONS, KINDS, kindWord, reasonOf, t } from '../utils/inquiry-words.js'
20
+
21
+ const MAX_TITLE = 200
22
+
23
+ /** Opens the window, carrying the screen it is opened on. */
24
+ export function openInquiryWriter(kind = 'QUESTION') {
25
+ const pageUrl = location.href
26
+ const handle: any = openPopup(html`<inquiry-writer .kind=${kind} .pageUrl=${pageUrl} @done=${() => handle.close()}></inquiry-writer>`, {
27
+ backdrop: true,
28
+ size: 'large',
29
+ title: t('inquiry.title.write')
30
+ })
31
+ return handle
32
+ }
33
+
34
+ @customElement('inquiry-writer')
35
+ export class InquiryWriter extends localize(i18next)(LitElement) {
36
+ static styles = [
37
+ FormFieldStyles,
38
+ ButtonStyles,
39
+ css`
40
+ :host {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: var(--spacing-medium, 8px);
44
+ padding: var(--spacing-large, 12px);
45
+ color: var(--md-sys-color-on-surface);
46
+ overflow: auto;
47
+ }
48
+ label {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 2px;
52
+ }
53
+ label.check {
54
+ flex-direction: row;
55
+ align-items: center;
56
+ gap: var(--spacing-small, 4px);
57
+ }
58
+ .kinds {
59
+ display: inline-flex;
60
+ gap: 4px;
61
+ padding: 3px;
62
+ border-radius: 10px;
63
+ background: var(--ui-panel-2, var(--md-sys-color-surface-container));
64
+ align-self: flex-start;
65
+ }
66
+ .kinds button {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 4px;
70
+ padding: 4px 14px;
71
+ border: 0;
72
+ border-radius: 8px;
73
+ background: transparent;
74
+ box-shadow: none;
75
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
76
+ font: inherit;
77
+ font-weight: 600;
78
+ cursor: pointer;
79
+ }
80
+ .kinds button[aria-pressed='true'] {
81
+ background: var(--ui-panel, var(--md-sys-color-surface-container-lowest));
82
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
83
+ }
84
+ .kinds md-icon {
85
+ --md-icon-size: 16px;
86
+ }
87
+ textarea {
88
+ min-height: 160px;
89
+ font-family: inherit;
90
+ box-sizing: border-box;
91
+ width: 100%;
92
+ }
93
+ .hint {
94
+ font-size: var(--ui-t-small, 12px);
95
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
96
+ word-break: break-all;
97
+ }
98
+ .files {
99
+ display: flex;
100
+ flex-wrap: wrap;
101
+ gap: var(--spacing-small, 4px);
102
+ align-items: center;
103
+ }
104
+ .file {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ gap: 2px;
108
+ padding: 0 var(--spacing-small, 4px);
109
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
110
+ border-radius: var(--border-radius, 4px);
111
+ font-size: var(--ui-t-small, 12px);
112
+ }
113
+ .file button {
114
+ border: 0;
115
+ background: transparent;
116
+ box-shadow: none;
117
+ padding: 0;
118
+ cursor: pointer;
119
+ color: inherit;
120
+ }
121
+ .file md-icon {
122
+ --md-icon-size: 14px;
123
+ }
124
+ .buttons {
125
+ display: flex;
126
+ justify-content: flex-end;
127
+ gap: var(--spacing-medium, 8px);
128
+ }
129
+ `
130
+ ]
131
+
132
+ @property() kind = 'QUESTION'
133
+ @property() pageUrl = ''
134
+ @state() title = ''
135
+ @state() body = ''
136
+ @state() privately = false
137
+ @state() files: File[] = []
138
+ @state() sending = false
139
+ /* Set only while files go up - the picker shows its bar then. */
140
+ @state() progress?: number
141
+
142
+ render() {
143
+ const title = this.title.trim()
144
+ return html`
145
+ <div class="kinds" role="group" aria-label=${t('inquiry.field.kind')}>
146
+ ${KINDS.map(
147
+ code => html`<button aria-pressed=${String(this.kind === code)} @click=${() => (this.kind = code)}><md-icon>${KIND_ICONS[code]}</md-icon>${kindWord(code)}</button>`
148
+ )}
149
+ </div>
150
+ <span class="hint">${t(`inquiry.text.kind-hint-${this.kind.toLowerCase()}`)}</span>
151
+ <label
152
+ >${t('inquiry.field.title')}
153
+ <input .value=${this.title} maxlength=${MAX_TITLE} @input=${(e: any) => (this.title = e.target.value)} />
154
+ <span class="hint">${t('inquiry.text.title-limit', { count: this.title.length, max: MAX_TITLE })}</span>
155
+ </label>
156
+ <label
157
+ >${t('inquiry.field.body')}
158
+ <textarea .value=${this.body} placeholder=${t(`inquiry.text.body-placeholder-${this.kind.toLowerCase()}`)} @input=${(e: any) => (this.body = e.target.value)}></textarea>
159
+ </label>
160
+ <div class="files">
161
+ ${this.files.map(
162
+ (file, index) =>
163
+ html`<span class="file"
164
+ ><md-icon>attach_file</md-icon>${file.name}<button aria-label=${t('inquiry.button.remove')} @click=${() => (this.files = this.files.filter((_, i) => i !== index))}>
165
+ <md-icon>close</md-icon>
166
+ </button></span
167
+ >`
168
+ )}
169
+ <ox-input-file quiet hide-filelist multiple icon="attach_file" .progress=${this.progress} @change=${(e: CustomEvent) => this.pick(e)}></ox-input-file>
170
+ </div>
171
+ <label class="check"><input type="checkbox" .checked=${this.privately} @change=${(e: any) => (this.privately = e.target.checked)} />${t('inquiry.text.private-label')}</label>
172
+ <span class="hint">${this.privately ? t('inquiry.text.private-hint') : t('inquiry.text.domain-hint')}</span>
173
+ ${this.pageUrl ? html`<span class="hint">${t('inquiry.text.page-hint', { page: this.pageUrl })}</span>` : nothing}
174
+ <div class="buttons">
175
+ <md-text-button @click=${() => this.dispatchEvent(new CustomEvent('done'))}>${t('inquiry.button.cancel')}</md-text-button>
176
+ <md-filled-button ?disabled=${!title || this.sending} @click=${() => this.send()}>${t('inquiry.button.submit')}</md-filled-button>
177
+ </div>
178
+ `
179
+ }
180
+
181
+ private pick(e: CustomEvent) {
182
+ const picked = [...((e.detail as File[]) ?? [])]
183
+ /* The picker's own reset says `change` again, with nothing - that one is let go. */
184
+ if (!picked.length) return
185
+ ;(e.target as any).reset?.()
186
+ this.files = [...this.files, ...picked]
187
+ }
188
+
189
+ private async send() {
190
+ this.sending = true
191
+ try {
192
+ await inquiryApi.create(
193
+ { kind: this.kind, title: this.title.trim(), body: this.body, visibility: this.privately ? 'PRIVATE' : 'DOMAIN', pageUrl: this.pageUrl },
194
+ this.files,
195
+ fraction => (this.progress = fraction)
196
+ )
197
+ inquiriesChanged()
198
+ notify({ message: t('inquiry.text.sent') })
199
+ /* The writer stays on the screen they were on - the answer comes as a notice and on the menu's badge. */
200
+ this.dispatchEvent(new CustomEvent('done'))
201
+ } catch (error) {
202
+ notify({ level: 'error', message: reasonOf(error) })
203
+ } finally {
204
+ this.sending = false
205
+ this.progress = undefined
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,2 @@
1
+ export { generateInquiryBadge, refreshInquiryBadge } from './utils/inquiry-badge.js'
2
+ export { openInquiryWriter } from './components/inquiry-writer.js'
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Keeps the menu badge up with inquiries while the application is open (ADR-0184 decision 8). The notice of an inquiry
3
+ * reaches the screen through notification's subscription; the count itself is asked of the server.
4
+ */
5
+ import gql from 'graphql-tag'
6
+
7
+ import { subscribe } from '@operato/graphql'
8
+
9
+ import { refreshInquiryBadge } from './utils/inquiry-badge.js'
10
+
11
+ window.addEventListener('inquiries-changed', () => refreshInquiryBadge())
12
+ let subscription: { unsubscribe(): void } | undefined
13
+ let watching = false
14
+
15
+ export async function startInquiryWatch(): Promise<void> {
16
+ if (watching) return
17
+ watching = true
18
+ refreshInquiryBadge()
19
+ try {
20
+ subscription = await subscribe(
21
+ {
22
+ query: gql`
23
+ subscription {
24
+ notification(subjects: ["info"]) {
25
+ type
26
+ }
27
+ }
28
+ `
29
+ },
30
+ {
31
+ next({ data }: any) {
32
+ if (data?.notification?.type === 'inquiry') refreshInquiryBadge()
33
+ }
34
+ }
35
+ )
36
+ } catch {
37
+ /* without the live line the badge comes at the next sign-in or the next change on a screen */
38
+ }
39
+ }
40
+
41
+ export function stopInquiryWatch(): void {
42
+ subscription?.unsubscribe()
43
+ subscription = undefined
44
+ watching = false
45
+ }