/**
* 콤보 요소를 제어하기 위한 추상 어댑터 클래스입니다.
*
* @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);
}