utils_combo_linked-combo.js

/**
 * 콤보 요소를 제어하기 위한 추상 어댑터 클래스입니다.
 *
 * @abstract
 */
export class ComboAdapter {
    /**
     * 현재 선택된 값을 가져옵니다.
     *
     * @param {number} [row]  - 그리드 행 인덱스 (그리드 어댑터인 경우)
     * @returns {*} 현재 선택된 값
     * @abstract
     */
    getValue(row) {
        throw new Error("getValue() must be implemented");
    }

    /**
     * 콤보의 값을 설정합니다.
     *
     * @param {*}      val    - 설정할 값
     * @param {number} [row]  - 그리드 행 인덱스 (그리드 어댑터인 경우)
     * @abstract
     */
    setValue(val, row) {
        throw new Error("setValue() must be implemented");
    }

    /**
     * 콤보의 인덱스를 설정합니다.
     *
     * @param {number} idx    - 설정할 인덱스
     * @param {number} [row]  - 그리드 행 인덱스 (그리드 어댑터인 경우)
     * @abstract
     */
    setIndex(idx, row) {
        throw new Error("setIndex() must be implemented");
    }

    /**
     * 콤보가 사용하는 내부 데이터셋을 가져옵니다.
     *
     * @returns {nexacro.Dataset | null} 내부 데이터셋
     * @abstract
     */
    getInnerDataset() {
        throw new Error("getInnerDataset() must be implemented");
    }

    /**
     * 표시할 데이터 컬럼명을 설정합니다.
     *
     * @param {string} col  - 표시할 데이터 컬럼명
     * @abstract
     */
    setDisplayColumn(col) {
        throw new Error("setDisplayColumn() must be implemented");
    }

    /**
     * 현재 표시중인 데이터 컬럼명을 가져옵니다.
     *
     * @returns {string | null} 현재 표시 컬럼명
     * @abstract
     */
    getDisplayColumn() {
        throw new Error("getDisplayColumn() must be implemented");
    }

    /**
     * 콤보 선택을 초기화(재설정)합니다.
     *
     * @param {number} [row]  - 그리드 행 인덱스 (그리드 어댑터인 경우)
     * @abstract
     */
    reset(row) {
        throw new Error("reset() must be implemented");
    }
}

/**
 * 넥사크로 Combo 컴포넌트를 위한 어댑터 클래스입니다.
 *
 * @augments ComboAdapter
 */
export class ComboComponentAdapter extends ComboAdapter {
    /**
     * @param {nexacro.Combo} comp  - 넥사크로 콤보 컴포넌트
     */
    constructor(comp) {
        super();
        this.comp = comp;
    }

    getValue() {
        return this.comp ? this.comp.value : undefined;
    }

    setValue(val) {
        if (this.comp) {
            this.comp.value = val;
        }
    }

    setIndex(idx) {
        if (this.comp) {
            this.comp.index = idx;
        }
    }

    getInnerDataset() {
        return this.comp ? this.comp.getInnerDataset() : null;
    }

    setDisplayColumn(col) {
        if (this.comp) {
            this.comp.datacolumn = col;
        }
    }

    getDisplayColumn() {
        return this.comp ? this.comp.datacolumn : null;
    }

    reset() {
        this.setIndex(0);
    }
}

/**
 * 넥사크로 Grid의 콤보 컬럼을 위한 어댑터 클래스입니다.
 *
 * @augments ComboAdapter
 */
export class ComboGridAdapter extends ComboAdapter {
    /**
     * @param {nexacro.Grid} grid   - 넥사크로 그리드 컴포넌트
     * @param {string}       colNm  - 바인딩 컬럼명
     */
    constructor(grid, colNm) {
        super();
        this.grid = grid;
        this.colNm = colNm;
        /**
         * 현재 편집 중인 행 인덱스
         *
         * @type {number}
         */
        this.currentRow = -1;
    }

    /**
     * 바인딩 데이터셋의 해당 열 인덱스를 조회합니다.
     *
     * @returns {number} 셀 인덱스
     * @access private
     */
    getCellIndex() {
        return this.grid ? this.grid.getBindCellIndex("body", this.colNm) : -1;
    }

    getValue(row = this.currentRow) {
        if (!this.grid) return undefined;
        const ds = this.grid.getBindDataset();
        return ds && row >= 0 ? ds.getColumn(row, this.colNm) : undefined;
    }

    setValue(val, row = this.currentRow) {
        if (!this.grid) return;
        const ds = this.grid.getBindDataset();
        if (ds && row >= 0) {
            ds.setColumn(row, this.colNm, val);
        }
    }

    setIndex(idx, row = this.currentRow) {
        if (idx === 0) {
            this.setValue(null, row);
        }
    }

    getInnerDataset() {
        return this.grid ? this.grid.getComboDataset(this.colNm) : null;
    }

    setDisplayColumn(col) {
        if (!this.grid) return;
        const idx = this.getCellIndex();
        if (idx !== -1) {
            this.grid.setCellProperty("body", idx, "combodatacol", col);
        }
    }

    getDisplayColumn() {
        if (!this.grid) return null;
        const idx = this.getCellIndex();
        return idx !== -1
            ? this.grid.getCellProperty("body", idx, "combodatacol")
            : null;
    }

    reset(row = this.currentRow) {
        this.setValue(null, row);
    }
}

/**
 * 콤보 표시 컬럼 변경 시 호출되는 콜백 함수입니다.
 *
 * @callback DisplayChangeCallback
 * @param {ComboNode}             node  - 대상 콤보 노드
 * @param {'default' | 'allPath'} type  - 변경할 표시 타입
 * @returns {void}
 */

/**
 * @typedef {Object} ComboNodeConfig
 * @property {string} id
 * - 콤보 노드의 고유 식별자 (예: 'univ', 'dpmj')
 * @property {string | null} [parentId]
 * - 부모 콤보 노드의 고유 식별자
 * @property {nexacro.Combo} [component]
 * - 넥사크로 콤보 컴포넌트 (컴포넌트 콤보인 경우)
 * @property {nexacro.Grid} [grid]
 * - 넥사크로 그리드 컴포넌트 (그리드 콤보인 경우)
 * @property {string} [colNm]
 * - 그리드 콤보의 바인딩 컬럼명
 * @property {ComboAdapter} [adapter]
 * - 커스텀 어댑터 인스턴스
 * @property {string} codeField
 * - 데이터셋의 코드 컬럼명
 * @property {string} textField
 * - 데이터셋의 표시 텍스트 컬럼명
 * @property {string | Object} [parentCodeField]
 * - 데이터셋의 부모 코드 컬럼명 혹은 조상 노드 ID별 컬럼명 매핑 객체
 * @property {string} [allPathField]
 * - 전체 경로 표시를 위한 데이터셋 컬럼명 (예: 'ALL_PATH')
 * @property {'a' | 's' | 'n'} [mode]
 * - 콤보 모드. 설정 시 노드 등록 시점에 데이터셋 첫 행에 자동 삽입됩니다.
 *   'a': 전체, 's': 선택, 'n': 빈 행(그리드 어댑터만 삽입)
 * @property {DisplayChangeCallback} [onDisplayChange]
 * - 콤보 표시 컬럼 변경 시 호출되는 콜백 함수
 */

/** 콤보 노드의 설정을 관리하는 클래스입니다. */
export class ComboNode {
    /**
     * @param {ComboNodeConfig} config  - 콤보 노드 설정
     */
    constructor(config) {
        /**
         * @type {string}
         */
        this.id = config.id;
        /**
         * @type {string | null}
         */
        this.parentId = config.parentId || null;
        /**
         * @type {ComboAdapter}
         */
        this.adapter = config.adapter;
        /**
         * @type {string}
         */
        this.codeField = config.codeField;
        /**
         * @type {string}
         */
        this.textField = config.textField;
        /**
         * @type {string | null}
         */
        this.allPathField = config.allPathField || null;
        /**
         * @type {string | Object | null}
         */
        this.parentCodeField = config.parentCodeField || null;
        /**
         * @type {DisplayChangeCallback | null}
         */
        this.onDisplayChange = config.onDisplayChange || null;
    }

    /**
     * 지정한 조상 노드의 코드 값이 현재 노드의 데이터셋에서 어떤 컬럼명으로 관리되는지 조회합니다.
     *
     * @param {string} ancestorId    - 조상 노드 ID.
     * @param {string} defaultColNm  - 기본 컬럼명
     * @returns {string} 컬럼명
     */
    getAncestorColumnName(ancestorId, defaultColNm) {
        if (!this.parentCodeField) {
            return defaultColNm;
        }
        if (typeof this.parentCodeField === "string") {
            return this.parentId === ancestorId
                ? this.parentCodeField
                : defaultColNm;
        }
        if (typeof this.parentCodeField === "object") {
            return this.parentCodeField[ancestorId] || defaultColNm;
        }
        return defaultColNm;
    }

    /**
     * 콤보의 표시 컬럼을 변경합니다.
     *
     * @param {'default' | 'allPath'} type  - 변경할 표시 타입
     */
    changeDisplay(type) {
        if (this.onDisplayChange) {
            this.onDisplayChange(this, type);
        } else {
            const col =
                type === "allPath" && this.allPathField
                    ? this.allPathField
                    : this.textField;
            this.adapter.setDisplayColumn(col);
        }
    }
}

/** 연계된 다중 콤보(대학/학과/전공 등)의 필터링 및 자동 선택 전파를 관리하는 매니저 클래스입니다. */
export class LinkedComboManager {
    /**
     * @param {Object} [options]
     * - 매니저 설정 옵션
     * @param {function(string, *): string} [options.filterBuilder]
     * - 사용자 정의 필터
     * 문자열 생성 함수
     */
    constructor(options = {}) {
        /**
         * @type {Map<string, ComboNode>}
         */
        this.nodes = new Map();
        /**
         * @type {Set<nexacro.Component>}
         */
        this.lockKeys = new Set();
        /**
         * @type {*}
         */
        this.oldValue = "";

        /**
         * @type {function(string, *): string}
         */
        this.filterBuilder =
            options.filterBuilder ||
            ((codeField, val) => {
                return val
                    ? `ORG_CD==undefined || ${codeField} == '${val}'`
                    : "";
            });
    }

    /**
     * 콤보 노드를 등록합니다.
     *
     * @param {ComboNodeConfig} config  - 노드 설정 정보
     * @returns {LinkedComboManager} 자기 자신 (메소드 체이닝용)
     */
    addNode(config) {
        let adapter;
        if (config.component) {
            adapter = new ComboComponentAdapter(config.component);
        } else if (config.grid && config.colNm) {
            adapter = new ComboGridAdapter(config.grid, config.colNm);
        } else if (config.adapter) {
            adapter = config.adapter;
        } else {
            throw new Error(
                "Either component, grid & colNm, or adapter must be provided",
            );
        }

        const node = new ComboNode({
            id: config.id,
            parentId: config.parentId,
            adapter: adapter,
            codeField: config.codeField,
            textField: config.textField,
            allPathField: config.allPathField,
            parentCodeField: config.parentCodeField,
            onDisplayChange: config.onDisplayChange,
        });

        if (config.mode) {
            const ds = adapter.getInnerDataset();
            if (ds) {
                const cols = [config.textField];
                if (config.allPathField) cols.push(config.allPathField);
                const insertNewRow = adapter instanceof ComboGridAdapter;
                insertModeRow(ds, cols, config.mode, { insertNewRow });
            }
        }

        this.nodes.set(node.id, node);
        return this;
    }

    /**
     * 노드의 모든 조상 노드를 가장 가까운 부모부터 루트 노드 순으로 반환합니다.
     *
     * @param {string} id  - 노드 ID.
     * @returns {ComboNode[]} 조상 노드 배열
     */
    getAncestors(id) {
        const ancestors = [];
        let current = this.nodes.get(id);
        while (current && current.parentId) {
            const parent = this.nodes.get(current.parentId);
            if (parent) {
                ancestors.push(parent);
            }
            current = parent;
        }
        return ancestors;
    }

    /**
     * 노드의 모든 자손 노드를 수집하여 반환합니다.
     *
     * @param {string} id  - 노드 ID.
     * @returns {ComboNode[]} 자손 노드 배열
     */
    getDescendants(id) {
        const descendants = [];
        const collect = (nodeId) => {
            this.nodes.forEach((node) => {
                if (node.parentId === nodeId) {
                    descendants.push(node);
                    collect(node.id);
                }
            });
        };
        collect(id);
        return descendants;
    }

    /**
     * 지정한 행 인덱스를 모든 그리드 어댑터에 설정합니다.
     *
     * @param {number} row  - 그리드 행 인덱스
     */
    setCurrentRow(row) {
        this.nodes.forEach((node) => {
            if (node.adapter instanceof ComboGridAdapter) {
                node.adapter.currentRow = row;
            }
        });
    }

    /**
     * 조상 노드들의 현재 선택값에 기반하여 지정 노드의 데이터셋 필터를 설정하고 표시 열을 변경합니다.
     *
     * @param {ComboNode} node  - 대상 콤보 노드
     */
    filterNode(node) {
        const ds = node.adapter.getInnerDataset();
        if (!ds) return;

        const ancestors = this.getAncestors(node.id);
        const activeAncestor = ancestors.find((anc) => {
            const val = anc.adapter.getValue();
            return val !== undefined && val !== null && val !== "";
        });

        if (activeAncestor) {
            const val = activeAncestor.adapter.getValue();
            const filterColNm = node.getAncestorColumnName(
                activeAncestor.id,
                activeAncestor.codeField,
            );
            const filterStr = this.filterBuilder(filterColNm, val);
            this.setDatasetFilter(ds, filterStr);

            const isDirectParent = activeAncestor.id === node.parentId;
            if (!isDirectParent && node.allPathField) {
                node.changeDisplay("allPath");
            } else {
                node.changeDisplay("default");
            }
        } else {
            this.setDatasetFilter(ds, "");
            if (node.allPathField) {
                node.changeDisplay("allPath");
            } else {
                node.changeDisplay("default");
            }
        }
    }

    /**
     * 하위 노드의 선택에 따라 상위 노드들을 자동으로 채우고, 자손 노드들은 초기화합니다.
     *
     * @param {ComboNode} node           - 선택 이벤트가 발생한 노드
     * @param {*}         selectedValue  - 선택된 값
     */
    propagateSelection(node, selectedValue) {
        const descendants = this.getDescendants(node.id);

        // 자손 노드 초기화
        descendants.forEach((desc) => {
            desc.adapter.reset();
        });

        // 빈 값이 아닌 경우 조상 노드 자동 완성
        if (
            selectedValue !== undefined &&
            selectedValue !== null &&
            selectedValue !== ""
        ) {
            const ds = node.adapter.getInnerDataset();
            if (ds) {
                const rowIdx = ds.findRow(node.codeField, selectedValue);
                if (rowIdx !== -1) {
                    const ancestors = this.getAncestors(node.id);
                    ancestors.forEach((anc) => {
                        const parentColNm = node.getAncestorColumnName(
                            anc.id,
                            anc.codeField,
                        );
                        const parentVal = ds.getColumn(rowIdx, parentColNm);
                        if (
                            parentVal !== undefined &&
                            parentVal !== null &&
                            parentVal !== ""
                        ) {
                            anc.adapter.setValue(parentVal);
                        }
                    });
                }
            }
        }
    }

    /**
     * 데이터셋에 필터를 적용하며, 이전 필터와 동일한 경우 필터링 동작을 스킵합니다.
     *
     * @param {nexacro.Dataset} ds         - 대상 데이터셋
     * @param {string}          filterStr  - 필터 조건 문자열
     */
    setDatasetFilter(ds, filterStr) {
        if (!ds) return;
        if (!ds.__linkedCmb) {
            ds.__linkedCmb = {};
        }
        if (ds.__linkedCmb.prevFilter === filterStr) {
            return;
        }
        ds.__linkedCmb.prevFilter = filterStr;
        ds.filter(filterStr);
    }

    /** 모든 콤보 노드의 필터링 상태를 제거합니다. */
    clearAllFilters() {
        this.nodes.forEach((node) => {
            const ds = node.adapter.getInnerDataset();
            if (ds) {
                this.setDatasetFilter(ds, "");
            }
        });
    }

    /** 모든 콤보 노드의 표시 열을 기본값으로 초기화합니다. */
    resetDisplayColumns() {
        this.nodes.forEach((node) => {
            node.changeDisplay("default");
        });
    }

    /**
     * 컴포넌트를 감싸는 노드를 찾습니다.
     *
     * @param {nexacro.Combo} comp  - 넥사크로 콤보 컴포넌트
     * @returns {ComboNode | null} 매칭되는 노드
     */
    findNodeByComponent(comp) {
        for (const node of this.nodes.values()) {
            if (
                node.adapter instanceof ComboComponentAdapter &&
                node.adapter.comp === comp
            ) {
                return node;
            }
        }
        return null;
    }

    /**
     * 그리드 컴포넌트와 컬럼명에 매칭되는 노드를 찾습니다.
     *
     * @param {nexacro.Grid} grid   - 넥사크로 그리드 컴포넌트
     * @param {string}       colNm  - 그리드 바인딩 컬럼명
     * @returns {ComboNode | null} 매칭되는 노드
     */
    findNodeByGridColumn(grid, colNm) {
        for (const node of this.nodes.values()) {
            if (
                node.adapter instanceof ComboGridAdapter &&
                node.adapter.grid === grid &&
                node.adapter.colNm === colNm
            ) {
                return node;
            }
        }
        return null;
    }

    /** 등록된 콤보 컴포넌트들에 대한 이벤트 리스너를 일괄 연결합니다. */
    attachEvents() {
        this.nodes.forEach((node) => {
            if (node.adapter instanceof ComboComponentAdapter) {
                const comp = node.adapter.comp;
                if (!comp) return;

                comp.addEventHandler(
                    "ondropdown",
                    (obj, e) => {
                        if (this.lockKeys.has(obj)) return;
                        this.handleComponentDropdown(node);
                    },
                    this,
                );

                comp.addEventHandler(
                    "oncloseup",
                    (obj, e) => {
                        if (this.lockKeys.has(obj)) return;
                        this.handleComponentCloseup(node, e.postvalue);
                    },
                    this,
                );

                comp.addEventHandler(
                    "onkillfocus",
                    (obj, e) => {
                        this.lockKeys.delete(obj);
                        this.handleComponentKillfocus(node);
                    },
                    this,
                );

                comp.addEventHandler(
                    "oninput",
                    (obj, e) => {
                        if (this.lockKeys.has(obj)) return;
                        this.lockKeys.add(obj);
                        this.handleComponentDropdown(node);
                    },
                    this,
                );

                comp.addEventHandler(
                    "onitemchanged",
                    (obj) => {
                        this.lockKeys.delete(obj);
                    },
                    this,
                );
            }
        });
    }

    /**
     * 컴포넌트 드롭다운 시 동작을 수행합니다.
     *
     * @param {ComboNode} node  - 대상 콤보 노드
     */
    handleComponentDropdown(node) {
        this.oldValue = node.adapter.getValue();
        this.filterNode(node);
    }

    /**
     * 컴포넌트의 선택이 닫힐 때 상하위 노드 제어 및 리필터링을 수행합니다.
     *
     * @param {ComboNode} node       - 대상 콤보 노드
     * @param {*}         postValue  - 변경 후 값
     */
    handleComponentCloseup(node, postValue) {
        if (postValue === this.oldValue) return;

        this.propagateSelection(node, postValue);

        // 자손 노드들의 필터를 갱신하여 정렬 상태를 일치시킴
        const descendants = this.getDescendants(node.id);
        descendants.forEach((desc) => {
            this.filterNode(desc);
        });

        this.resetDisplayColumns();
    }

    /**
     * 컴포넌트 포커스를 잃을 때 필터를 초기화합니다.
     *
     * @param {ComboNode} node  - 대상 콤보 노드
     */
    handleComponentKillfocus(node) {
        this.clearAllFilters();
        this.resetDisplayColumns();
    }

    /**
     * 그리드에 대한 이벤트 리스너를 연결합니다.
     *
     * @param {nexacro.Grid} grid  - 대상 그리드 컴포넌트
     */
    attachGridEvents(grid) {
        grid.addEventHandler(
            "ondropdown",
            (obj, e) => {
                this.handleGridDropdown(obj, e);
            },
            this,
        );

        grid.addEventHandler(
            "oncloseup",
            (obj, e) => {
                this.handleGridCloseup(obj, e);
            },
            this,
        );
    }

    /**
     * 그리드 셀 드롭다운 시 동작을 수행합니다.
     *
     * @param {nexacro.Grid} grid
     * - 대상 그리드 컴포넌트
     * @param {nexacro.GridEditEventInfo} e
     * - 드롭다운 이벤트 객체
     */
    handleGridDropdown(grid, e) {
        const cellIdx = e.cell;
        const textProp = grid.getCellProperty("body", cellIdx, "text") || "";
        const bindColNm = textProp.replace("bind:", "");

        const node = this.findNodeByGridColumn(grid, bindColNm);
        if (!node) return;

        this.setCurrentRow(e.row);
        this.oldValue = e.value;

        this.filterNode(node);
    }

    /**
     * 그리드 셀 선택이 완료된 후 동작을 수행합니다.
     *
     * @param {nexacro.Grid} grid
     * - 대상 그리드 컴포넌트
     * @param {nexacro.GridEditEventInfo} e
     * - 클로즈업 이벤트 객체
     */
    handleGridCloseup(grid, e) {
        const cellIdx = e.cell;
        const textProp = grid.getCellProperty("body", cellIdx, "text") || "";
        const bindColNm = textProp.replace("bind:", "");

        const node = this.findNodeByGridColumn(grid, bindColNm);
        if (!node) return;

        this.setCurrentRow(e.row);

        if (e.value === this.oldValue) return;

        this.propagateSelection(node, e.value);

        // 연관 데이터셋의 필터를 모두 갱신하여 그리드 화면을 동기화함
        this.nodes.forEach((n) => {
            this.filterNode(n);
        });
    }
}

/**
 * 데이터셋 첫번째 행에 mode에 맞는 행을 추가합니다.
 *
 * @param {nexacro.Dataset}  dataset
 * 대상 데이터셋
 * @param {string[]}         columns
 * 대상 컬럼
 * @param {'a' | 's' | 'n'} mode
 * 콤보 모드 ('a': 전체, 's': 선택, 'n': 추가 안함)
 * @param {Object}           [option]
 * @param {boolean}          [option.insertNewRow=true]
 * 새 행 추가 여부
 */
export function insertModeRow(dataset, columns, mode, option) {
    const isInsertNewRow = option?.insertNewRow ?? true;

    dataset.enableevent = false;

    if (isInsertNewRow || mode !== "n") {
        dataset.insertRow(0);
    }

    columns.forEach((col) => {
        if (mode === "a") {
            dataset.setColumn(0, col, "전체");
        } else if (mode === "s") {
            dataset.setColumn(0, col, "선택");
        }
    });

    dataset.applyChange();
    dataset.enableevent = true;
}

/**
 * 연계 콤보 매니저 객체를 리턴합니다. 상세한 사용법은 연계 콤보 매니저({@link LinkedComboManager})를 참조하세요.
 *
 * @function getLinkedComboManager
 * @param {Object} [options]
 * - 매니저 설정 옵션
 * @param {function(string, *): string} [options.filterBuilder]
 * - 사용자 정의 필터 문자열 생성 함수
 * @returns {LinkedComboManager}
 * 연계 콤보 매니저 인스턴스
 * @memberof $f
 * @example
 * // 기본 사용법
 * const comboManager = $f.getLinkedComboManager();
 *
 */
export function getLinkedComboManager(options) {
    return new LinkedComboManager(options);
}