UNPKG

@ehfuse/forma

Version:

Advanced React state management library with individual field subscriptions - supports both forms and general state management with useFormaState

939 lines 44.3 kB
"use strict"; /** * FieldStore.ts * * Forma - 개별 필드 상태 관리 핵심 클래스 / Core class for individual field state management * 선택적 구독과 성능 최적화 지원 / Supports selective subscriptions and performance optimization * * @license MIT License * @copyright 2025 KIM YOUNG JIN (Kim Young Jin) * @author KIM YOUNG JIN (ehfuse@gmail.com) * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.FieldStore = void 0; const dotNotation_1 = require("../utils/dotNotation"); const environment_1 = require("../utils/environment"); class FieldStore { constructor(initialValues) { this.fields = new Map(); this.dotNotationListeners = new Map(); // Dot notation 구독자 / Dot notation subscribers this.globalListeners = new Set(); this.watchers = new Map(); // Watch 콜백 관리 / Watch callback management this.initialValues = { ...initialValues }; // 초기값으로 필드 초기화 / Initialize fields with initial values Object.keys(initialValues).forEach((key) => { this.fields.set(key, { value: initialValues[key], listeners: new Set(), }); }); } /** * 특정 필드 값 가져오기 / Get specific field value * Dot notation 지원 / Supports dot notation * @param fieldName 필드명 또는 dot notation 경로 또는 "*" (전체) / Field name or dot notation path or "*" (all) * @returns 필드 값 / Field value */ getValue(fieldName) { const fieldNameStr = fieldName; // "*" 패턴: 전체 상태 반환 / "*" pattern: return all state if (fieldNameStr === "*") { const allValues = this.getValues(); // 빈 객체이거나 모든 값이 undefined/null인 경우 undefined 반환 // Return undefined for empty objects or when all values are undefined/null const hasValidData = Object.values(allValues).some((value) => value !== undefined && value !== null && value !== ""); if (!hasValidData) { return undefined; } return allValues; } // dot notation이 포함된 경우 중첩 객체 접근 / Access nested object for dot notation if (fieldNameStr.includes(".")) { const values = this.getValues(); return (0, dotNotation_1.getNestedValue)(values, fieldNameStr); } // 일반 필드 접근 / Regular field access const field = this.fields.get(fieldName); return field?.value; } /** * 특정 필드 구독 / Subscribe to specific field * Dot notation 지원 / Supports dot notation * @param fieldName 필드명 또는 dot notation 경로 또는 "*" (전체) / Field name or dot notation path or "*" (all) * @param listener 변경 시 호출될 콜백 / Callback to call on change * @returns 구독 해제 함수 / Unsubscribe function */ subscribe(fieldName, listener) { const fieldNameStr = fieldName; // "*" 패턴: 전체 상태 변경 구독 / "*" pattern: subscribe to all state changes if (fieldNameStr === "*") { this.globalListeners.add(listener); return () => { this.globalListeners.delete(listener); }; } // dot notation이 포함된 경우 정확한 경로로 구독 / Subscribe to exact path for dot notation if (fieldNameStr.includes(".")) { let listeners = this.dotNotationListeners.get(fieldNameStr); if (!listeners) { listeners = new Set(); this.dotNotationListeners.set(fieldNameStr, listeners); } listeners.add(listener); // dot notation 필드가 구독될 때 기본값 생성 / Create default value when dot notation field is subscribed const currentValue = this.getValue(fieldNameStr); if (currentValue === undefined) { // 기본값은 undefined로 유지 (필요시에만 설정) // Keep default value as undefined (set only when needed) // this.setValue(fieldNameStr, undefined); // 주석 처리: 불필요한 초기화 방지 } return () => { const listeners = this.dotNotationListeners.get(fieldNameStr); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { this.dotNotationListeners.delete(fieldNameStr); } } }; } // 일반 필드 구독 / Regular field subscription let field = this.fields.get(fieldName); if (!field) { // 필드가 없으면 생성 / Create field if not exists field = { value: undefined, // 초기값은 undefined로 설정 listeners: new Set(), }; this.fields.set(fieldName, field); } field.listeners.add(listener); return () => { field?.listeners.delete(listener); }; } /** * 전역 구독 / Global subscription * isModified 등을 위해 사용 / Used for isModified etc. * @param listener 변경 시 호출될 콜백 / Callback to call on change * @returns 구독 해제 함수 / Unsubscribe function */ subscribeGlobal(listener) { this.globalListeners.add(listener); return () => { this.globalListeners.delete(listener); }; } /** * 필드 값 설정 / Set field value * Dot notation 지원 / Supports dot notation * @param fieldName 필드명 또는 dot notation 경로 / Field name or dot notation path * @param value 설정할 값 / Value to set */ setValue(fieldName, value) { const fieldNameStr = fieldName; // dot notation이 포함된 경우 / For dot notation if (fieldNameStr.includes(".")) { const rootField = fieldNameStr.split(".")[0]; const rootFieldStr = String(rootField); const remainingPath = fieldNameStr.substring(rootFieldStr.length + 1); let field = this.fields.get(rootField); if (!field) { field = { value: {}, listeners: new Set(), }; this.fields.set(rootField, field); } const oldRootValue = field.value; const newRootValue = (0, dotNotation_1.setNestedValue)(field.value || {}, remainingPath, value); if (JSON.stringify(field.value) !== JSON.stringify(newRootValue)) { // 변경 전 자식 필드의 값 (watch용) const prevChildValue = (0, dotNotation_1.getNestedValue)(field.value, remainingPath); // ⭐ 변경 전 부모 경로들의 값 저장 (부모 watch용) const prevParentValues = new Map(); if (fieldNameStr.includes(".")) { const parts = fieldNameStr.split("."); for (let i = 1; i < parts.length; i++) { const parentPath = parts.slice(0, i).join("."); prevParentValues.set(parentPath, this.getValue(parentPath)); } } field.value = newRootValue; // 루트 필드 구독자들 알림 / Notify root field subscribers field.listeners.forEach((listener) => { listener(); }); // Dot notation 구독자들 알림 / Notify dot notation subscribers this.dotNotationListeners.forEach((listeners, subscribedPath) => { // 1. 정확히 일치하는 경로 if (subscribedPath === fieldNameStr) { listeners.forEach((listener) => listener()); } // 2. 자식 경로가 변경되면 부모 경로 구독자에게 알림 // 예: fieldNameStr이 "checkboxes.0.checked", subscribedPath가 "checkboxes" else if (fieldNameStr.startsWith(subscribedPath + ".")) { listeners.forEach((listener) => listener()); } // 3. 부모 경로가 변경되면 자식 경로 구독자에게 알림 (중복 제거 필요) // 예: fieldNameStr이 "checkboxes", subscribedPath가 "checkboxes.0" // 단, rootFieldStr와 정확히 같은 경로는 루트 필드 구독자가 이미 처리 else if (subscribedPath.startsWith(fieldNameStr + ".") && subscribedPath !== rootFieldStr) { listeners.forEach((listener) => listener()); } // 배열 필드나 .length 구독자들에게 알림 // Notify array field or .length subscribers else if (subscribedPath === `${rootFieldStr}.length`) { // 이전 길이와 새 길이 계산 const oldLength = Array.isArray(oldRootValue) ? oldRootValue.length : 0; const newLength = Array.isArray(newRootValue) ? newRootValue.length : 0; // 길이가 변경되었거나 undefined에서 배열로 변경된 경우 알림 if (oldLength !== newLength || (!oldRootValue && newRootValue)) { listeners.forEach((listener) => listener()); } } }); // 전역 구독자들 알림 / Notify global subscribers this.globalListeners.forEach((listener) => listener()); // Watcher 실행 (와일드카드 매칭 포함) / Execute watcher (including wildcard matching) this.notifyWatchers(fieldNameStr, value, prevChildValue, prevParentValues); } return; } // 일반 필드 설정 / Regular field setting let field = this.fields.get(fieldName); if (!field) { field = { value: undefined, listeners: new Set(), }; this.fields.set(fieldName, field); } if (field.value !== value) { const oldValue = field.value; field.value = value; const fieldStr = fieldName; // 해당 필드 구독자들 알림 / Notify field subscribers field.listeners.forEach((listener) => { listener(); }); // Dot notation 구독자들 알림 / Notify dot notation subscribers this.dotNotationListeners.forEach((listeners, subscribedPath) => { // 1. 정확히 일치하는 경로 if (subscribedPath === fieldStr) { listeners.forEach((listener) => listener()); } // 2. 배열 필드나 .length 구독자들에게 알림 // Notify array field or .length subscribers else if (subscribedPath === `${fieldStr}.length`) { // 이전 길이와 새 길이 계산 const oldLength = Array.isArray(oldValue) ? oldValue.length : 0; const newLength = Array.isArray(value) ? value.length : 0; // 길이가 변경되었거나 undefined에서 배열로 변경된 경우 알림 if (oldLength !== newLength || (!oldValue && value)) { listeners.forEach((listener) => listener()); } } // 3. 객체 필드 전체 교체 시 실제로 값이 변경된 개별 필드 구독자들에게만 알림 // Notify individual field subscribers only if their actual values changed when entire object is replaced else if (subscribedPath.startsWith(fieldStr + ".") && typeof value === "object" && value !== null && !Array.isArray(value)) { // customer.name, customer.seq 등의 자식 경로 const childPath = subscribedPath.substring(fieldStr.length + 1); const oldChildValue = oldValue && typeof oldValue === "object" ? (0, dotNotation_1.getNestedValue)(oldValue, childPath) : undefined; const newChildValue = (0, dotNotation_1.getNestedValue)(value, childPath); // 실제로 값이 변경된 경우에만 알림 if (JSON.stringify(oldChildValue) !== JSON.stringify(newChildValue)) { listeners.forEach((listener) => listener()); } } // 🔥 배열 전체 교체 시 실제로 값이 변경된 개별 필드 구독자들에게만 알림 // Notify individual field subscribers only if their actual values changed when entire array is replaced else if (Array.isArray(value) && Array.isArray(oldValue) && subscribedPath.startsWith(`${fieldStr}.`)) { const pathParts = subscribedPath.split("."); if (pathParts.length >= 2 && pathParts[0] === fieldStr) { const index = parseInt(pathParts[1]); if (!isNaN(index) && index >= 0) { // 해당 인덱스의 값을 비교 (전체 객체 또는 특정 속성) const pathAfterIndex = pathParts.slice(1).join("."); const oldItemValue = (0, dotNotation_1.getNestedValue)(oldValue, pathAfterIndex); const newItemValue = (0, dotNotation_1.getNestedValue)(value, pathAfterIndex); // 실제로 값이 변경된 경우에만 알림 if (JSON.stringify(oldItemValue) !== JSON.stringify(newItemValue)) { listeners.forEach((listener) => listener()); } } } } // 배열이 새로 생성되거나 삭제된 경우 (undefined → array 또는 array → undefined) else if (subscribedPath.startsWith(`${fieldStr}.`) && ((Array.isArray(value) && !Array.isArray(oldValue)) || (!Array.isArray(value) && Array.isArray(oldValue)))) { listeners.forEach((listener) => listener()); } }); // 전역 구독자들 알림 / Notify global subscribers if (this.globalListeners.size > 0) { this.globalListeners.forEach((listener) => listener()); } // Watcher 실행 (와일드카드 매칭 포함) / Execute watcher (including wildcard matching) this.notifyWatchers(fieldStr, value, oldValue); } } /** * 모든 값 가져오기 / Get all values * @returns 모든 필드 값을 포함한 객체 / Object containing all field values */ getValues() { const values = {}; this.fields.forEach((field, key) => { // 와일드카드 구독을 위해 undefined 값을 그대로 유지 // Keep undefined values as-is for wildcard subscriptions values[key] = field.value; }); return values; } /** * 모든 값 설정 / Set all values * @param newValues 설정할 값들 / Values to set */ setValues(newValues) { if (!newValues || Object.keys(newValues).length === 0) { return; } // 성능 최적화: 영향받는 리스너들을 먼저 수집 const affectedListeners = new Set(); const watcherNotifications = []; // 각 업데이트를 개별적으로 처리하되, 리스너 실행은 마지막에 일괄 처리 Object.entries(newValues).forEach(([fieldName, value]) => { // 이전 값 저장 (watch 알림용) const prevValue = this.getValue(fieldName); this.setValueWithoutNotify(fieldName, value, affectedListeners); // watch 알림 예약 watcherNotifications.push({ path: fieldName, value, prevValue }); }); // 글로벌 리스너들도 추가 this.globalListeners.forEach((listener) => affectedListeners.add(listener)); // 배치로 모든 영향받는 리스너들 실행 affectedListeners.forEach((listener) => { try { listener(); } catch (error) { (0, environment_1.devError)("setValues 리스너 실행 중 오류:", error); } }); // watch 알림 실행 watcherNotifications.forEach(({ path, value, prevValue }) => { this.notifyWatchers(path, value, prevValue); }); } /** * 초기값 재설정 / Reset initial values * @param newInitialValues 새로운 초기값 / New initial values */ setInitialValues(newInitialValues) { this.initialValues = { ...newInitialValues }; // 기존 리스너를 보존하면서 값만 업데이트 / Update values while preserving existing listeners Object.keys(newInitialValues).forEach((key) => { const existingField = this.fields.get(key); if (existingField) { // 기존 필드가 있으면 값만 업데이트 / Update value only if field exists existingField.value = newInitialValues[key]; } else { // 새 필드면 생성 / Create new field this.fields.set(key, { value: newInitialValues[key], listeners: new Set(), }); } }); // 모든 리스너에게 알림 / Notify all listeners this.fields.forEach((field) => { field.listeners.forEach((listener) => listener()); }); this.globalListeners.forEach((listener) => listener()); } /** * 수정 여부 확인 / Check if modified * @returns 초기값에서 변경되었는지 여부 / Whether changed from initial values */ isModified() { const currentValues = this.getValues(); // Pure Zero-Config의 경우 초기값이 빈 객체일 수 있음 // In Pure Zero-Config, initial values might be an empty object const isInitialEmpty = Object.keys(this.initialValues).length === 0; if (isInitialEmpty) { // 초기값이 빈 객체인 경우, 현재값에 의미있는 데이터가 있는지 확인 // If initial values are empty, check if current values have meaningful data return this.hasNonEmptyValues(currentValues); } return (JSON.stringify(currentValues) !== JSON.stringify(this.initialValues)); } /** * 객체에 비어있지 않은 값이 있는지 확인 / Check if object has non-empty values */ hasNonEmptyValues(obj) { for (const key in obj) { const value = obj[key]; if (value !== undefined && value !== null && value !== "" && value !== 0) { if (typeof value === "object" && value !== null) { if (Array.isArray(value)) { if (value.length > 0) return true; } else { if (this.hasNonEmptyValues(value)) return true; } } else { return true; } } } return false; } /** * 특정 필드가 존재하는지 확인 / Check if a specific field exists * @param path 필드 경로 (dot notation 지원) / Field path (supports dot notation) * @returns 필드 존재 여부 / Whether the field exists */ hasField(path) { const currentValues = this.getValues(); try { const value = (0, dotNotation_1.getNestedValue)(currentValues, path); return value !== undefined; } catch { return false; } } /** * 특정 필드를 제거 / Remove a specific field * @param path 필드 경로 (dot notation 지원) / Field path (supports dot notation) */ removeField(path) { const currentValues = this.getValues(); const pathParts = path.split("."); if (pathParts.length === 1) { // 루트 레벨 필드 제거 / Remove root level field delete currentValues[pathParts[0]]; this.fields.delete(pathParts[0]); } else { // 중첩된 필드 제거 / Remove nested field const parentPath = pathParts.slice(0, -1).join("."); const fieldName = pathParts[pathParts.length - 1]; const parent = (0, dotNotation_1.getNestedValue)(currentValues, parentPath); if (parent && typeof parent === "object") { if (Array.isArray(parent)) { const index = parseInt(fieldName, 10); if (!isNaN(index) && index >= 0 && index < parent.length) { parent.splice(index, 1); } } else { delete parent[fieldName]; } } } this.setValues(currentValues); // 해당 필드의 구독자들에게 알림 / Notify subscribers of this field this.dotNotationListeners.forEach((listeners, subscribedPath) => { if (subscribedPath === path) { listeners.forEach((listener) => listener()); } }); // 전역 구독자들 알림 / Notify global subscribers if (this.globalListeners.size > 0) { this.globalListeners.forEach((listener) => listener()); } } /** * 전역 상태 변경에 구독 / Subscribe to global state changes * @param callback 상태 변경 시 실행될 콜백 / Callback to execute on state change * @returns 구독 해제 함수 / Unsubscribe function */ subscribeToAll(callback) { const wrappedCallback = () => { callback(this.getValues()); }; this.globalListeners.add(wrappedCallback); return () => { this.globalListeners.delete(wrappedCallback); }; } /** * 특정 prefix를 가진 모든 필드 구독자들을 새로고침합니다 * Refresh all field subscribers with specific prefix * @param prefix 새로고침할 필드 prefix (예: "address") */ refreshFields(prefix) { const prefixWithDot = prefix + "."; // 성능 최적화: 리스너들을 먼저 수집한 후 배치 실행 const listenersToNotify = new Set(); // 일반 필드 구독자들 중 prefix와 일치하는 경우 수집 this.fields.forEach((field, key) => { const keyStr = String(key); if (keyStr === prefix || keyStr.startsWith(prefixWithDot)) { field.listeners.forEach((listener) => { listenersToNotify.add(listener); }); } }); // Dot notation 구독자들 중 prefix와 일치하는 경우 수집 this.dotNotationListeners.forEach((listeners, subscribedPath) => { if (subscribedPath === prefix || subscribedPath.startsWith(prefixWithDot)) { listeners.forEach((listener) => { listenersToNotify.add(listener); }); } }); // 배치 실행: 중복 제거된 리스너들을 한 번에 실행 // 마이크로태스크로 실행하여 동기 작업 완료 후 리렌더링 수행 if (listenersToNotify.size > 0) { Promise.resolve().then(() => { listenersToNotify.forEach((listener) => { try { listener(); } catch (error) { (0, environment_1.devError)("refreshFields 리스너 실행 중 오류:", error); } }); }); } } /** * Batch update multiple fields efficiently * 여러 필드를 효율적으로 일괄 업데이트 * @param updates - 업데이트할 필드들의 키-값 쌍 */ setBatch(updates) { if (!updates || Object.keys(updates).length === 0) { return; } // 성능 최적화: 영향받는 리스너들을 먼저 수집 const affectedListeners = new Set(); // 각 업데이트를 개별적으로 처리하되, 리스너 실행은 마지막에 일괄 처리 Object.entries(updates).forEach(([fieldName, value]) => { this.setValueWithoutNotify(fieldName, value, affectedListeners); }); // 글로벌 리스너들도 추가 this.globalListeners.forEach((listener) => affectedListeners.add(listener)); // 배치로 모든 영향받는 리스너들 실행 affectedListeners.forEach((listener) => { try { listener(); } catch (error) { (0, environment_1.devError)("setBatch 리스너 실행 중 오류:", error); } }); } /** * Set value without immediately notifying listeners (for batch operations) * 리스너 알림 없이 값 설정 (배치 작업용) */ setValueWithoutNotify(fieldName, value, affectedListeners) { // dot notation이 포함된 경우 if (fieldName.includes(".")) { const rootField = fieldName.split(".")[0]; const rootFieldStr = String(rootField); const remainingPath = fieldName.substring(rootFieldStr.length + 1); let field = this.fields.get(rootField); if (!field) { field = { value: {}, listeners: new Set(), }; this.fields.set(rootField, field); } const oldRootValue = field.value; const newRootValue = (0, dotNotation_1.setNestedValue)(field.value || {}, remainingPath, value); if (JSON.stringify(field.value) !== JSON.stringify(newRootValue)) { field.value = newRootValue; // 루트 필드 구독자들 수집 field.listeners.forEach((listener) => { affectedListeners.add(listener); }); // Dot notation 구독자들 수집 this.dotNotationListeners.forEach((listeners, subscribedPath) => { if (subscribedPath === fieldName) { listeners.forEach((listener) => affectedListeners.add(listener)); } // 배열 필드나 .length 구독자들에게 알림 else if (subscribedPath === `${rootFieldStr}.length`) { const oldLength = Array.isArray(oldRootValue) ? oldRootValue.length : 0; const newLength = Array.isArray(newRootValue) ? newRootValue.length : 0; if (oldLength !== newLength) { listeners.forEach((listener) => affectedListeners.add(listener)); } } // 부모 경로가 변경된 경우 하위 구독자들도 알림 else if (subscribedPath.startsWith(`${fieldName}.`)) { listeners.forEach((listener) => affectedListeners.add(listener)); } }); } } else { // 일반 필드 처리 const oldValue = this.fields.has(fieldName) ? this.fields.get(fieldName).value : undefined; if (!this.fields.has(fieldName)) { this.fields.set(fieldName, { value: value, listeners: new Set(), }); } else { const field = this.fields.get(fieldName); if (field) { field.value = value; } } // 값이 실제로 변경된 경우에만 리스너 수집 if (JSON.stringify(oldValue) !== JSON.stringify(value)) { const field = this.fields.get(fieldName); if (field) { // 루트 필드 구독자들 수집 field.listeners.forEach((listener) => { affectedListeners.add(listener); }); } const fieldStr = fieldName; // Dot notation 구독자들 수집 (setValue와 동일한 로직) this.dotNotationListeners.forEach((listeners, subscribedPath) => { // 1. 정확히 일치하는 경로 if (subscribedPath === fieldStr) { listeners.forEach((listener) => affectedListeners.add(listener)); } // 2. 배열 필드나 .length 구독자들에게 알림 else if (subscribedPath === `${fieldStr}.length`) { const oldLength = Array.isArray(oldValue) ? oldValue.length : 0; const newLength = Array.isArray(value) ? value.length : 0; if (oldLength !== newLength || (!oldValue && value)) { listeners.forEach((listener) => affectedListeners.add(listener)); } } // 3. 객체 필드 전체 교체 시 실제로 값이 변경된 개별 필드 구독자들에게만 알림 else if (subscribedPath.startsWith(fieldStr + ".") && typeof value === "object" && value !== null && !Array.isArray(value)) { const childPath = subscribedPath.substring(fieldStr.length + 1); const oldChildValue = oldValue && typeof oldValue === "object" ? (0, dotNotation_1.getNestedValue)(oldValue, childPath) : undefined; const newChildValue = (0, dotNotation_1.getNestedValue)(value, childPath); if (JSON.stringify(oldChildValue) !== JSON.stringify(newChildValue)) { listeners.forEach((listener) => affectedListeners.add(listener)); } } // 4. 배열 전체 교체 시 실제로 값이 변경된 개별 필드 구독자들에게만 알림 else if (Array.isArray(value) && Array.isArray(oldValue) && subscribedPath.startsWith(`${fieldStr}.`)) { const pathParts = subscribedPath.split("."); if (pathParts.length >= 2 && pathParts[0] === fieldStr) { const index = parseInt(pathParts[1]); if (!isNaN(index) && index >= 0) { const pathAfterIndex = pathParts .slice(1) .join("."); const oldItemValue = (0, dotNotation_1.getNestedValue)(oldValue, pathAfterIndex); const newItemValue = (0, dotNotation_1.getNestedValue)(value, pathAfterIndex); if (JSON.stringify(oldItemValue) !== JSON.stringify(newItemValue)) { listeners.forEach((listener) => affectedListeners.add(listener)); } } } } // 5. 배열이 새로 생성되거나 삭제된 경우 else if (subscribedPath.startsWith(`${fieldStr}.`) && ((Array.isArray(value) && !Array.isArray(oldValue)) || (!Array.isArray(value) && Array.isArray(oldValue)))) { listeners.forEach((listener) => affectedListeners.add(listener)); } }); } } } /** * 초기값으로 리셋 / Reset to initial values */ reset() { // Pure Zero-Config 모드인지 확인 (초기값이 빈 객체) const isPureZeroConfig = Object.keys(this.initialValues).length === 0; if (isPureZeroConfig) { // Pure Zero-Config 모드: 먼저 구독된 dot notation 필드들의 기본값 설정 this.dotNotationListeners.forEach((listeners, path) => { if (listeners.size > 0) { // 구독자가 있는 dot notation 필드는 빈 문자열로 설정 this.setValue(path, ""); } }); // 일반 필드들 중에서 dot notation과 충돌하지 않는 것들만 기본값으로 설정 this.fields.forEach((field, key) => { const keyStr = String(key); // dot notation 필드의 부모가 아닌 경우에만 null로 설정 let hasChildDotNotation = false; for (const dotPath of this.dotNotationListeners.keys()) { if (dotPath.startsWith(keyStr + ".")) { hasChildDotNotation = true; break; } } if (!hasChildDotNotation) { field.value = ""; } }); } else { // 일반 모드: initialValues로 복원 // Normal mode: Restore to initialValues Object.keys(this.initialValues).forEach((key) => { const field = this.fields.get(key); if (field) { field.value = this.initialValues[key]; } }); // 2. 누락된 초기값 필드들 추가 Object.keys(this.initialValues).forEach((key) => { if (!this.fields.has(key)) { this.fields.set(key, { value: this.initialValues[key], listeners: new Set(), }); } }); // 3. dot notation 구독자가 있는 필드들도 초기값으로 재설정 // 이는 중첩된 필드 (예: labels, items 등)가 배열/객체일 때 중요 this.dotNotationListeners.forEach((listeners, path) => { if (listeners.size > 0 && !path.includes(".")) { // 최상위 레벨 필드만 (dot이 없는 경로) const initialValue = this.initialValues[path]; if (initialValue !== undefined) { // setValue가 아닌 직접 설정 (무한 루프 방지) if (!this.fields.has(path)) { this.fields.set(path, { value: initialValue, listeners: new Set(), }); } else { const field = this.fields.get(path); if (field) { field.value = initialValue; } } } } }); } // 모든 필드 리스너들에게 알림 this.fields.forEach((field) => { field.listeners.forEach((listener) => listener()); }); // dot notation 리스너들에게도 알림 this.dotNotationListeners.forEach((listeners) => { listeners.forEach((listener) => listener()); }); // 글로벌 리스너들에게도 알림 this.globalListeners.forEach((listener) => listener()); } /** * 필드 변경 감시 / Watch field changes * @param path 감시할 필드 경로 (dot notation 지원) / Field path to watch (supports dot notation) * @param callback 변경 시 실행할 콜백 / Callback to execute on change * @param options 옵션 / Options * @returns cleanup 함수 / Cleanup function */ watch(path, callback, options) { if (!this.watchers.has(path)) { this.watchers.set(path, new Set()); } const watcherSet = this.watchers.get(path); watcherSet.add(callback); // immediate: true면 현재 값으로 즉시 실행 / Execute immediately with current value if immediate: true if (options?.immediate) { const currentValue = this.getValue(path); callback(currentValue, undefined); } // cleanup 함수 반환 / Return cleanup function return () => { watcherSet.delete(callback); if (watcherSet.size === 0) { this.watchers.delete(path); } }; } /** * Watcher 알림 실행 / Notify watchers * @param path 변경된 필드 경로 / Changed field path * @param value 새 값 / New value * @param prevValue 이전 값 / Previous value * @param prevParentValues 부모 경로들의 이전 값 맵 / Map of previous values for parent paths */ notifyWatchers(path, value, prevValue, prevParentValues) { // 값이 실제로 변경되지 않았으면 알림하지 않음 / Skip notification if value hasn't actually changed if (JSON.stringify(value) === JSON.stringify(prevValue)) { return; } // 1. 정확한 경로 매칭 / Exact path match const exactWatchers = this.watchers.get(path); if (exactWatchers && exactWatchers.size > 0) { exactWatchers.forEach((callback) => { try { callback(value, prevValue); } catch (error) { console.error(`Error in watcher for path "${path}":`, error); } }); } // 2. 부모 경로들에게도 알림 / Notify parent paths // 예: filters.interval 변경 시 filters watcher도 트리거 if (path.includes(".")) { const parts = path.split("."); for (let i = parts.length - 1; i > 0; i--) { const parentPath = parts.slice(0, i).join("."); const parentWatchers = this.watchers.get(parentPath); if (parentWatchers && parentWatchers.size > 0) { // 부모 객체의 현재 값 const parentValue = this.getValue(parentPath); // 부모 객체의 이전 값 (미리 저장된 값 사용) const parentPrevValue = prevParentValues?.get(parentPath) || parentValue; parentWatchers.forEach((callback) => { try { callback(parentValue, parentPrevValue); } catch (error) { console.error(`Error in parent watcher for path "${parentPath}" (triggered by "${path}"):`, error); } }); } } } // 3. 와일드카드 패턴 매칭 / Wildcard pattern matching // todos.0.completed 변경 시 "todos.*.completed" 패턴도 트리거 this.watchers.forEach((watcherSet, watcherPath) => { if (watcherPath.includes("*")) { if (this.matchesWildcard(path, watcherPath)) { watcherSet.forEach((callback) => { try { callback(value, prevValue); } catch (error) { console.error(`Error in wildcard watcher for pattern "${watcherPath}" (triggered by "${path}"):`, error); } }); } } }); } /** * 와일드카드 패턴 매칭 / Wildcard pattern matching * @param path 실제 경로 / Actual path (e.g., "todos.0.completed") * @param pattern 와일드카드 패턴 / Wildcard pattern (e.g., "todos.*.completed") * @returns 매칭 여부 / Whether path matches pattern */ matchesWildcard(path, pattern) { const pathParts = path.split("."); const patternParts = pattern.split("."); if (pathParts.length !== patternParts.length) { return false; } for (let i = 0; i < patternParts.length; i++) { if (patternParts[i] === "*") { continue; // 와일드카드는 모든 값과 매칭 / Wildcard matches any value } if (patternParts[i] !== pathParts[i]) { return false; } } return true; } /** * 특정 path에 watcher가 등록되어 있는지 확인 / Check if watcher is registered for specific path * @param path 확인할 경로 / Path to check * @returns watcher 등록 여부 / Whether watcher is registered */ hasWatcher(path) { return this.watchers.has(path); } /** * 등록된 모든 watcher path 목록 반환 (디버깅용) / Return all registered watcher paths (for debugging) * @returns watcher path 배열 / Array of watcher paths */ getWatchedPaths() { return Array.from(this.watchers.keys()); } /** * 리소스 정리 / Clean up resources */ destroy() { this.fields.clear(); this.globalListeners.clear(); this.dotNotationListeners.clear(); this.watchers.clear(); } } exports.FieldStore = FieldStore; //# sourceMappingURL=FieldStore.js.map