import '@material/web/button/filled-button.js' import '@material/web/button/outlined-button.js' import '@material/web/button/text-button.js' import '@material/web/icon/icon.js' import gql from 'graphql-tag' import { LitElement, css, html } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { client } from '@operato/graphql' import { notify } from '@operato/layout' import { OxPropertyEditor } from '@operato/property-editor' import { i18next, localize } from '@operato/i18n' interface Resolution { key: string status: 'local' | 'inherited' | 'absent' envVarId?: string sourceDomainName?: string value?: string hasValue?: boolean } /** * Connection / Step 파라미터 화면에서 useDomainAttribute 속성의 EnvVar 를 * 한 자리에서 확인·편집·삭제하는 인라인 편집기. * * 동작: * - 현 도메인에 값이 있으면 update(M) / delete * - 상속만 있는 경우(또는 없는 경우) "이 도메인에 등록" (create) * - 부모 값을 덮어쓰면 closest-wins 로 자식 값이 적용됨을 안내 * * 저장/삭제 성공 시 `saved` 이벤트 발생. */ @customElement('env-var-quick-editor') export class EnvVarQuickEditor extends localize(i18next)(LitElement) { static styles = css` :host { display: flex; flex-direction: column; min-width: 460px; max-height: 80vh; padding: 20px 24px; box-sizing: border-box; background: var(--md-sys-color-surface, #fff); } h3 { margin: 0 0 4px; font-size: 16px; flex: 0 0 auto; } /* 스크롤 가능 본문 — flex column 안에서 actions 위쪽 영역만 흘러내림 */ .body { flex: 1 1 auto; overflow: auto; min-height: 0; } .key { font-family: var(--md-sys-typescale-body-medium-font-family, monospace); font-size: 12px; color: var(--md-sys-color-on-surface-variant); background: var(--md-sys-color-surface-variant); padding: 4px 8px; border-radius: 6px; display: inline-block; margin-bottom: 16px; word-break: break-all; } .status { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; font-size: 14px; } .status[data-status='local'] { color: var(--md-sys-color-tertiary); } .status[data-status='inherited'] { color: var(--md-sys-color-primary); } .status[data-status='absent'] { color: var(--md-sys-color-error); } md-outlined-text-field { width: 100%; margin-bottom: 12px; } .actions { display: flex; justify-content: flex-end; gap: 8px; flex: 0 0 auto; padding-top: 12px; margin-top: 4px; border-top: 1px solid var(--md-sys-color-outline-variant, #e0e0e0); } .info { font-size: 12px; color: var(--md-sys-color-on-surface-variant); margin-bottom: 12px; line-height: 1.5; } .scope { font-size: 12px; line-height: 1.6; padding: 10px 12px; margin-bottom: 14px; border-radius: 8px; background: var(--md-sys-color-surface-container-low, #f5f5f5); border-left: 3px solid var(--md-sys-color-primary, #3457d5); } .scope .scope-title { font-weight: 600; color: var(--md-sys-color-on-surface); margin-bottom: 4px; display: flex; align-items: center; gap: 4px; } .scope .scope-title md-icon { font-size: 14px; --md-icon-size: 14px; } .scope ul { margin: 4px 0 0; padding-left: 18px; color: var(--md-sys-color-on-surface-variant); } .scope li { margin-bottom: 2px; } .scope code { font-family: var(--md-sys-typescale-body-medium-font-family, monospace); font-size: 11px; background: var(--md-sys-color-surface-variant); padding: 1px 4px; border-radius: 3px; } ` /** EnvVar 키 (예: `Connection::kiscon-conn::password`) */ @property({ type: String }) key_!: string /** 속성 사양 (type/secret 여부 표시 등에 사용) */ @property({ type: Object }) propSpec: any /** 사전 조회한 해소 상태 */ @property({ type: Object }) resolution!: Resolution /** 표시용 라벨 (속성 이름) */ @property({ type: String }) propLabel: string = '' @state() private editedValue: string = '' @state() private busy: boolean = false connectedCallback() { super.connectedCallback() this.editedValue = this.resolution?.value ?? '' } private get isSecret(): boolean { return this.propSpec?.type === 'secret' || /password|secret|token|key$/i.test(this.propLabel || '') } /** * 저장 시 영향 범위 (scope) 안내 패널 — 운영자 의사결정 보조. * - absent : 처음 등록. 자손 트리 전체에 inherit. * - local : 이미 등록됨. 자손이 inherit (자손에 자기 override 있으면 그것 우선). * - inherited: 상위에서 받고있음. 여기 등록 시 이 도메인 + 자손에만 한정 override. */ private _renderScopePanel(isLocal: boolean, isInherited: boolean) { if (isLocal) { return html`
info 저장 위치 / 영향 범위
이 도메인에 이미 등록된 값이 자손 도메인 전체에 inherit 됩니다 (closest-wins).
` } if (isInherited) { return html`
inventory 저장 위치 / 영향 범위
현재 ${this.resolution.sourceDomainName || '상위 도메인'} 에서 상속된 값을 사용 중입니다.
` } return html`
add_circle 저장 위치 / 영향 범위
이 도메인에 처음 등록합니다.
` } private get statusLabel(): string { const s = this.resolution?.status if (s === 'local') return `✓ ${i18next.t('text.set-on-this-domain') || '이 도메인에 등록됨'}` if (s === 'inherited') return `🔗 ${i18next.t('text.inherited-from') || '상속'} (${this.resolution.sourceDomainName || '부모 도메인'})` return `⚠ ${i18next.t('text.not-set') || '미설정'}` } render() { const isLocal = this.resolution?.status === 'local' const isInherited = this.resolution?.status === 'inherited' return html`

${i18next.t('text.domain-attribute') || '도메인 속성'} · ${this.propLabel || this.key_}

${this.key_}
${this.statusLabel}
${this._renderScopePanel(isLocal, isInherited)} ${this._renderValueInput()}
${isLocal ? html` ${i18next.t('button.delete-from-this-domain') || '이 도메인에서 삭제'} ` : ''} ${i18next.t('button.cancel')} ${isLocal ? i18next.t('button.update') || '갱신' : i18next.t('button.set') || '등록'}
` } /** * 입력 control 은 플랫폼의 OxPropertyEditor 레지스트리에서 propSpec.type 에 * 등록된 element 를 그대로 사용. parameters builder 가 task params 화면에서 * 쓰는 것과 동일한 editor 를 EnvVar 편집기에도 노출 — 일관된 UX, 단일 진실 원천. * * EnvVar 저장 값은 항상 문자열. 비-string 값 (number/boolean/object) 은 * 직렬화/역직렬화. select 의 경우 option.value 가 string 이라 자연 동작. */ private _renderValueInput() { // host 만 lit-html 로 렌더. 실제 editor element 는 updated() 에서 imperative. // secret 은 OxPropertyEditor 의 password editor 가 부족할 수 있어 별도 처리. if (this.isSecret) { return html` (this.editedValue = e.target.value)} ?disabled=${this.busy} style="width:100%; padding:8px; box-sizing:border-box;" /> ` } return html`
` } private _editorEl: HTMLElement | null = null private _editorChangeBound: ((e: Event) => void) | null = null protected updated(changed: Map) { super.updated?.(changed) if (this.isSecret) return if (changed.has('propSpec') || !this._editorEl) { this._mountEditor() } else if (changed.has('editedValue') && this._editorEl) { // 외부에서 editedValue 변경 시 editor 도 동기화 const cur = (this._editorEl as any).value if (cur !== this.editedValue) (this._editorEl as any).value = this.editedValue } } private _mountEditor() { const host = this.renderRoot.querySelector('#editor-host') as HTMLElement | null if (!host) return host.replaceChildren() this._cleanupEditorListener() const spec = this.propSpec || {} const type = spec.type || 'string' const elementType = OxPropertyEditor.getEditor(type) if (!elementType) { // 등록 editor 없음 — fallback 으로 단순 text input const fallback = document.createElement('input') fallback.type = 'text' fallback.style.cssText = 'width:100%; padding:8px; box-sizing:border-box;' fallback.value = this.editedValue || '' fallback.addEventListener('input', () => (this.editedValue = fallback.value)) host.appendChild(fallback) this._editorEl = fallback return } const el = document.createElement(elementType) as any el.label = spec.label || i18next.t('field.value') el.type = type el.placeholder = spec.placeholder || '' el.property = spec.property el.editor = spec.editor el.value = this.editedValue || (type === 'checkbox' ? false : '') // editor 의 change event 를 받아 editedValue 동기화. EnvVar 는 문자열 저장이라 // value 가 boolean/number 면 String() 변환. const onChange = () => { const v = el.value this.editedValue = v == null ? '' : typeof v === 'string' ? v : String(v) } el.addEventListener('change', onChange) this._editorChangeBound = onChange host.appendChild(el) this._editorEl = el } private _cleanupEditorListener() { if (this._editorEl && this._editorChangeBound) { this._editorEl.removeEventListener('change', this._editorChangeBound) } this._editorChangeBound = null this._editorEl = null } disconnectedCallback() { this._cleanupEditorListener() super.disconnectedCallback() } private _cancel() { this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true })) } private async _save() { this.busy = true try { const isUpdate = this.resolution?.status === 'local' && this.resolution?.envVarId const patches = isUpdate ? [{ id: this.resolution.envVarId, cuFlag: 'M', value: this.editedValue, active: true }] : [ { cuFlag: '+', name: this.key_, value: this.editedValue, active: true, description: this.propLabel || '' } ] const response = await client.mutate({ mutation: gql` mutation ($patches: [EnvVarPatch!]!) { updateMultipleEnvVars(patches: $patches) { name } } `, variables: { patches } }) if (response.errors) throw new Error(response.errors.map((e: any) => e.message).join('\n')) notify({ message: i18next.t('text.saved-successfully') || '저장되었습니다' }) this.dispatchEvent(new CustomEvent('saved', { bubbles: true, composed: true })) } catch (e: any) { notify({ level: 'error', message: e.message || String(e) }) } finally { this.busy = false } } private async _delete() { if (!this.resolution?.envVarId) return // 도메인 환경변수 삭제는 확인 없이 즉시 처리. this.busy = true try { const response = await client.mutate({ mutation: gql` mutation ($id: String!) { deleteEnvVar(id: $id) } `, variables: { id: this.resolution.envVarId } }) if (response.errors) throw new Error(response.errors.map((e: any) => e.message).join('\n')) notify({ message: i18next.t('text.deleted-successfully') || '삭제되었습니다' }) this.dispatchEvent(new CustomEvent('saved', { bubbles: true, composed: true })) } catch (e: any) { notify({ level: 'error', message: e.message || String(e) }) } finally { this.busy = false } } }