svelte-common-hooks
Version:
Common hooks for Svelte
650 lines (649 loc) • 19.5 kB
JavaScript
/**
* 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;
}
}