568 lines
15 KiB
TypeScript
568 lines
15 KiB
TypeScript
import type { DeepPartial } from "react-hook-form";
|
|
import type { TableState } from "~/components/core/data-table";
|
|
|
|
/**
|
|
* Chuyển đổi TableState thành URL query parameters
|
|
* @param state - TableState object từ onStateChange callback
|
|
* @returns URLSearchParams object
|
|
*/
|
|
export function stateToURLQuery<T>(state: TableState<T>): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
|
|
// 📄 Pagination
|
|
if (state.pagination.currentPage > 1) {
|
|
params.set("page", state.pagination.currentPage.toString());
|
|
}
|
|
if (state.pagination.pageSize !== 10) {
|
|
// Default page size
|
|
params.set("size", state.pagination.pageSize.toString());
|
|
}
|
|
|
|
// 🔍 Search
|
|
if (state.search && state.search.trim()) {
|
|
params.set("search", state.search.trim());
|
|
}
|
|
|
|
// 🔄 Sort
|
|
if (state.sort.key) {
|
|
params.set("sort", String(state.sort.key));
|
|
if (state.sort.direction !== "asc") {
|
|
// Default direction
|
|
params.set("dir", state.sort.direction);
|
|
}
|
|
}
|
|
|
|
// 🎯 Select Filters
|
|
Object.entries(state.filters.select).forEach(([key, values]) => {
|
|
if (values && values.length > 0) {
|
|
params.set(`filter_${key}`, values.join(","));
|
|
}
|
|
});
|
|
|
|
// 📝 Text Filters
|
|
Object.entries(state.filters.text).forEach(([key, value]) => {
|
|
if (value && value.trim()) {
|
|
params.set(`text_${key}`, value.trim());
|
|
}
|
|
});
|
|
|
|
// 📅 Date Filters
|
|
Object.entries(state.filters.date).forEach(([key, value]) => {
|
|
if (value) {
|
|
params.set(`date_${key}`, value.toISOString().split("T")[0]); // YYYY-MM-DD format
|
|
}
|
|
});
|
|
|
|
// 📅 Date Range Filters
|
|
Object.entries(state.filters.dateRange).forEach(([key, range]) => {
|
|
if (range.from || range.to) {
|
|
const rangeValue = [
|
|
range.from ? range.from.toISOString().split("T")[0] : "",
|
|
range.to ? range.to.toISOString().split("T")[0] : "",
|
|
].join("|");
|
|
params.set(`daterange_${key}`, rangeValue);
|
|
}
|
|
});
|
|
|
|
// 🔢 Number Filters
|
|
Object.entries(state.filters.number).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
params.set(`num_${key}`, value.toString());
|
|
}
|
|
});
|
|
|
|
// 🔢 Number Range Filters
|
|
Object.entries(state.filters.numberRange).forEach(([key, range]) => {
|
|
if (range.min !== undefined || range.max !== undefined) {
|
|
const rangeValue = [
|
|
range.min !== undefined ? range.min.toString() : "",
|
|
range.max !== undefined ? range.max.toString() : "",
|
|
].join("|");
|
|
params.set(`numrange_${key}`, rangeValue);
|
|
}
|
|
});
|
|
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Chuyển đổi URL query parameters thành partial state object
|
|
* @param searchParams - URLSearchParams hoặc string query
|
|
* @returns Partial state object có thể dùng làm initialState
|
|
*/
|
|
export function urlQueryToState<T>(
|
|
searchParams: URLSearchParams | string
|
|
): Partial<TableState<T>> {
|
|
const params =
|
|
typeof searchParams === "string"
|
|
? new URLSearchParams(searchParams)
|
|
: searchParams;
|
|
|
|
const state: Partial<TableState<T>> = {
|
|
pagination: {
|
|
currentPage: 1,
|
|
pageSize: 10,
|
|
totalPages: 0,
|
|
totalItems: 0,
|
|
startIndex: 0,
|
|
endIndex: 0,
|
|
},
|
|
search: "",
|
|
sort: { key: null, direction: "asc" },
|
|
filters: {
|
|
select: {},
|
|
text: {},
|
|
date: {},
|
|
dateRange: {},
|
|
number: {},
|
|
numberRange: {},
|
|
},
|
|
};
|
|
|
|
// 📄 Pagination
|
|
const page = params.get("page");
|
|
const size = params.get("size");
|
|
|
|
if (page) {
|
|
const pageNum = Number.parseInt(page, 10);
|
|
if (!isNaN(pageNum) && pageNum > 0) {
|
|
state.pagination!.currentPage = pageNum;
|
|
}
|
|
}
|
|
|
|
if (size) {
|
|
const sizeNum = Number.parseInt(size, 10);
|
|
if (!isNaN(sizeNum) && sizeNum > 0) {
|
|
state.pagination!.pageSize = sizeNum;
|
|
}
|
|
}
|
|
|
|
// 🔍 Search
|
|
const search = params.get("search");
|
|
if (search) {
|
|
state.search = decodeURIComponent(search);
|
|
}
|
|
|
|
// 🔄 Sort
|
|
const sort = params.get("sort");
|
|
const dir = params.get("dir");
|
|
|
|
if (sort) {
|
|
state.sort!.key = sort as keyof T;
|
|
state.sort!.direction = dir === "desc" ? "desc" : "asc";
|
|
}
|
|
|
|
// 🎯 Parse all filter parameters
|
|
for (const [key, value] of params.entries()) {
|
|
if (!value) continue;
|
|
|
|
try {
|
|
// Select Filters (filter_*)
|
|
if (key.startsWith("filter_")) {
|
|
const filterKey = key.replace("filter_", "");
|
|
state.filters!.select![filterKey] = value
|
|
.split(",")
|
|
.filter((v) => v.trim());
|
|
}
|
|
|
|
// Text Filters (text_*)
|
|
else if (key.startsWith("text_")) {
|
|
const filterKey = key.replace("text_", "");
|
|
state.filters!.text![filterKey] = decodeURIComponent(value);
|
|
}
|
|
|
|
// Date Filters (date_*)
|
|
else if (key.startsWith("date_")) {
|
|
const filterKey = key.replace("date_", "");
|
|
const date = new Date(value);
|
|
if (!isNaN(date.getTime())) {
|
|
state.filters!.date![filterKey] = date;
|
|
}
|
|
}
|
|
|
|
// Date Range Filters (daterange_*)
|
|
else if (key.startsWith("daterange_")) {
|
|
const filterKey = key.replace("daterange_", "");
|
|
const [fromStr, toStr] = value.split("|");
|
|
const range: { from?: Date; to?: Date } = {};
|
|
|
|
if (fromStr) {
|
|
const fromDate = new Date(fromStr);
|
|
if (!isNaN(fromDate.getTime())) {
|
|
range.from = fromDate;
|
|
}
|
|
}
|
|
|
|
if (toStr) {
|
|
const toDate = new Date(toStr);
|
|
if (!isNaN(toDate.getTime())) {
|
|
range.to = toDate;
|
|
}
|
|
}
|
|
|
|
if (range.from || range.to) {
|
|
state.filters!.dateRange![filterKey] = range;
|
|
}
|
|
}
|
|
|
|
// Number Filters (num_*)
|
|
else if (key.startsWith("num_")) {
|
|
const filterKey = key.replace("num_", "");
|
|
const num = Number.parseFloat(value);
|
|
if (!isNaN(num)) {
|
|
state.filters!.number![filterKey] = num;
|
|
}
|
|
}
|
|
|
|
// Number Range Filters (numrange_*)
|
|
else if (key.startsWith("numrange_")) {
|
|
const filterKey = key.replace("numrange_", "");
|
|
const [minStr, maxStr] = value.split("|");
|
|
const range: { min?: number; max?: number } = {};
|
|
|
|
if (minStr) {
|
|
const min = Number.parseFloat(minStr);
|
|
if (!isNaN(min)) {
|
|
range.min = min;
|
|
}
|
|
}
|
|
|
|
if (maxStr) {
|
|
const max = Number.parseFloat(maxStr);
|
|
if (!isNaN(max)) {
|
|
range.max = max;
|
|
}
|
|
}
|
|
|
|
if (range.min !== undefined || range.max !== undefined) {
|
|
state.filters!.numberRange![filterKey] = range;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to parse URL parameter ${key}:`, error);
|
|
}
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Helper function để tạo shareable URL với current state
|
|
* @param baseUrl - Base URL (thường là window.location.origin + pathname)
|
|
* @param state - TableState object
|
|
* @returns Complete URL string
|
|
*/
|
|
export function createShareableURL<T>(
|
|
baseUrl: string,
|
|
state: TableState<T>
|
|
): string {
|
|
const params = stateToURLQuery(state);
|
|
const queryString = params.toString();
|
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
|
}
|
|
|
|
/**
|
|
* Helper function để update browser URL mà không reload page
|
|
* @param state - TableState object
|
|
* @param replaceState - Dùng replaceState thay vì pushState (default: true)
|
|
*/
|
|
export function updateBrowserURL<T>(
|
|
state: TableState<T>,
|
|
replaceState = true
|
|
): void {
|
|
const params = stateToURLQuery(state);
|
|
const queryString = params.toString();
|
|
const newUrl = queryString
|
|
? `${window.location.pathname}?${queryString}`
|
|
: window.location.pathname;
|
|
|
|
if (replaceState) {
|
|
window.history.replaceState({}, "", newUrl);
|
|
} else {
|
|
window.history.pushState({}, "", newUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper function để get current URL params
|
|
* @returns URLSearchParams từ current URL
|
|
*/
|
|
export function getCurrentURLParams(): URLSearchParams {
|
|
return new URLSearchParams(window.location.search);
|
|
}
|
|
|
|
/**
|
|
* Helper function để compress state thành base64 string (cho URL ngắn hơn)
|
|
* @param state - TableState object
|
|
* @returns Base64 encoded string
|
|
*/
|
|
export function compressStateToBase64<T>(state: TableState<T>): string {
|
|
const minimalState = {
|
|
p:
|
|
state.pagination.currentPage > 1
|
|
? state.pagination.currentPage
|
|
: undefined,
|
|
s: state.pagination.pageSize !== 10 ? state.pagination.pageSize : undefined,
|
|
q: state.search || undefined,
|
|
sort: state.sort.key
|
|
? {
|
|
k: state.sort.key,
|
|
d: state.sort.direction !== "asc" ? state.sort.direction : undefined,
|
|
}
|
|
: undefined,
|
|
f: {
|
|
sel:
|
|
Object.keys(state.filters.select).length > 0
|
|
? state.filters.select
|
|
: undefined,
|
|
txt:
|
|
Object.keys(state.filters.text).length > 0
|
|
? state.filters.text
|
|
: undefined,
|
|
dt:
|
|
Object.keys(state.filters.date).length > 0
|
|
? Object.fromEntries(
|
|
Object.entries(state.filters.date).map(([k, v]) => [
|
|
k,
|
|
v?.toISOString(),
|
|
])
|
|
)
|
|
: undefined,
|
|
dtr:
|
|
Object.keys(state.filters.dateRange).length > 0
|
|
? Object.fromEntries(
|
|
Object.entries(state.filters.dateRange).map(([k, v]) => [
|
|
k,
|
|
{
|
|
f: v.from?.toISOString(),
|
|
t: v.to?.toISOString(),
|
|
},
|
|
])
|
|
)
|
|
: undefined,
|
|
num:
|
|
Object.keys(state.filters.number).length > 0
|
|
? state.filters.number
|
|
: undefined,
|
|
numr:
|
|
Object.keys(state.filters.numberRange).length > 0
|
|
? state.filters.numberRange
|
|
: undefined,
|
|
},
|
|
};
|
|
|
|
// Remove undefined values
|
|
const cleanState = JSON.parse(JSON.stringify(minimalState));
|
|
return btoa(JSON.stringify(cleanState));
|
|
}
|
|
|
|
/**
|
|
* Helper function để decompress base64 string thành state
|
|
* @param base64String - Base64 encoded state
|
|
* @returns Partial state object
|
|
*/
|
|
export function decompressStateFromBase64<T>(
|
|
base64String: string
|
|
): Partial<TableState<T>> {
|
|
try {
|
|
const minimalState = JSON.parse(atob(base64String));
|
|
|
|
const state: Partial<TableState<T>> = {
|
|
pagination: {
|
|
currentPage: minimalState.p || 1,
|
|
pageSize: minimalState.s || 10,
|
|
totalPages: 0,
|
|
totalItems: 0,
|
|
startIndex: 0,
|
|
endIndex: 0,
|
|
},
|
|
search: minimalState.q || "",
|
|
sort: {
|
|
key: minimalState.sort?.k || null,
|
|
direction: minimalState.sort?.d || "asc",
|
|
},
|
|
filters: {
|
|
select: minimalState.f?.sel || {},
|
|
text: minimalState.f?.txt || {},
|
|
date: minimalState.f?.dt
|
|
? Object.fromEntries(
|
|
Object.entries(minimalState.f.dt).map(([k, v]) => [
|
|
k,
|
|
new Date(v as string),
|
|
])
|
|
)
|
|
: {},
|
|
dateRange: minimalState.f?.dtr
|
|
? Object.fromEntries(
|
|
Object.entries(minimalState.f.dtr).map(
|
|
([k, v]: [string, any]) => [
|
|
k,
|
|
{
|
|
from: v.f ? new Date(v.f) : undefined,
|
|
to: v.t ? new Date(v.t) : undefined,
|
|
},
|
|
]
|
|
)
|
|
)
|
|
: {},
|
|
number: minimalState.f?.num || {},
|
|
numberRange: minimalState.f?.numr || {},
|
|
},
|
|
};
|
|
|
|
return state;
|
|
} catch (error) {
|
|
console.error("Failed to decompress state from base64:", error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
type PartialTableState<T> = {
|
|
pagination?: {
|
|
currentPage?: number;
|
|
pageSize?: number;
|
|
};
|
|
search?: string;
|
|
sort?: {
|
|
key?: keyof T | string | null;
|
|
direction?: "asc" | "desc";
|
|
};
|
|
filters?: {
|
|
select?: Record<string, string[]>;
|
|
text?: Record<string, string>;
|
|
date?: Record<string, Date>;
|
|
dateRange?: Record<string, { from?: Date; to?: Date }>;
|
|
number?: Record<string, number>;
|
|
numberRange?: Record<string, { min?: number; max?: number }>;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Cập nhật từng phần của URLSearchParams
|
|
*/
|
|
export function updateURLQueryPartially<T>(
|
|
currentParams: URLSearchParams,
|
|
partial: DeepPartial<TableState<T>>
|
|
): URLSearchParams {
|
|
const params = new URLSearchParams(currentParams.toString()); // Clone
|
|
|
|
// 📄 Pagination
|
|
if (partial.pagination) {
|
|
if (partial.pagination.currentPage !== undefined) {
|
|
if (partial.pagination.currentPage > 1) {
|
|
params.set("page", partial.pagination.currentPage.toString());
|
|
} else {
|
|
params.delete("page");
|
|
}
|
|
}
|
|
|
|
if (partial.pagination.pageSize !== undefined) {
|
|
if (partial.pagination.pageSize !== 10) {
|
|
params.set("size", partial.pagination.pageSize.toString());
|
|
} else {
|
|
params.delete("size");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 🔍 Search
|
|
if (partial.search !== undefined) {
|
|
const val = partial.search.trim();
|
|
if (val) {
|
|
params.set("search", val);
|
|
} else {
|
|
params.delete("search");
|
|
}
|
|
}
|
|
|
|
// 🔄 Sort
|
|
if (partial.sort) {
|
|
if (partial.sort.key) {
|
|
params.set("sort", String(partial.sort.key));
|
|
if (partial.sort.direction && partial.sort.direction !== "asc") {
|
|
params.set("dir", partial.sort.direction);
|
|
} else {
|
|
params.delete("dir");
|
|
}
|
|
} else {
|
|
params.delete("sort");
|
|
params.delete("dir");
|
|
}
|
|
}
|
|
|
|
// 🎯 Filters
|
|
const filters = partial.filters;
|
|
|
|
// // Select
|
|
// if (filters?.select) {
|
|
// Object.entries(filters.select).forEach(([key, values]) => {
|
|
// if (values.length > 0) {
|
|
// params.set(`filter_${key}`, values.join(","));
|
|
// } else {
|
|
// params.delete(`filter_${key}`);
|
|
// }
|
|
// });
|
|
// }
|
|
|
|
// Text
|
|
if (filters?.text) {
|
|
Object.entries(filters.text).forEach(([key, value]) => {
|
|
if (value && value.trim()) {
|
|
params.set(`text_${key}`, value.trim());
|
|
} else {
|
|
params.delete(`text_${key}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Date
|
|
if (filters?.date) {
|
|
Object.entries(filters.date).forEach(([key, value]) => {
|
|
if (value) {
|
|
params.set(`date_${key}`, value.toISOString().split("T")[0]);
|
|
} else {
|
|
params.delete(`date_${key}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// DateRange
|
|
if (filters?.dateRange) {
|
|
Object.entries(filters.dateRange).forEach(([key, range]) => {
|
|
const from = range?.from ? range.from.toISOString().split("T")[0] : "";
|
|
const to = range?.to ? range.to.toISOString().split("T")[0] : "";
|
|
if (from || to) {
|
|
params.set(`daterange_${key}`, `${from}|${to}`);
|
|
} else {
|
|
params.delete(`daterange_${key}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Number
|
|
if (filters?.number) {
|
|
Object.entries(filters.number).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
params.set(`num_${key}`, value.toString());
|
|
} else {
|
|
params.delete(`num_${key}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// NumberRange
|
|
if (filters?.numberRange) {
|
|
Object.entries(filters.numberRange).forEach(([key, range]) => {
|
|
const min = range?.min !== undefined ? range.min.toString() : "";
|
|
const max = range?.max !== undefined ? range.max.toString() : "";
|
|
if (min || max) {
|
|
params.set(`numrange_${key}`, `${min}|${max}`);
|
|
} else {
|
|
params.delete(`numrange_${key}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
return params;
|
|
}
|