import { ReactElement, createElement, useCallback, useEffect, useRef, useState } from "react";
import "../ui/SpecGridFilterNSort.css";
import { FilterInfo, FilterNSortPopupProps, SortInfo, SpecColumn } from "typings/types";
// ===== 다국어 메시지 =====
const messages = {
ko: {
sort: "정렬",
sortAsc: "오름차순 정렬",
sortDesc: "내림차순 정렬",
sortReset: "정렬 안함",
hideColumn: "열 숨기기",
hideColumnDisabledTooltip: "마지막 컬럼은 숨길 수 없습니다",
freeze: "고정",
unfreeze: "고정 해제",
filter: "필터",
searchPlaceholder: "검색",
btnFilter: "필터",
btnClear: "지우기",
showFilterIcon: "필터 표시",
method_contains: "포함",
method_notcontains: "포함하지 않음",
method_startwith: "시작 문자",
method_endwith: "끝 문자",
method_equal: "같음",
method_nequal: "같지 않음"
},
en: {
sort: "Sort",
sortAsc: "Sort Ascending",
sortDesc: "Sort Descending",
sortReset: "Clear Sort",
hideColumn: "Hide Column",
hideColumnDisabledTooltip: "Cannot hide the last visible column",
freeze: "Freeze",
unfreeze: "Unfreeze",
filter: "Filter",
searchPlaceholder: "Search",
btnFilter: "Apply",
btnClear: "Clear",
showFilterIcon: "Show filter",
method_contains: "Contains",
method_notcontains: "Not contains",
method_startwith: "Starts with",
method_endwith: "Ends with",
method_equal: "Equal",
method_nequal: "Not equal"
}
};
// ===== 언어 감지 =====
function detectLang(): "ko" | "en" {
try {
const w = window as any;
const mxLocale =
w.mx?.session?.getConfig?.("locale.code") ||
w.mx?.session?.getUserLocale?.() ||
w.mx?.session?.locale?.code;
if (mxLocale && typeof mxLocale === "string") {
return mxLocale.toLowerCase().startsWith("ko") ? "ko" : "en";
}
} catch (e) {
console.warn("[FilterPopup] Mendix locale detect failed", e);
}
const browserLang = navigator.language || "ko";
return browserLang.toLowerCase().startsWith("ko") ? "ko" : "en";
}
// ===== 확장 props =====
interface ExtendedProps extends FilterNSortPopupProps {
onToggleFrozen?: (columnName: string) => void;
frozenColumnName?: string;
visibleColumnCount?: number;
showFilterIcons?: boolean;
onToggleFilterIcons?: (value: boolean) => void;
}
export function FilterNSortPopup(props: ExtendedProps): ReactElement {
// 다국어
const lang = detectLang();
const t = messages[lang];
const popupRef = useRef<HTMLDivElement>(null);
const options = [
{ label: t.method_contains, value: "contains" },
{ label: t.method_notcontains, value: "notcontains" },
{ label: t.method_startwith, value: "startwith" },
{ label: t.method_endwith, value: "endwith" },
{ label: t.method_equal, value: "equal" },
{ label: t.method_nequal, value: "nequal" }
];
const [columnName, setColumnName] = useState<string>("");
const [columns, setColumns] = useState<SpecColumn[]>(props.columns);
const [sortable, setSortable] = useState<boolean>(false);
const [filterable, setFilterable] = useState<boolean>(false);
const [sortInfo, setSortInfo] = useState<SortInfo[]>([]);
const [filterInfo, setFilterInfo] = useState<FilterInfo[]>([]);
const [currSortDirection, setCurrSortDirection] = useState<string>("");
const [currFilterMethod, setCurrFilterMethod] = useState<string>(options[0].value);
const [currFilterSearchWord, setCurrFilterSearchWord] = useState<string>("");
const [isFrozen, setIsFrozen] = useState<boolean>(false);
// 마지막 컬럼 체크
const isLastVisibleColumn = (props.visibleColumnCount ?? 0) <= 1;
// 리스너를 ref에 저장 (stale closure 방지)
const outsideClickListenerRef = useRef<((e: any) => void) | null>(null);
const contextMenuListenerRef = useRef<((e: any) => void) | null>(null);
useEffect(() => {
if (props.columns.length > 0) setColumns(props.columns);
}, [props.columns]);
useEffect(() => {
setSortInfo(props.sortInfo || []);
}, [props.sortInfo]);
useEffect(() => {
setFilterInfo(props.filterInfo || []);
}, [props.filterInfo]);
const showPopup = (target: any, isshow: Boolean) => {
if (!target) return;
if (isshow) {
target.style.opacity = "0";
target.style.display = "block";
} else {
target.style.opacity = "0";
target.style.display = "none";
}
};
// ===== 매 렌더링마다 ref.current 갱신 (최신 state 캡처) =====
contextMenuListenerRef.current = (event: any) => {
const grid = document.querySelector(`.${props.gridName}`);
if (!grid || !grid.contains(event.target)) return;
let thElement: HTMLElement | null = event.target;
while (thElement && thElement.nodeName !== "TH" && thElement !== grid) {
thElement = thElement.parentElement;
}
if (!thElement || thElement.nodeName !== "TH" || !props.usePopup) return;
const newColumnname = thElement.getAttribute("data-column-name");
if (!newColumnname || newColumnname === "_checked" || newColumnname === "_number") return;
event.preventDefault();
event.stopPropagation();
const found = props.columns.find((e: SpecColumn) => e.name === newColumnname);
if (!found) return;
setColumnName(newColumnname);
setSortable(found.sortable);
setFilterable(found.filterable);
const foundsort = sortInfo.find((e: SortInfo) => e.name === newColumnname);
setCurrSortDirection(foundsort ? foundsort.dir : "");
const foundFilter = filterInfo.find((e: FilterInfo) => e.name === newColumnname);
if (foundFilter) {
setCurrFilterMethod(foundFilter.method);
setCurrFilterSearchWord(foundFilter.search);
} else {
setCurrFilterMethod(options[0].value);
setCurrFilterSearchWord("");
}
setIsFrozen(props.frozenColumnName === newColumnname);
const target = popupRef.current;
if (!target) return;
showPopup(target, true);
requestAnimationFrame(() => {
if (!target) return;
const thRect = thElement!.getBoundingClientRect();
const panelRect = target.getBoundingClientRect();
let x = thRect.left + thRect.width / 2 - panelRect.width / 2;
let y = thRect.bottom;
const viewportWidth = window.innerWidth;
if (x + panelRect.width > viewportWidth - 10) {
x = viewportWidth - panelRect.width - 10;
}
if (x < 10) x = 10;
const viewportHeight = window.innerHeight;
if (y + panelRect.height > viewportHeight - 10) {
y = thRect.top - panelRect.height;
}
target.style.left = x + "px";
target.style.top = y + "px";
target.style.opacity = "1";
});
};
outsideClickListenerRef.current = (event: any) => {
if (!popupRef.current) return;
const isVisible = popupRef.current.style.display === "block";
if (!isVisible) return;
if (!popupRef.current.contains(event.target)) {
showPopup(popupRef.current, false);
setColumnName("");
}
};
// ===== 마운트 시 1번만 등록 =====
useEffect(() => {
const ctxHandler = (e: any) => contextMenuListenerRef.current?.(e);
const clickHandler = (e: any) => outsideClickListenerRef.current?.(e);
document.addEventListener("contextmenu", ctxHandler, true);
document.addEventListener("click", clickHandler, true);
return () => {
document.removeEventListener("contextmenu", ctxHandler, true);
document.removeEventListener("click", clickHandler, true);
if (popupRef.current) {
popupRef.current.style.display = "none";
popupRef.current.style.opacity = "0";
}
};
}, []);
// ===== ESC 키로 닫기 =====
useEffect(() => {
const escListener = (e: KeyboardEvent) => {
if (e.key === "Escape" && popupRef.current?.style.display === "block") {
showPopup(popupRef.current, false);
setColumnName("");
}
};
document.addEventListener("keydown", escListener);
return () => document.removeEventListener("keydown", escListener);
}, []);
// ===== 스크롤/리사이즈 시 닫기 =====
useEffect(() => {
const scrollListener = () => {
if (popupRef.current?.style.display === "block") {
showPopup(popupRef.current, false);
setColumnName("");
}
};
document.addEventListener("scroll", scrollListener, true);
window.addEventListener("resize", scrollListener);
return () => {
document.removeEventListener("scroll", scrollListener, true);
window.removeEventListener("resize", scrollListener);
};
}, []);
// ===== 그리드 visibility 체크 =====
useEffect(() => {
const visibilityChecker = setInterval(() => {
if (popupRef.current?.style.display === "block") {
const grid = document.querySelector(`.${props.gridName}`);
if (!grid || !(grid as any).checkVisibility?.()) {
showPopup(popupRef.current, false);
setColumnName("");
}
}
}, 500);
return () => clearInterval(visibilityChecker);
}, [props.gridName]);
// ===== 콜백들 =====
const cbClickSortAsc = useCallback(() => {
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeSort) return;
setCurrSortDirection("asc");
props.onChangeSort({
name: columnName,
dir: "asc",
multiple: found.sortMultiple
});
showPopup(popupRef.current, false);
}, [props, columns, columnName]);
const cbClickSortDesc = useCallback(() => {
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeSort) return;
setCurrSortDirection("desc");
props.onChangeSort({
name: columnName,
dir: "desc",
multiple: found.sortMultiple
});
showPopup(popupRef.current, false);
}, [props, columns, columnName]);
const cbClickSortReset = useCallback(() => {
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeSort) return;
setCurrSortDirection("clear");
props.onChangeSort({
name: columnName,
dir: "clear",
multiple: found.sortMultiple
});
showPopup(popupRef.current, false);
}, [props, columns, columnName]);
const cbClickHideColumn = useCallback(() => {
if (isLastVisibleColumn) return;
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeHideColumn) return;
props.onChangeHideColumn(columnName);
showPopup(popupRef.current, false);
}, [props, columns, columnName, isLastVisibleColumn]);
const cbClickToggleFrozen = useCallback(() => {
if (!props.onToggleFrozen) return;
props.onToggleFrozen(columnName);
showPopup(popupRef.current, false);
}, [props, columnName]);
const handleChangeFilterSelectbox = useCallback((event: any) => {
setCurrFilterMethod(event.target.value);
}, []);
const handleChangeFilterSearchWord = useCallback((event: any) => {
setCurrFilterSearchWord(event.target.value);
}, []);
const handleConfirmFilter = useCallback(() => {
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeFilter) return;
props.onChangeFilter({
name: columnName,
search: currFilterSearchWord,
method: currFilterMethod,
multiple: found.filterMultiple
});
showPopup(popupRef.current, false);
}, [props, columns, columnName, currFilterSearchWord, currFilterMethod]);
const handleClearFilter = useCallback(() => {
const found = columns.find((e: SpecColumn) => e.name === columnName);
if (!found || !props.onChangeFilter) return;
props.onChangeFilter({
name: columnName,
search: currFilterSearchWord,
method: "clear",
multiple: found.filterMultiple
});
setCurrFilterSearchWord("");
showPopup(popupRef.current, false);
}, [props, columns, columnName, currFilterSearchWord]);
const handleFilterInputKeyDown = useCallback(
(event: any) => {
if (event.key === "Enter") {
event.preventDefault();
handleConfirmFilter();
}
},
[handleConfirmFilter]
);
if (!props.usePopup) return <div />;
return (
<div className={"sg-grid-header-control"} id="filterPanel" ref={popupRef}>
{/* {sortable && (
<section className="sg-section">
<h4 className="sg-h4">{t.sort}</h4>
<ul className={"sg-sort-list"}>
<li>
<button
className={"sg-sort-button" + (currSortDirection === "asc" ? " sg-sort-selected" : "")}
onClick={cbClickSortAsc}
>
<span className={"sg-icon"}>▲</span>
{t.sortAsc}
</button>
</li>
<li>
<button
className={"sg-sort-button" + (currSortDirection === "desc" ? " sg-sort-selected" : "")}
onClick={cbClickSortDesc}
>
<span className={"sg-icon"}>▼</span>
{t.sortDesc}
</button>
</li>
<li>
<button
className={
"sg-sort-button" +
(currSortDirection === "" || currSortDirection === "clear"
? " sg-sort-selected"
: "")
}
onClick={cbClickSortReset}
>
<span className={"sg-icon"}>⇅</span>
{t.sortReset}
</button>
</li>
<li>
<button
className={"sg-sort-button" + (isLastVisibleColumn ? " sg-sort-disabled" : "")}
onClick={cbClickHideColumn}
disabled={isLastVisibleColumn}
title={isLastVisibleColumn ? t.hideColumnDisabledTooltip : ""}
>
<span className={"sg-icon"}>⊘</span>
{t.hideColumn}
</button>
</li>
<li>
<button
className={"sg-sort-button" + (isFrozen ? " sg-sort-selected" : "")}
onClick={cbClickToggleFrozen}
>
<span className={"sg-icon"}>📌</span>
{isFrozen ? t.unfreeze : t.freeze}
</button>
</li>
</ul>
</section>
)} */}
{filterable && (
<section className="sg-section">
<h4 className="sg-h4">{t.filter}</h4>
<div className={"sg-filter-condition"}>
<select
className={"sg-filter-method"}
value={currFilterMethod}
onChange={handleChangeFilterSelectbox}
>
{options.map(e => (
<option key={e.value} value={e.value}>
{e.label}
</option>
))}
</select>
<input
type="text"
className={"sg-filter-input"}
placeholder={t.searchPlaceholder}
value={currFilterSearchWord}
onChange={handleChangeFilterSearchWord}
onKeyDown={handleFilterInputKeyDown}
/>
</div>
{/* 필터 표시 토글 */}
<div className="sg-filter-toggle">
<span className="sg-filter-toggle-label">{t.showFilterIcon}</span>
<label className="sg-switch">
<input
type="checkbox"
checked={props.showFilterIcons ?? true}
onChange={e => props.onToggleFilterIcons?.(e.target.checked)}
/>
<span className="sg-slider"></span>
</label>
</div>
</section>
)}
{filterable && (
<section className={"sg-button-section"}>
<div className={"sg-button-row"}>
<button className={"sg-btn-clear"} onClick={handleClearFilter}>
{t.btnClear}
</button>
<button className={"sg-btn-filter"} onClick={handleConfirmFilter}>
{t.btnFilter}
</button>
</div>
</section>
)}
</div>
);
}