import '@material/web/icon/icon.js' import './env-var-quick-editor.js' import gql from 'graphql-tag' import { html } from 'lit' import { client } from '@operato/graphql' import { notify, openPopup } from '@operato/layout' import { i18next } from '@operato/i18n' import { PropertySpec } from '../types.js' export interface EnvVarResolution { key: string status: 'local' | 'inherited' | 'absent' envVarId?: string sourceDomainId?: string sourceDomainName?: string value?: string hasValue?: boolean } /** * useDomainAttribute 속성의 EnvVar 4-상태 칩 + 인라인 편집기 팝업. * Connection / Step params 화면이 공통으로 사용. */ export async function resolveEnvVars(keys: string[]): Promise> { const map = new Map() if (!keys || keys.length === 0) return map try { const response = await client.query({ query: gql` query ($keys: [String!]!) { envVarResolutions(keys: $keys) { key status envVarId sourceDomainId sourceDomainName value hasValue } } `, variables: { keys }, fetchPolicy: 'network-only' }) for (const r of response.data?.envVarResolutions || []) { map.set(r.key, r) } } catch (e) { console.warn('envVarResolutions failed; falling back to absent', e) } return map } async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text) } catch { const textArea = document.createElement('textarea') textArea.value = text document.body.appendChild(textArea) textArea.select() document.execCommand('copy') document.body.removeChild(textArea) } } function buildStatusChip( key: string, resolution: EnvVarResolution | undefined, onClick: () => void, onCopy: () => void ): HTMLElement { const chip = document.createElement('span') chip.style.cssText = [ 'display:inline-flex', 'align-items:center', 'gap:4px', 'padding:2px 8px', 'font-size:11px', 'line-height:1.4', 'border-radius:10px', 'cursor:pointer', 'user-select:none', 'border:1px solid' ].join(';') const status = resolution?.status || 'absent' let icon = 'help' let label = '미설정' let fg = '' let bg = '' if (status === 'local') { icon = 'check_circle' label = '이 도메인' fg = 'var(--md-sys-color-on-tertiary-container)' bg = 'var(--md-sys-color-tertiary-container)' } else if (status === 'inherited') { icon = 'inventory' label = `상속${resolution!.sourceDomainName ? ' · ' + resolution!.sourceDomainName : ''}` fg = 'var(--md-sys-color-on-primary-container)' bg = 'var(--md-sys-color-primary-container)' } else { icon = 'warning' label = '미설정' fg = 'var(--md-sys-color-on-error-container)' bg = 'var(--md-sys-color-error-container)' } chip.style.color = fg chip.style.background = bg chip.style.borderColor = fg chip.title = `${key} — 클릭: 편집, ⌥ 클릭: 키 복사` const iconEl = document.createElement('md-icon') iconEl.textContent = icon iconEl.style.cssText = `font-size:13px; --md-icon-size:13px; color:${fg};` chip.appendChild(iconEl) const text = document.createElement('span') text.textContent = label chip.appendChild(text) chip.addEventListener('click', (e: MouseEvent) => { if (e.altKey) onCopy() else onClick() }) return chip } function openEnvVarEditor( key: string, propName: string, propSpec: PropertySpec, resolution: EnvVarResolution, refresh: () => void ): void { const propLabel = (propSpec.label && i18next.t('label.' + propSpec.label)) || propName const popup = openPopup( html` { popup.close?.() refresh() }} @cancel=${() => popup.close?.()} > `, { backdrop: true, size: 'small', title: `${propLabel} — ${key}` } ) } /** * actionInjector 팩토리. * * @param keyBuilder 속성 이름을 받아 EnvVar 키를 생성. 예: * Connection 화면: `(p) => 'Connection::' + connName + '::' + p` * Step 화면: `(p) => 'Step::' + scenarioName + '::' + stepName + '::' + p` * @param resolutions 사전 조회한 해소 상태 맵 (key → resolution) * @param refresh 저장·삭제 후 부모 grist 새로고침 콜백 */ export function createEnvVarActionInjector( keyBuilder: (propName: string) => string, resolutions: Map, refresh: () => void ): (propName: string, propSpec: PropertySpec) => HTMLElement | null { return (propName, propSpec) => { if (!propSpec.useDomainAttribute) return null const key = keyBuilder(propName) let currentResolution = resolutions.get(key) || { key, status: 'absent' as const } // 안정 컨테이너 — DOM 상의 위치/참조는 유지하고 내부 chip 만 교체. // chip 직접 replaceWith 시 shadow DOM 경계나 grist 재렌더에 의해 // 칩이 사라지는 케이스가 있어 컨테이너로 한 단계 감쌈. const host = document.createElement('span') host.style.cssText = 'display:inline-flex; align-items:center;' const openCopy = () => copyToClipboard(key).then(() => notify({ message: `복사됨: ${key}` })) const renderChip = () => { const chip = buildStatusChip(key, currentResolution, openEdit, openCopy) host.replaceChildren(chip) } // 저장·삭제 후 자기 chip 만 즉시 갱신. 부모 grist 의 fetch 는 호출하지 않는다 — // grist.fetch() 가 parameters 편집기(popover) 자체를 닫아 칩들이 통째로 사라지는 // 사고를 막기 위함. 다음 cell edit 저장 시점에 자연스럽게 grid 가 새로고침됨. const onSaved = async () => { try { const updated = await resolveEnvVars([key]) currentResolution = updated.get(key) || { key, status: 'absent' as const } renderChip() } catch (e) { console.warn('chip 갱신 실패', e) } } const openEdit = () => openEnvVarEditor(key, propName, propSpec, currentResolution, onSaved) renderChip() return host } } /** * 한 화면(Connection 한 행, Scenario 한 step 셋) 의 진단 라벨. * - 사용 준비됨 (n/n) : 모든 useDomainAttribute 속성이 local 또는 inherited * - 미완성 (m absent / n total): 하나라도 absent */ export function diagnoseReadiness( resolutions: Map, expectedKeys: string[] ): { ready: boolean; total: number; absent: number; label: string } { const total = expectedKeys.length let absent = 0 for (const k of expectedKeys) { const r = resolutions.get(k) if (!r || r.status === 'absent') absent++ } const ready = absent === 0 && total > 0 const label = total === 0 ? '도메인 속성 없음' : ready ? `사용 준비됨 (${total}/${total})` : `미완성 — ${absent} 미설정 / ${total}` return { ready, total, absent, label } }