@things-factory/integration-ui 10.0.0-beta.92 → 10.0.0-beta.96

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.
@@ -14,47 +14,12 @@ import { CommonButtonStyles, CommonGristStyles, ScrollbarStyles } from '@operato
14
14
  import { isMobileDevice } from '@operato/utils'
15
15
  import { FetchOption } from '@operato/data-grist'
16
16
  import { p13n } from '@operato/p13n'
17
- import { PropertySpec } from '../types.js'
17
+ import {
18
+ createEnvVarActionInjector,
19
+ diagnoseReadiness,
20
+ resolveEnvVars
21
+ } from '../viewparts/env-var-action-injector.js'
18
22
 
19
- async function copyToClipboard(text: string): Promise<void> { try { await navigator.clipboard.writeText(text)
20
- } catch (err) { // fallback: old way
21
- const textArea = document.createElement('textarea')
22
- textArea.value = text
23
- document.body.appendChild(textArea)
24
- textArea.select()
25
- document.execCommand('copy')
26
- document.body.removeChild(textArea)
27
- }
28
- }
29
-
30
- function createActionInjector(
31
- connectionName: string
32
- ): (propName: string, propSpec: PropertySpec) => HTMLElement | null { return (propName: string, propSpec: PropertySpec): HTMLElement | null => { if (!propSpec.useDomainAttribute) { return null
33
- }
34
-
35
- const attributeName = `Connection::${connectionName}::${propName}`
36
-
37
- const copyIcon = document.createElement('md-icon')
38
- copyIcon.textContent = 'dictionary'
39
- copyIcon.style.cssText =
40
- 'cursor: pointer; color: var(--md-sys-color-primary); font-size: 16px; --md-icon-size: 16px;'
41
- copyIcon.title = `Copy ${attributeName}`
42
-
43
- copyIcon.addEventListener('click', () => { copyToClipboard(attributeName)
44
-
45
- // 복사 성공 피드백
46
- const originalText = copyIcon.textContent
47
- copyIcon.textContent = 'check'
48
- copyIcon.style.color = 'var(--md-sys-color-tertiary)'
49
-
50
- setTimeout(() => { copyIcon.textContent = originalText
51
- copyIcon.style.color = 'var(--md-sys-color-primary)'
52
- }, 1000)
53
- })
54
-
55
- return copyIcon
56
- }
57
- }
58
23
 
59
24
  @customElement('connection-page')
60
25
  export class Connection extends p13n(localize(i18next)(PageView)) { static styles = [
@@ -197,6 +162,27 @@ export class Connection extends p13n(localize(i18next)(PageView)) { static sty
197
162
  record: { editable: true
198
163
  },
199
164
  width: 120
165
+ },
166
+ {
167
+ type: 'select',
168
+ name: 'inheritanceMode',
169
+ label: true,
170
+ header: i18next.t('field.inheritance-mode') || '상속 모드',
171
+ record: {
172
+ editable: true,
173
+ // GraphQL enum 이름 (대문자) 을 value 로 사용 — type-graphql registerEnumType 이
174
+ // 노출하는 형식과 일치해야 함. 서버 내부 runtime 값('isolate'/'share') 으로의
175
+ // 변환은 GraphQL 레이어가 자동.
176
+ options: [
177
+ { display: '(connector 기본)', value: null },
178
+ { display: 'ISOLATE — 자식별 격리 인스턴스 (기본·안전)', value: 'ISOLATE' },
179
+ { display: 'SHARE — 자식이 부모 인스턴스 공유', value: 'SHARE' }
180
+ ]
181
+ },
182
+ // SHARE 의 의미는 select 옵션 라벨에 명시 ("자식이 부모 인스턴스 공유").
183
+ // 운영자가 의식적으로 선택. native confirm() 차단 다이얼로그 제거 — 잘못 선택해도
184
+ // 즉시 ISOLATE / (connector 기본) 으로 되돌릴 수 있음.
185
+ width: 200
200
186
  },
201
187
  { type: 'connector',
202
188
  name: 'type',
@@ -225,20 +211,53 @@ export class Connection extends p13n(localize(i18next)(PageView)) { static sty
225
211
  name: 'params',
226
212
  header: i18next.t('field.params'),
227
213
  record: { editable: true,
228
- options: async (value, column, record, row, field) => { const { name, help, parameterSpec: spec } = record.type ? this.connectors?.[record.type] : ({} as any)
214
+ options: async (value, column, record, row, field) => { // 등록된 connector 아닌 type 가진 Connection 에서 디스트럭처 죽음을 막고,
215
+ // 동시에 운영자가 데이터 측 문제를 인지할 수 있도록 콘솔 경고를 남김.
216
+ let connectorEntry: any = null
217
+ if (record.type) { connectorEntry = this.connectors?.[record.type] || null
218
+ if (!connectorEntry && this.connectors) { console.warn(
219
+ `[connection] connector type '${record.type}' (connection '${record.name}') is not registered. ` +
220
+ `Server-side package missing or not bootstrapped.`
221
+ )
222
+ }
223
+ }
224
+ const { name, help, parameterSpec: spec } = connectorEntry || ({} as any)
229
225
  const context = this.grist
230
226
 
227
+ // useDomainAttribute params 의 EnvVar 해소 상태 사전 조회
228
+ const domainAttrParams = (Array.isArray(spec) ? spec : []).filter((p: any) => p?.useDomainAttribute && p?.name)
229
+ const keys = domainAttrParams.map((p: any) => `Connection::${record.name}::${p.name}`)
230
+ const resolutions = await resolveEnvVars(keys)
231
+
231
232
  return { name,
232
233
  help,
233
234
  spec,
234
235
  context,
235
236
  objectified: true,
236
- actionInjector: createActionInjector(record.name)
237
+ actionInjector: createEnvVarActionInjector(
238
+ (propName: string) => `Connection::${record.name}::${propName}`,
239
+ resolutions,
240
+ () => this.grist.fetch()
241
+ )
237
242
  }
238
243
  },
239
244
  renderer: 'json5'
240
245
  },
241
246
  width: 100
247
+ },
248
+ {
249
+ type: 'string',
250
+ name: '_readiness',
251
+ header: i18next.t('field.domain-attribute-readiness') || '준비 상태',
252
+ record: {
253
+ editable: false,
254
+ renderer: (_v: any, _c: any, r: any) => {
255
+ const d = r?._readiness
256
+ if (!d) return ''
257
+ return d.label
258
+ }
259
+ },
260
+ width: 180
242
261
  },
243
262
  { type: 'resource-object',
244
263
  name: 'edge',
@@ -292,6 +311,7 @@ export class Connection extends p13n(localize(i18next)(PageView)) { static sty
292
311
  endpoint
293
312
  active
294
313
  onDemand
314
+ inheritanceMode
295
315
  state
296
316
  params
297
317
  updater { id
@@ -310,9 +330,43 @@ export class Connection extends p13n(localize(i18next)(PageView)) { static sty
310
330
  }
311
331
  })
312
332
 
333
+ const items = response.data.responses.items || []
334
+ const records = await this._annotateReadiness(items)
335
+
313
336
  return { total: response.data.responses.total || 0,
314
- records: response.data.responses.items || []
337
+ records
338
+ }
339
+ }
340
+
341
+ /**
342
+ * 한 페이지 분 connections 에 대해 useDomainAttribute params 키를 모두 모아 단일
343
+ * envVarResolutions 호출로 해소한다. 그 후 각 connection 의 키 부분집합에 대해
344
+ * diagnoseReadiness 로 _readiness 를 주입.
345
+ */
346
+ async _annotateReadiness(items: any[]): Promise<any[]> { if (!items || items.length === 0) return items
347
+ if (!this.connectors) { // connectors 로딩 전 호출되었으면 일단 그대로
348
+ return items
349
+ }
350
+
351
+ type ConnKeys = { conn: any; keys: string[] }
352
+ const perConn: ConnKeys[] = []
353
+ const allKeys: string[] = []
354
+
355
+ for (const conn of items) { const connector = this.connectors[conn.type]
356
+ const spec = Array.isArray(connector?.parameterSpec) ? connector.parameterSpec : []
357
+ const keys = spec
358
+ .filter((p: any) => p?.useDomainAttribute && p?.name)
359
+ .map((p: any) => `Connection::${conn.name}::${p.name}`)
360
+ perConn.push({ conn, keys })
361
+ allKeys.push(...keys)
315
362
  }
363
+
364
+ const uniqueKeys = Array.from(new Set(allKeys))
365
+ const resolutions = await resolveEnvVars(uniqueKeys)
366
+
367
+ return perConn.map(({ conn, keys }) => ({ ...conn,
368
+ _readiness: diagnoseReadiness(resolutions, keys)
369
+ }))
316
370
  }
317
371
 
318
372
  async fetchConnectors() { const response = await client.query({ query: gql`
@@ -335,6 +389,10 @@ export class Connection extends p13n(localize(i18next)(PageView)) { static sty
335
389
  if (!response.errors) { this.connectors = response.data.connectors.items.reduce((connectors, connector) => { connectors[connector.name] = connector
336
390
  return connectors
337
391
  }, {})
392
+
393
+ // connectors 가 준비된 시점에 readiness 가 비어있다면 한 번 더 fetch
394
+ if (this.grist?.dirtyData?.records?.some?.((r: any) => !r._readiness)) { this.grist.fetch()
395
+ }
338
396
  } else { console.error('fetch connectors error')
339
397
  }
340
398
  }
@@ -13,6 +13,8 @@ import { isMobileDevice } from '@operato/utils'
13
13
  import { CommonHeaderStyles } from '@operato/styles'
14
14
  import { FetchOption } from '@operato/data-grist'
15
15
 
16
+ import { createEnvVarActionInjector, resolveEnvVars } from '../viewparts/env-var-action-injector.js'
17
+
16
18
  const SelectFields = ['name', 'description', 'sequence', 'task', 'connection', 'params', 'result', 'skip', 'log']
17
19
 
18
20
  @customElement('scenario-detail')
@@ -176,10 +178,46 @@ class ScenarioDetail extends localize(i18next)(LitElement) {
176
178
  header: i18next.t('field.params'),
177
179
  record: {
178
180
  editable: true,
179
- options: (value, column, record, row, field) => {
180
- var { name, parameterSpec: spec, help } = record.task ? this.taskTypes?.[record.task] : ({} as any)
181
+ options: async (value, column, record, row, field) => {
182
+ // 등록된 task type 아닌 step.task 디스트럭처 보호 + 진단 경고
183
+ let taskEntry: any = null
184
+ if (record.task) {
185
+ taskEntry = this.taskTypes?.[record.task] || null
186
+ if (!taskEntry && this.taskTypes) {
187
+ console.warn(
188
+ `[scenario-detail] task type '${record.task}' (step '${record.name}') is not registered. ` +
189
+ `Server-side handler missing or not bootstrapped.`
190
+ )
191
+ }
192
+ }
193
+ var { name, parameterSpec: spec, help } = taskEntry || ({} as any)
181
194
  const context = this.grist
182
- return { name, spec, help, context, objectified: true }
195
+
196
+ const scenarioName = this.scenario?.name
197
+ const stepName = record?.name
198
+ const domainAttrParams = (Array.isArray(spec) ? spec : []).filter(
199
+ (p: any) => p?.useDomainAttribute && p?.name
200
+ )
201
+ const keys = scenarioName && stepName
202
+ ? domainAttrParams.map((p: any) => `Step::${scenarioName}::${stepName}::${p.name}`)
203
+ : []
204
+ const resolutions = await resolveEnvVars(keys)
205
+
206
+ return {
207
+ name,
208
+ spec,
209
+ help,
210
+ context,
211
+ objectified: true,
212
+ actionInjector:
213
+ scenarioName && stepName
214
+ ? createEnvVarActionInjector(
215
+ (propName: string) => `Step::${scenarioName}::${stepName}::${propName}`,
216
+ resolutions,
217
+ () => this.grist.fetch()
218
+ )
219
+ : undefined
220
+ }
183
221
  },
184
222
  renderer: 'json5'
185
223
  },
@@ -334,6 +334,7 @@ export class Scenario extends p13n(localize(i18next)(PageView)) { static get s
334
334
  placeholder
335
335
  property
336
336
  styles
337
+ useDomainAttribute
337
338
  }
338
339
  }
339
340
  }
@@ -0,0 +1,239 @@
1
+ import '@material/web/icon/icon.js'
2
+ import './env-var-quick-editor.js'
3
+
4
+ import gql from 'graphql-tag'
5
+ import { html } from 'lit'
6
+ import { client } from '@operato/graphql'
7
+ import { notify, openPopup } from '@operato/layout'
8
+ import { i18next } from '@operato/i18n'
9
+ import { PropertySpec } from '../types.js'
10
+
11
+ export interface EnvVarResolution {
12
+ key: string
13
+ status: 'local' | 'inherited' | 'absent'
14
+ envVarId?: string
15
+ sourceDomainId?: string
16
+ sourceDomainName?: string
17
+ value?: string
18
+ hasValue?: boolean
19
+ }
20
+
21
+ /**
22
+ * useDomainAttribute 속성의 EnvVar 4-상태 칩 + 인라인 편집기 팝업.
23
+ * Connection / Step params 화면이 공통으로 사용.
24
+ */
25
+ export async function resolveEnvVars(keys: string[]): Promise<Map<string, EnvVarResolution>> {
26
+ const map = new Map<string, EnvVarResolution>()
27
+ if (!keys || keys.length === 0) return map
28
+
29
+ try {
30
+ const response = await client.query({
31
+ query: gql`
32
+ query ($keys: [String!]!) {
33
+ envVarResolutions(keys: $keys) {
34
+ key
35
+ status
36
+ envVarId
37
+ sourceDomainId
38
+ sourceDomainName
39
+ value
40
+ hasValue
41
+ }
42
+ }
43
+ `,
44
+ variables: { keys },
45
+ fetchPolicy: 'network-only'
46
+ })
47
+ for (const r of response.data?.envVarResolutions || []) {
48
+ map.set(r.key, r)
49
+ }
50
+ } catch (e) {
51
+ console.warn('envVarResolutions failed; falling back to absent', e)
52
+ }
53
+ return map
54
+ }
55
+
56
+ async function copyToClipboard(text: string): Promise<void> {
57
+ try {
58
+ await navigator.clipboard.writeText(text)
59
+ } catch {
60
+ const textArea = document.createElement('textarea')
61
+ textArea.value = text
62
+ document.body.appendChild(textArea)
63
+ textArea.select()
64
+ document.execCommand('copy')
65
+ document.body.removeChild(textArea)
66
+ }
67
+ }
68
+
69
+ function buildStatusChip(
70
+ key: string,
71
+ resolution: EnvVarResolution | undefined,
72
+ onClick: () => void,
73
+ onCopy: () => void
74
+ ): HTMLElement {
75
+ const chip = document.createElement('span')
76
+ chip.style.cssText = [
77
+ 'display:inline-flex',
78
+ 'align-items:center',
79
+ 'gap:4px',
80
+ 'padding:2px 8px',
81
+ 'font-size:11px',
82
+ 'line-height:1.4',
83
+ 'border-radius:10px',
84
+ 'cursor:pointer',
85
+ 'user-select:none',
86
+ 'border:1px solid'
87
+ ].join(';')
88
+
89
+ const status = resolution?.status || 'absent'
90
+ let icon = 'help'
91
+ let label = '미설정'
92
+ let fg = ''
93
+ let bg = ''
94
+
95
+ if (status === 'local') {
96
+ icon = 'check_circle'
97
+ label = '이 도메인'
98
+ fg = 'var(--md-sys-color-on-tertiary-container)'
99
+ bg = 'var(--md-sys-color-tertiary-container)'
100
+ } else if (status === 'inherited') {
101
+ icon = 'inventory'
102
+ label = `상속${resolution!.sourceDomainName ? ' · ' + resolution!.sourceDomainName : ''}`
103
+ fg = 'var(--md-sys-color-on-primary-container)'
104
+ bg = 'var(--md-sys-color-primary-container)'
105
+ } else {
106
+ icon = 'warning'
107
+ label = '미설정'
108
+ fg = 'var(--md-sys-color-on-error-container)'
109
+ bg = 'var(--md-sys-color-error-container)'
110
+ }
111
+
112
+ chip.style.color = fg
113
+ chip.style.background = bg
114
+ chip.style.borderColor = fg
115
+ chip.title = `${key} — 클릭: 편집, ⌥ 클릭: 키 복사`
116
+
117
+ const iconEl = document.createElement('md-icon')
118
+ iconEl.textContent = icon
119
+ iconEl.style.cssText = `font-size:13px; --md-icon-size:13px; color:${fg};`
120
+ chip.appendChild(iconEl)
121
+
122
+ const text = document.createElement('span')
123
+ text.textContent = label
124
+ chip.appendChild(text)
125
+
126
+ chip.addEventListener('click', (e: MouseEvent) => {
127
+ if (e.altKey) onCopy()
128
+ else onClick()
129
+ })
130
+
131
+ return chip
132
+ }
133
+
134
+ function openEnvVarEditor(
135
+ key: string,
136
+ propName: string,
137
+ propSpec: PropertySpec,
138
+ resolution: EnvVarResolution,
139
+ refresh: () => void
140
+ ): void {
141
+ const propLabel = (propSpec.label && i18next.t('label.' + propSpec.label)) || propName
142
+
143
+ const popup = openPopup(
144
+ html`
145
+ <env-var-quick-editor
146
+ .key_=${key}
147
+ .propSpec=${propSpec}
148
+ .resolution=${resolution}
149
+ .propLabel=${propLabel}
150
+ @saved=${() => {
151
+ popup.close?.()
152
+ refresh()
153
+ }}
154
+ @cancel=${() => popup.close?.()}
155
+ ></env-var-quick-editor>
156
+ `,
157
+ {
158
+ backdrop: true,
159
+ size: 'small',
160
+ title: `${propLabel} — ${key}`
161
+ }
162
+ )
163
+ }
164
+
165
+ /**
166
+ * actionInjector 팩토리.
167
+ *
168
+ * @param keyBuilder 속성 이름을 받아 EnvVar 키를 생성. 예:
169
+ * Connection 화면: `(p) => 'Connection::' + connName + '::' + p`
170
+ * Step 화면: `(p) => 'Step::' + scenarioName + '::' + stepName + '::' + p`
171
+ * @param resolutions 사전 조회한 해소 상태 맵 (key → resolution)
172
+ * @param refresh 저장·삭제 후 부모 grist 새로고침 콜백
173
+ */
174
+ export function createEnvVarActionInjector(
175
+ keyBuilder: (propName: string) => string,
176
+ resolutions: Map<string, EnvVarResolution>,
177
+ refresh: () => void
178
+ ): (propName: string, propSpec: PropertySpec) => HTMLElement | null {
179
+ return (propName, propSpec) => {
180
+ if (!propSpec.useDomainAttribute) return null
181
+ const key = keyBuilder(propName)
182
+ let currentResolution = resolutions.get(key) || { key, status: 'absent' as const }
183
+
184
+ // 안정 컨테이너 — DOM 상의 위치/참조는 유지하고 내부 chip 만 교체.
185
+ // chip 직접 replaceWith 시 shadow DOM 경계나 grist 재렌더에 의해
186
+ // 칩이 사라지는 케이스가 있어 컨테이너로 한 단계 감쌈.
187
+ const host = document.createElement('span')
188
+ host.style.cssText = 'display:inline-flex; align-items:center;'
189
+
190
+ const openCopy = () => copyToClipboard(key).then(() => notify({ message: `복사됨: ${key}` }))
191
+
192
+ const renderChip = () => {
193
+ const chip = buildStatusChip(key, currentResolution, openEdit, openCopy)
194
+ host.replaceChildren(chip)
195
+ }
196
+
197
+ // 저장·삭제 후 자기 chip 만 즉시 갱신. 부모 grist 의 fetch 는 호출하지 않는다 —
198
+ // grist.fetch() 가 parameters 편집기(popover) 자체를 닫아 칩들이 통째로 사라지는
199
+ // 사고를 막기 위함. 다음 cell edit 저장 시점에 자연스럽게 grid 가 새로고침됨.
200
+ const onSaved = async () => {
201
+ try {
202
+ const updated = await resolveEnvVars([key])
203
+ currentResolution = updated.get(key) || { key, status: 'absent' as const }
204
+ renderChip()
205
+ } catch (e) {
206
+ console.warn('chip 갱신 실패', e)
207
+ }
208
+ }
209
+
210
+ const openEdit = () => openEnvVarEditor(key, propName, propSpec, currentResolution, onSaved)
211
+
212
+ renderChip()
213
+ return host
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 한 화면(Connection 한 행, Scenario 한 step 셋) 의 진단 라벨.
219
+ * - 사용 준비됨 (n/n) : 모든 useDomainAttribute 속성이 local 또는 inherited
220
+ * - 미완성 (m absent / n total): 하나라도 absent
221
+ */
222
+ export function diagnoseReadiness(
223
+ resolutions: Map<string, EnvVarResolution>,
224
+ expectedKeys: string[]
225
+ ): { ready: boolean; total: number; absent: number; label: string } {
226
+ const total = expectedKeys.length
227
+ let absent = 0
228
+ for (const k of expectedKeys) {
229
+ const r = resolutions.get(k)
230
+ if (!r || r.status === 'absent') absent++
231
+ }
232
+ const ready = absent === 0 && total > 0
233
+ const label = total === 0
234
+ ? '도메인 속성 없음'
235
+ : ready
236
+ ? `사용 준비됨 (${total}/${total})`
237
+ : `미완성 — ${absent} 미설정 / ${total}`
238
+ return { ready, total, absent, label }
239
+ }