UNPKG

svelte-common-hooks

Version:
650 lines (649 loc) 19.5 kB
/** * Creates a DataTable instance. * * @example * the code below shows the example of client `Mode` * ```svelte * <script lang="ts"> * import { createDataTable } from 'svelte-common-hooks'; * let { data } = $props(); * const dataTable = createDataTable({ * // depending of the mode, the config might be different, see `Config` * mode: 'client', * initial: data.users, * searchWith(item, query) { * return item.name.toLowerCase().includes(query.toLowerCase()); * }, * filters: { * isAdult: (item, args: boolean) => item.age >= 18 === args, * age: (item, args: number) => item.age === args * }, * sorts: { * age: (a, b, dir) => (dir === 'asc' ? a.age - b.age : b.age - a.age), * name: (a, b, dir) => * dir === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) * } * }); * </script> * ``` */ export function createDataTable(config) { return new DataTable(config); } export class DataTable { /** * we do this to avoid reprocessing the initial data * it should be done once in the server or when the component is initialized * not in hydration process */ hydrated = false; /** * We keep track of the initial as a state */ initial = $state([]); // private initial = $state<Result[]>([]); /** * Keep the config as a class property for use later */ config; /** * Only available on server mode * This is where the data is fetched from the server */ queryFn; /** * The processed data */ #data = $derived.by(() => { const processed = $state(this.process(this.initial)); return { get length() { return processed.totalItems; }, set length(newValue) { processed.totalItems = newValue; }, get value() { return processed.result; }, set value(newValue) { processed.result = newValue; } }; }); /** * The filter that is currently applied, we keep track of the filter to apply it to the data */ #appliableFilter = $state({}); /** * The filter that is currently pending to be applied */ #pendingFilter = $state({}); /** * The sorting that is currently applied, we keep track of the sorting to apply it to the data */ #appliableSort = $state({ current: undefined, dir: undefined }); /** * The number of items per page */ #perPage = $state(10); /** * The current page */ #currentPage = $state(1); /** * The total number of items */ #totalItems = $derived(this.#data.length); /** * The total number of pages */ #totalPage = $derived(Math.ceil(this.#totalItems / this.#perPage)); /** * The list of pages */ #pageList = $derived(Array.from({ length: this.#totalPage }, (_, i) => i + 1)); /** * Whether we can go to the next page */ #canGoNext = $derived(this.#currentPage < this.#totalPage && this.#totalItems > 0); /** * Whether we can go to the previous page */ #canGoPrevious = $derived(this.#currentPage > 1 && this.#totalItems > 0); /** * The number of items showing from */ #showingFrom = $derived((this.#currentPage - 1) * this.#perPage + 1); /** * The number of items showing to */ #showingTo = $derived(Math.min(this.#currentPage * this.#perPage, this.#totalItems)); /** * The search query */ #search = $state(''); /** * Processing State, only available on `server` and `manual` mode */ #processing = $state(false); constructor(config) { if (config.mode === 'server' && 'queryFn' in config && config.queryFn) { this.queryFn = config.queryFn; } else if ((config.mode === 'manual' || config.mode === 'client') && 'initial' in config && config.initial) { this.initial = config.initial; } this.config = { ...config }; // free memory if ('initial' in this.config) { this.config.initial = []; } this.#perPage = config.perPage ?? 10; // apply initial per page this.#totalItems = config.mode === 'client' ? this.initial.length : 'totalItems' in config && config.totalItems ? config.totalItems : this.initial.length; // apply initial totalItems this.#search = config.search ?? ''; // apply initial search this.#currentPage = config.page ?? 1; // apply initial page if (config.mode === 'manual' || config.mode === 'server') { // apply initial filter const currentFilter = Object.entries(config.filters ?? {}); currentFilter?.forEach(([k, v]) => { if (typeof config.filters?.[k] === 'function') return; if (v === undefined || v === null) return; const snap = $state.snapshot(this.#pendingFilter); if (v !== undefined || v !== null) { this.#pendingFilter = { ...snap, [k]: v }; } }); this.#appliableFilter = this.#pendingFilter; // apply initial sorts const currentSort = Object.entries(config.sorts ?? {}); currentSort?.forEach(([key, value]) => { if (typeof config.sorts?.[key] === 'function') return; if (this.#appliableSort.current && this.#appliableSort.dir) return; if (key && value) { this.#appliableSort = { current: key, dir: value }; } }); } // reset the current page when search is not empty on client mode $effect(() => { if (this.mode === 'client' && this.#search) this.#currentPage = 1; }); /** * As soon as the component is mounted, we fetch the data from the server if the mode is server */ $effect(() => { if (config.mode === 'server' && this.queryFn) { this.queryFn()?.then?.((result) => { if (result && 'result' in result && 'totalItems' in result) { this.updateDataAndTotalItems(result.result, result.totalItems); } }); } }); } getFilterValue = (key, args) => { return args.by === 'pending' ? (this.#pendingFilter[key] ?? args.defaultValue) : args.by === 'applied' ? (this.#appliableFilter[key] ?? args.defaultValue) : (this.#pendingFilter[key] ?? this.#appliableFilter[key] ?? args.defaultValue); }; /** * Whether any interaction happened */ hasInteracted = $derived.by(() => { if (this.#search) return true; if (this.#appliableSort.current) return true; if (Object.keys(this.#appliableFilter ?? {}).length) return true; if (this.#currentPage !== 1) return true; if (this.perPage !== (this.config.perPage ?? 10)) return true; return false; }); /** * Update the data and total items, should only be used in manual `Mode` * @param data the new data * @param totalItems the new total items */ updateDataAndTotalItems = (data, totalItems) => { this.#data.value = data; this.#totalItems = totalItems; }; timeout = null; /** * Set the search query, and delay the process update */ setSearch = (value, delay = 0) => { if (this.timeout) clearTimeout(this.timeout); this.timeout = setTimeout(() => { this.#search = value; this.#currentPage = 1; this.processUpdate(); }, delay); }; /** * Client side data processing */ process(value) { if (this.mode !== 'client') return { result: value, totalItems: value.length }; const result = value?.length ? value .filter((v) => ('searchWith' in this.config && this.config.searchWith ? this.config.searchWith(v, this.#search) : true) && Object.entries(this.#appliableFilter).every(([filterKey, args]) => typeof this.config.filters?.[filterKey] === 'function' ? this.config.filters?.[filterKey]?.(v, args) : true)) .sort((a, b) => { if (!this.config.sorts || !this.#appliableSort.current || this.mode !== 'client') return 0; const sortFn = this.config?.sorts?.[this.#appliableSort.current]; if (typeof sortFn === 'function') return sortFn(a, b, this.#appliableSort.dir); return 0; }) : []; return { result: result.length > this.#perPage ? result.slice((this.#currentPage - 1) * this.#perPage, this.#currentPage * this.#perPage) : result, totalItems: result.length }; } /** * Manual mode processing */ processUpdate = () => { if (this.mode === 'manual' && 'processWith' in this.config && typeof this.config.processWith === 'function') { this.#processing = true; this.config.processWith?.()?.then(() => (this.#processing = false)); } }; /** * Go to the next page */ nextPage = () => { if (!this.#canGoNext) return; this.#currentPage = this.#currentPage + 1; this.processUpdate(); }; /** * Go to the previous page */ previousPage = () => { if (!this.#canGoPrevious) return; this.#currentPage = this.#currentPage - 1; this.processUpdate(); }; /** * Go to a specific page */ gotoPage = (page) => { if (page >= this.#totalPage || page <= 1 || page === this.#currentPage) return; this.#currentPage = page; this.processUpdate(); }; /** * Set the number of items per page */ setPerPage = (newPerPage) => { this.#perPage = newPerPage; this.#currentPage = 1; this.processUpdate(); }; /** * @deprecated use `effect` instead * hydrate state on page invalidation * only for client mode * @param getters the state getters * @returns this * @example * ```svelte * <script lang="ts"> * const dataTable = createDataTable({ * mode: 'client', * initial: [], * searchWith(item, query) { * return item.name.toLowerCase().includes(query.toLowerCase()); * }, * filters: { * isAdult: (item, args: boolean) => item.age >= 18 === args, * age: (item, args: number) => item.age === args * }, * sorts: { * age: (a, b, dir) => (dir === 'asc' ? a.age - b.age : b.age - a.age), * name: (a, b, dir) => * dir === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) * } * }).hydrate(() => data.users); * * </script> * ``` */ hydrate(getters) { if (this.mode !== 'client') return this; $effect(() => { // i am really sorry for this garbage const next = getters(); if (this.hydrated) { this.initial = next; } else { this.hydrated = true; } }); return this; } /** * @deprecated use `effect` instead * Sometimes you want to invalidate the data on a side effect. * and you have to wrap the creation inside of `$derived` block. * or you do the side effect inside of `$effect` block. * this method provide you just that to avoid that ugliness. * this method only available on `server` and `manual` mode * @param dependencies * @param invalidation * @returns */ invalidate(dependencies, invalidation) { if (this.mode !== 'manual' && this.mode !== 'server') return this; $effect(() => { // i am really sorry for this garbage dependencies(); if (this.hydrated) { invalidation(this); } else { this.hydrated = true; } }); return this; } /** * Sometimes you want to invalidate the data on a side effect. * and you have to wrap the creation inside of `$derived` block. * or you do the side effect inside of `$effect` block. * this method provide you just that to avoid that ugliness. * this method only available on `server` and `manual` mode * @param dependencies * @param invalidation * @returns */ effect(dependencies, invalidation) { $effect(() => { // i am really sorry for this garbage const deps = dependencies(); let cleanup; if (this.hydrated) { cleanup = invalidation(this, deps); } else { this.hydrated = true; } return () => { if (typeof cleanup === 'function') return cleanup(); }; }); return this; } /** * Filter the data by the given defined filter from `config` */ filterBy = (key, args, config) => { config = { immediate: false, resetPage: false, resetSearch: false, ...config }; if (typeof this.config.filters === 'undefined') return; this.#pendingFilter[key] = args; if (config.immediate === true) this.#appliableFilter[key] = args; if (config.resetPage === true) this.#currentPage = 1; if (config.resetSearch === true) this.#search = ''; if (config.immediate === true) this.processUpdate(); }; /** * Remove a filter from `pendingFilter` * set `immediate` to true if you want to remove the filter from `appliableFilter` as well */ removeFilter = (key, immediate = false) => { delete this.#pendingFilter[key]; if (immediate === true) { delete this.#appliableFilter[key]; this.processUpdate(); } }; /** * Clear all filters from `pendingFilter` and `appliableFilter` */ clearFilters = () => { this.#appliableFilter = {}; this.#pendingFilter = {}; this.processUpdate(); }; /** * Apply the pending filter to `appliableFilter` */ applyPendingFilter = () => { const snap = $state.snapshot(this.#pendingFilter); this.#appliableFilter = snap; this.processUpdate(); }; /** * Reset the data table, including `appliableFilter`, `appliableSort`, `currentPage`, `perPage`, `search` and re-process the data */ reset = () => { this.#appliableFilter = {}; this.#appliableSort = { current: undefined, dir: undefined }; this.#currentPage = 1; this.#perPage = this.config.perPage ?? 10; this.#search = ''; this.processUpdate(); }; /** * Sort the data by the given defined sort from `config` */ sortBy = (col, dir) => { const isTheSameColumn = this.#appliableSort.current === col; if (isTheSameColumn && dir === this.#appliableSort.dir) return; this.#appliableSort = { current: col, dir: dir ?? (isTheSameColumn ? (this.#appliableSort.dir === 'asc' ? 'desc' : 'asc') : 'asc') }; this.processUpdate(); }; /** * Remove the sort from `appliableSort` */ removeSort = () => { this.#appliableSort = { current: undefined, dir: undefined }; this.processUpdate(); }; /** * Get all of the state used for fetching data */ getConfig = () => { return { search: $state.snapshot(this.#search), page: $state.snapshot(this.#currentPage), limit: $state.snapshot(this.#perPage), filter: $state.snapshot(this.#appliableFilter), sort: $state.snapshot(this.#appliableSort) }; }; /** * @readonly data * get the processed data */ get data() { return this.#data.value; } /** * @readonly perPage * get the number of items per page */ get perPage() { return this.#perPage; } /** * @readonly currentPage * get the current page */ get currentPage() { return this.#currentPage; } /** * @readonly totalItems * get the total number of items */ get totalItems() { return this.#totalItems; } /** * @readonly totalPage * get the total number of pages */ get totalPage() { return this.#totalPage; } /** * @readonly pageList * get the list of pages */ get pageList() { return this.#pageList; } /** * @readonly canGoNext * whether we can go to the next page */ get canGoNext() { return this.#canGoNext; } /** * @readonly canGoPrevious * whether we can go to the previous page */ get canGoPrevious() { return this.#canGoPrevious; } /** * @readonly showingFrom * get the number of items showing from */ get showingFrom() { return this.#showingFrom; } /** * @readonly showingTo * get the number of items showing to */ get showingTo() { return this.#showingTo; } /** * @readonly appliableFilter * get all applied filter */ get appliableFilter() { return this.#appliableFilter; } /** * @readonly appliableSort * get all applied sort */ get appliableSort() { return this.#appliableSort; } /** * @readonly sortKeys * get all the keys from `config.sorts` */ get sortKeys() { return this.config.sorts ? Object.keys(this.config.sorts) : []; } /** * @readonly pendingFilter * get all of the `pendingFilter` */ get pendingFilter() { return this.#pendingFilter; } /** * search value */ get search() { return this.#search; } /** * set the search value */ set search(value) { this.#search = value; } /** * @readonly mode * get the mode of the DataTable */ get mode() { return this.config.mode; } /** * @readonly processing * get the processing state */ get processing() { return this.#processing; } }