listing-facebook/client/app/features/state-url-converter.ts

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;
}