@things-factory/inquiry 10.1.253 → 10.1.254
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/client/bootstrap.ts +0 -27
- package/client/components/inquiry-thread.ts +0 -277
- package/client/components/inquiry-todo.ts +0 -36
- package/client/components/inquiry-writer.ts +0 -212
- package/client/index.ts +0 -2
- package/client/inquiry-watch.ts +0 -45
- package/client/pages/inquiry-list.ts +0 -218
- package/client/route.ts +0 -8
- package/client/tsconfig.json +0 -13
- package/client/utils/inquiry-api.ts +0 -103
- package/client/utils/inquiry-badge.ts +0 -46
- package/client/utils/inquiry-words.ts +0 -26
- package/server/attachment-reader.ts +0 -18
- package/server/index.ts +0 -18
- package/server/inquiry-activity.ts +0 -35
- package/server/role-tasks.ts +0 -13
- package/server/service/index.ts +0 -19
- package/server/service/inquiry/index.ts +0 -5
- package/server/service/inquiry/inquiry-resolver.ts +0 -321
- package/server/service/inquiry/inquiry-rules.ts +0 -173
- package/server/service/inquiry/inquiry-service.ts +0 -143
- package/server/service/inquiry/inquiry-types.ts +0 -45
- package/server/service/inquiry/inquiry.ts +0 -180
- package/server/tsconfig.json +0 -11
- package/test/inquiry-persistence.test.ts +0 -218
- package/test/inquiry-rules.test.ts +0 -121
- package/test/open-doors.test.ts +0 -44
- package/test/translations.test.ts +0 -53
- package/test/writer-popup-standard.test.ts +0 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/inquiry",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.254",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
"@operato/shell": "^10.29.39",
|
|
37
37
|
"@operato/styles": "^10.29.1",
|
|
38
38
|
"@operato/utils": "^10.0.0",
|
|
39
|
-
"@things-factory/apptool-base": "^10.1.
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
39
|
+
"@things-factory/apptool-base": "^10.1.254",
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.254",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.254",
|
|
42
42
|
"@things-factory/env": "^10.1.149",
|
|
43
|
-
"@things-factory/i18n-base": "^10.1.
|
|
44
|
-
"@things-factory/notification": "^10.1.
|
|
45
|
-
"@things-factory/shell": "^10.1.
|
|
46
|
-
"@things-factory/worklist": "^10.1.
|
|
43
|
+
"@things-factory/i18n-base": "^10.1.254",
|
|
44
|
+
"@things-factory/notification": "^10.1.254",
|
|
45
|
+
"@things-factory/shell": "^10.1.254",
|
|
46
|
+
"@things-factory/worklist": "^10.1.254",
|
|
47
47
|
"graphql-scalars": "^1.22.0",
|
|
48
48
|
"graphql-tag": "^2.12.6",
|
|
49
49
|
"graphql-upload": "^15.0.2",
|
|
50
50
|
"lit": "^3.1.2"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "793e2fd617db5bb917ec892e64956fb690f7e7b0"
|
|
53
53
|
}
|
package/client/bootstrap.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,277 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,212 +0,0 @@
|
|
|
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
|
-
import '@operato/input/ox-input-select-buttons.js'
|
|
11
|
-
import '@operato/popup/ox-popup-actions.js'
|
|
12
|
-
|
|
13
|
-
import { css, html, LitElement, nothing } from 'lit'
|
|
14
|
-
import { customElement, property, state } from 'lit/decorators.js'
|
|
15
|
-
|
|
16
|
-
import { i18next, localize } from '@operato/i18n'
|
|
17
|
-
import { notify, openPopup } from '@operato/layout'
|
|
18
|
-
import { CommonPopupStyles, FormFieldStyles, PopupBodyStyles, PopupFieldStyles } from '@operato/styles'
|
|
19
|
-
|
|
20
|
-
import { inquiriesChanged, inquiryApi } from '../utils/inquiry-api.js'
|
|
21
|
-
import { KINDS, kindWord, reasonOf, t } from '../utils/inquiry-words.js'
|
|
22
|
-
|
|
23
|
-
const MAX_TITLE = 200
|
|
24
|
-
|
|
25
|
-
/** Opens the window, carrying the screen it is opened on. */
|
|
26
|
-
export function openInquiryWriter(kind = 'QUESTION') {
|
|
27
|
-
const pageUrl = location.href
|
|
28
|
-
const handle: any = openPopup(
|
|
29
|
-
html`<inquiry-writer .kind=${kind} .pageUrl=${pageUrl} @close=${() => handle.close()}></inquiry-writer>`,
|
|
30
|
-
{
|
|
31
|
-
backdrop: true,
|
|
32
|
-
size: 'large',
|
|
33
|
-
title: t('inquiry.title.write')
|
|
34
|
-
}
|
|
35
|
-
)
|
|
36
|
-
return handle
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
@customElement('inquiry-writer')
|
|
40
|
-
export class InquiryWriter extends localize(i18next)(LitElement) {
|
|
41
|
-
/* The house shape of a popup (page-standard sections 7, 8): only the body scrolls, fields are labels above native
|
|
42
|
-
inputs, the one button row is `ox-popup-actions`. */
|
|
43
|
-
static styles = [
|
|
44
|
-
CommonPopupStyles,
|
|
45
|
-
PopupBodyStyles,
|
|
46
|
-
FormFieldStyles,
|
|
47
|
-
PopupFieldStyles,
|
|
48
|
-
css`
|
|
49
|
-
ox-input-select-buttons {
|
|
50
|
-
align-self: flex-start;
|
|
51
|
-
}
|
|
52
|
-
textarea {
|
|
53
|
-
min-height: 160px;
|
|
54
|
-
resize: vertical;
|
|
55
|
-
}
|
|
56
|
-
.note {
|
|
57
|
-
color: var(--ui-muted);
|
|
58
|
-
font-size: var(--ui-t-small, 11.5px);
|
|
59
|
-
word-break: break-all;
|
|
60
|
-
}
|
|
61
|
-
.files {
|
|
62
|
-
display: flex;
|
|
63
|
-
flex-wrap: wrap;
|
|
64
|
-
gap: var(--spacing-small, 4px);
|
|
65
|
-
align-items: center;
|
|
66
|
-
color: var(--ui-ink);
|
|
67
|
-
}
|
|
68
|
-
.file {
|
|
69
|
-
display: inline-flex;
|
|
70
|
-
align-items: center;
|
|
71
|
-
gap: 2px;
|
|
72
|
-
}
|
|
73
|
-
.file md-icon {
|
|
74
|
-
--md-icon-size: 14px;
|
|
75
|
-
}
|
|
76
|
-
.file button {
|
|
77
|
-
border: 0;
|
|
78
|
-
background: transparent;
|
|
79
|
-
box-shadow: none;
|
|
80
|
-
padding: 0;
|
|
81
|
-
cursor: pointer;
|
|
82
|
-
color: var(--ui-muted);
|
|
83
|
-
}
|
|
84
|
-
.check {
|
|
85
|
-
display: flex;
|
|
86
|
-
align-items: center;
|
|
87
|
-
gap: var(--spacing-small, 4px);
|
|
88
|
-
color: var(--ui-ink);
|
|
89
|
-
}
|
|
90
|
-
/* The field rule stretches its inputs to the row; a checkbox keeps its own size. */
|
|
91
|
-
.field .check input {
|
|
92
|
-
width: auto;
|
|
93
|
-
margin: 0;
|
|
94
|
-
}
|
|
95
|
-
`
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
@property() kind = 'QUESTION'
|
|
99
|
-
@property() pageUrl = ''
|
|
100
|
-
@state() title = ''
|
|
101
|
-
@state() body = ''
|
|
102
|
-
@state() privately = false
|
|
103
|
-
@state() files: File[] = []
|
|
104
|
-
@state() sending = false
|
|
105
|
-
/* Set only while files go up - the picker shows its bar then. */
|
|
106
|
-
@state() progress?: number
|
|
107
|
-
|
|
108
|
-
render() {
|
|
109
|
-
const title = this.title.trim()
|
|
110
|
-
return html`
|
|
111
|
-
<div class="body">
|
|
112
|
-
<div class="field">
|
|
113
|
-
<ox-input-select-buttons
|
|
114
|
-
.value=${this.kind}
|
|
115
|
-
.options=${KINDS.map(code => ({ display: kindWord(code), value: code }))}
|
|
116
|
-
@change=${(e: CustomEvent) => e.detail && (this.kind = e.detail)}
|
|
117
|
-
></ox-input-select-buttons>
|
|
118
|
-
<span>${t(`inquiry.text.kind-hint-${this.kind.toLowerCase()}`)}</span>
|
|
119
|
-
</div>
|
|
120
|
-
<label class="field">
|
|
121
|
-
<span
|
|
122
|
-
>${t('inquiry.field.title')} ·
|
|
123
|
-
${t('inquiry.text.title-limit', { count: this.title.length, max: MAX_TITLE })}</span
|
|
124
|
-
>
|
|
125
|
-
<input .value=${this.title} maxlength=${MAX_TITLE} @input=${(e: any) => (this.title = e.target.value)} />
|
|
126
|
-
</label>
|
|
127
|
-
<label class="field">
|
|
128
|
-
<span>${t('inquiry.field.body')}</span>
|
|
129
|
-
<textarea
|
|
130
|
-
.value=${this.body}
|
|
131
|
-
placeholder=${t(`inquiry.text.body-placeholder-${this.kind.toLowerCase()}`)}
|
|
132
|
-
@input=${(e: any) => (this.body = e.target.value)}
|
|
133
|
-
></textarea>
|
|
134
|
-
</label>
|
|
135
|
-
<div class="files">
|
|
136
|
-
${this.files.map(
|
|
137
|
-
(file, index) =>
|
|
138
|
-
html`<span class="file"
|
|
139
|
-
><md-icon>attach_file</md-icon>${file.name}<button
|
|
140
|
-
aria-label=${t('inquiry.button.remove')}
|
|
141
|
-
@click=${() => (this.files = this.files.filter((_, i) => i !== index))}
|
|
142
|
-
>
|
|
143
|
-
<md-icon>close</md-icon>
|
|
144
|
-
</button></span
|
|
145
|
-
>`
|
|
146
|
-
)}
|
|
147
|
-
<ox-input-file
|
|
148
|
-
quiet
|
|
149
|
-
hide-filelist
|
|
150
|
-
multiple
|
|
151
|
-
icon="attach_file"
|
|
152
|
-
.progress=${this.progress}
|
|
153
|
-
@change=${(e: CustomEvent) => this.pick(e)}
|
|
154
|
-
></ox-input-file>
|
|
155
|
-
</div>
|
|
156
|
-
<div class="field">
|
|
157
|
-
<label class="check"
|
|
158
|
-
><input
|
|
159
|
-
type="checkbox"
|
|
160
|
-
.checked=${this.privately}
|
|
161
|
-
@change=${(e: any) => (this.privately = e.target.checked)}
|
|
162
|
-
/>${t('inquiry.text.private-label')}</label
|
|
163
|
-
>
|
|
164
|
-
<span>${this.privately ? t('inquiry.text.private-hint') : t('inquiry.text.domain-hint')}</span>
|
|
165
|
-
</div>
|
|
166
|
-
${this.pageUrl ? html`<div class="note">${t('inquiry.text.page-hint', { page: this.pageUrl })}</div>` : nothing}
|
|
167
|
-
</div>
|
|
168
|
-
<ox-popup-actions>
|
|
169
|
-
<md-text-button slot="cancel" @click=${() => this.dispatchEvent(new CustomEvent('close'))}
|
|
170
|
-
>${t('inquiry.button.cancel')}</md-text-button
|
|
171
|
-
>
|
|
172
|
-
<md-filled-button slot="confirm" ?disabled=${!title || this.sending} @click=${() => this.send()}
|
|
173
|
-
>${t('inquiry.button.submit')}</md-filled-button
|
|
174
|
-
>
|
|
175
|
-
</ox-popup-actions>
|
|
176
|
-
`
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private pick(e: CustomEvent) {
|
|
180
|
-
const picked = [...((e.detail as File[]) ?? [])]
|
|
181
|
-
/* The picker's own reset says `change` again, with nothing - that one is let go. */
|
|
182
|
-
if (!picked.length) return
|
|
183
|
-
;(e.target as any).reset?.()
|
|
184
|
-
this.files = [...this.files, ...picked]
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
private async send() {
|
|
188
|
-
this.sending = true
|
|
189
|
-
try {
|
|
190
|
-
await inquiryApi.create(
|
|
191
|
-
{
|
|
192
|
-
kind: this.kind,
|
|
193
|
-
title: this.title.trim(),
|
|
194
|
-
body: this.body,
|
|
195
|
-
visibility: this.privately ? 'PRIVATE' : 'DOMAIN',
|
|
196
|
-
pageUrl: this.pageUrl
|
|
197
|
-
},
|
|
198
|
-
this.files,
|
|
199
|
-
fraction => (this.progress = fraction)
|
|
200
|
-
)
|
|
201
|
-
inquiriesChanged()
|
|
202
|
-
notify({ message: t('inquiry.text.sent') })
|
|
203
|
-
/* The writer stays on the screen they were on - the answer comes as a notice and on the menu's badge. */
|
|
204
|
-
this.dispatchEvent(new CustomEvent('close'))
|
|
205
|
-
} catch (error) {
|
|
206
|
-
notify({ level: 'error', message: reasonOf(error) })
|
|
207
|
-
} finally {
|
|
208
|
-
this.sending = false
|
|
209
|
-
this.progress = undefined
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
package/client/index.ts
DELETED
package/client/inquiry-watch.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
}
|