feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: the hexagonal refactor churns the backend tree for nine more
phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper,
compose/web image) must move to its own repo first so doc/layout
rewrites land once on a backend-only tree.

Change:
- New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/
  (updateURL repointed to oikos-web releases), compose/, own CI (web +
  desktop jobs), own deploy script (CI-green gate, TOCTOU guard,
  version-tagged images, prune-to-3), own webhook receiver on :9798 +
  launchd unit, own compose project publishing the same 8091:80.
- Cutover executed on mac-mini in order: oikos stack's web service
  stopped+removed, oikos-web project brought up on 8091; outer Caddy
  untouched (targets the published port) — serving + Authentik flow +
  /wails 404 quirk verified post-cutover.
- Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI
  workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install
  targets, the compose web service, oikos-web from deploy.sh's fallback
  prune list; wails + go-keyring dropped from go.mod, vendor synced.
- README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now
  point at the new repo; mbse + mascot design docs carry a path note.

Risk: production SPA serving depends on the new pipeline now; rollback
is versioned-image re-up of the old web service from a pre-split
checkout (port 8091). Desktop builds installed before the split still
check dtoro/oikos releases — one manual reinstall, noted in the
oikos-web release notes.

Verification: go vet, make test (race), make generate-check, golangci
(no new findings; baseline down 400→365); post-cutover curls —
localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
This commit is contained in:
2026-08-15 22:27:52 +02:00
parent e074f04bdf
commit d4d99a7473
18854 changed files with 2615729 additions and 173735 deletions

1
web/node_modules/@vincjo/datatables/dist/index.d.ts generated vendored Normal file
View File

@@ -0,0 +1 @@
export { TableHandler, RecordFilter, Datatable, Search, RowsPerPage, Th, ThSort, ThFilter, Pagination, RowCount, check, type Field, type Check, type Internationalization, type Row, type TableParams, type SortParams, type TableHandlerInterface, type AdvancedFilterBuilder, type CalculationBuilder, type CSVBuilder, type FilterBuilder, type RecordFilterBuilder, type SearchBuilder, type SortBuilder, } from './src/client/index.js';

7
web/node_modules/@vincjo/datatables/dist/index.js generated vendored Normal file
View File

@@ -0,0 +1,7 @@
export {
// class:
TableHandler, RecordFilter,
// components:
Datatable, Search, RowsPerPage, Th, ThSort, ThFilter, Pagination, RowCount,
// utils:
check, } from './src/client/index.js';

View File

@@ -0,0 +1,12 @@
import DataHandler from './local/DataHandler';
import Datatable from './local/Datatable.svelte';
import Th from './local/Th.svelte';
import ThFilter from './local/ThFilter.svelte';
import Pagination from './local/Pagination.svelte';
import RowCount from './local/RowCount.svelte';
import RowsPerPage from './local/RowsPerPage.svelte';
import Search from './local/Search.svelte';
import { check } from './local/Comparator';
import type { Internationalization, Row, Field, Comparator, Filter, Sort, Selectable, Order, FilterBy, OrderBy } from './local';
export { DataHandler, check, Datatable, Th, ThFilter, Pagination, RowCount, RowsPerPage, Search };
export type { Internationalization, Row, Field, Comparator, Filter, Sort, Selectable, Order, OrderBy, FilterBy, };

View File

@@ -0,0 +1,11 @@
// Reexport your entry components here
import DataHandler from './local/DataHandler';
import Datatable from './local/Datatable.svelte';
import Th from './local/Th.svelte';
import ThFilter from './local/ThFilter.svelte';
import Pagination from './local/Pagination.svelte';
import RowCount from './local/RowCount.svelte';
import RowsPerPage from './local/RowsPerPage.svelte';
import Search from './local/Search.svelte';
import { check } from './local/Comparator';
export { DataHandler, check, Datatable, Th, ThFilter, Pagination, RowCount, RowsPerPage, Search };

View File

@@ -0,0 +1,25 @@
import type { Criterion } from './';
export declare const check: {
isLike: (entry: any, value: any) => boolean;
isNotLike: (entry: any, value: any) => boolean;
startsWith: (entry: any, value: any) => boolean;
endsWith: (entry: any, value: any) => boolean;
isEqualTo: (entry: any, value: any) => boolean;
isNotEqualTo: (entry: any, value: any) => boolean;
isGreaterThan: (entry: number, value: number) => boolean;
isGreaterThanOrEqualTo: (entry: number, value: number) => boolean;
isLessThan: (entry: number, value: number) => boolean;
isLessThanOrEqualTo: (entry: number, value: number) => boolean;
isBetween: (entry: number, value: [min: number, max: number]) => boolean;
isStrictlyBetween: (entry: number, value: [min: number, max: number]) => boolean;
isTrue: (entry: boolean, _: any) => boolean;
isFalse: (entry: boolean, _: any) => boolean;
isNull: (entry: null, _: any) => boolean;
isNotNull: (entry: any, _: any) => boolean;
whereIn: (entry: any, values?: Criterion[]) => boolean;
/**
* @deprecated use "isLike" instead
* @since 1.12.7 2023-09-27
*/
contains: (entry: any, value: any) => boolean;
};

View File

@@ -0,0 +1,94 @@
export const check = {
isLike: (entry, value) => {
return stringify(entry).indexOf(stringify(value)) > -1;
},
isNotLike: (entry, value) => {
return stringify(entry).indexOf(stringify(value)) === -1;
},
startsWith: (entry, value) => {
return stringify(entry).startsWith(stringify(value));
},
endsWith: (entry, value) => {
return stringify(entry).endsWith(stringify(value));
},
isEqualTo: (entry, value) => {
return stringify(entry) === stringify(value);
},
isNotEqualTo: (entry, value) => {
return stringify(entry) !== stringify(value);
},
isGreaterThan: (entry, value) => {
if (isNull(entry))
return false;
return entry > value;
},
isGreaterThanOrEqualTo: (entry, value) => {
if (isNull(entry))
return false;
return entry >= value;
},
isLessThan: (entry, value) => {
if (isNull(entry))
return false;
return entry < value;
},
isLessThanOrEqualTo: (entry, value) => {
if (isNull(entry))
return false;
return entry <= value;
},
isBetween: (entry, value) => {
if (isNull(entry))
return false;
const [min, max] = value;
return entry >= min && entry <= max;
},
isStrictlyBetween: (entry, value) => {
if (isNull(entry))
return false;
const [min, max] = value;
return entry > min && entry < max;
},
isTrue: (entry, _) => {
return entry === true;
},
isFalse: (entry, _) => {
return entry === false;
},
isNull: (entry, _) => {
return entry === null || entry === undefined;
},
isNotNull: (entry, _) => {
return entry === null || entry === undefined ? false : true;
},
whereIn: (entry, values = []) => {
if (isNull(entry))
return false;
if (values.length === 0)
return false;
for (const { value, comparator } of values) {
if (comparator(entry, value)) {
return true;
}
}
return false;
},
/**
* @deprecated use "isLike" instead
* @since 1.12.7 2023-09-27
*/
contains: (entry, value) => {
return check.isLike(entry, value);
},
};
/* utils */
function stringify(value = null) {
return String(value)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
function isNull(entry) {
if (entry === null || entry === undefined)
return true;
}

View File

@@ -0,0 +1,40 @@
import { type Writable, type Readable } from 'svelte/store';
import type { Filter, Sort } from './';
import type { Params } from './DataHandler';
import EventHandler from './handlers/EventHandler';
export default class Context<Row> {
event: EventHandler;
rowsPerPage: Writable<number | null>;
pageNumber: Writable<number>;
search: Writable<{
value?: string;
scope?: (keyof Row)[];
}>;
filters: Writable<Filter<Row>[]>;
filterCount: Readable<number>;
rawRows: Writable<Row[]>;
filteredRows: Readable<Row[]>;
pagedRows: Readable<Row[]>;
rowCount: Readable<{
total: number;
start: number;
end: number;
}>;
pages: Readable<number[]>;
pagesWithEllipsis: Readable<number[]>;
pageCount: Readable<number>;
sort: Writable<(Sort<Row>)>;
selected: Writable<(Row | Row[keyof Row])[]>;
selectScope: Writable<'all' | 'currentPage'>;
isAllSelected: Readable<boolean>;
constructor(data: Row[], params: Params);
private createFilterCount;
private createFilteredRows;
private match;
private createPagedRows;
private createRowCount;
private createPages;
private createPagesWithEllipsis;
private createPageCount;
private createIsAllSelected;
}

View File

@@ -0,0 +1,171 @@
import { writable, derived } from 'svelte/store';
import { isNull, parseField } from './utils';
import { check } from './Comparator';
import EventHandler from './handlers/EventHandler';
export default class Context {
event;
rowsPerPage;
pageNumber;
search;
filters;
filterCount;
rawRows;
filteredRows;
pagedRows;
rowCount;
pages;
pagesWithEllipsis;
pageCount;
sort;
selected;
selectScope;
isAllSelected;
constructor(data, params) {
this.event = new EventHandler();
this.rowsPerPage = writable(params.rowsPerPage);
this.pageNumber = writable(1);
this.search = writable({});
this.filters = writable([]);
this.filterCount = this.createFilterCount();
this.rawRows = writable(data);
this.filteredRows = this.createFilteredRows();
this.pagedRows = this.createPagedRows();
this.rowCount = this.createRowCount();
this.pages = this.createPages();
this.pagesWithEllipsis = this.createPagesWithEllipsis();
this.pageCount = this.createPageCount();
this.sort = writable({});
this.selected = writable([]);
this.selectScope = writable('all');
this.isAllSelected = this.createIsAllSelected();
}
createFilterCount() {
return derived(this.filters, ($filters) => $filters.length);
}
createFilteredRows() {
return derived([this.rawRows, this.search, this.filters], ([$rawRows, $search, $filters]) => {
if ($search.value) {
$rawRows = $rawRows.filter((row) => {
const fields = $search.scope ?? Object.keys(row);
const scope = fields.map((field) => {
const { callback } = parseField(field);
return callback;
});
return scope.some((callback) => {
return this.match(callback(row), $search.value);
});
});
this.pageNumber.set(1);
this.selected.set([]);
this.event.trigger('change');
}
if ($filters.length > 0) {
$filters.forEach((filter) => {
return ($rawRows = $rawRows.filter((row) => {
const entry = filter.callback(row);
return this.match(entry, filter.value, filter.comparator);
}));
});
this.pageNumber.set(1);
this.selected.set([]);
this.event.trigger('change');
}
return $rawRows;
});
}
match(entry, value, compare = null) {
if (isNull(value)) {
return true;
}
if (!entry && compare) {
return compare(entry, value);
}
if (!entry)
return check.isLike(entry, value);
else if (typeof entry === 'object') {
return Object.keys(entry).some((k) => {
return this.match(entry[k], value, compare);
});
}
if (!compare)
return check.isLike(entry, value);
return compare(entry, value);
}
createPagedRows() {
return derived([this.filteredRows, this.rowsPerPage, this.pageNumber], ([$filteredRows, $rowsPerPage, $pageNumber]) => {
if (!$rowsPerPage) {
return $filteredRows;
}
return $filteredRows.slice(($pageNumber - 1) * $rowsPerPage, $pageNumber * $rowsPerPage);
});
}
createRowCount() {
return derived([this.filteredRows, this.pageNumber, this.rowsPerPage], ([$filteredRows, $pageNumber, $rowsPerPage]) => {
const total = $filteredRows.length;
if (!$rowsPerPage) {
return { total: total, start: 1, end: total };
}
return {
total: total,
start: $pageNumber * $rowsPerPage - $rowsPerPage + 1,
end: Math.min($pageNumber * $rowsPerPage, $filteredRows.length)
};
});
}
createPages() {
return derived([this.rowsPerPage, this.filteredRows], ([$rowsPerPage, $filteredRows]) => {
if (!$rowsPerPage) {
return [1];
}
const pages = Array.from(Array(Math.ceil($filteredRows.length / $rowsPerPage)));
return pages.map((_, i) => i + 1);
});
}
createPagesWithEllipsis() {
return derived([this.pages, this.pageNumber], ([$pages, $pageNumber]) => {
if ($pages.length <= 7) {
return $pages;
}
const ellipse = null;
const firstPage = 1;
const lastPage = $pages.length;
if ($pageNumber <= 4) {
return [
...$pages.slice(0, 5),
ellipse,
lastPage
];
}
else if ($pageNumber < $pages.length - 3) {
return [
firstPage,
ellipse,
...$pages.slice($pageNumber - 2, $pageNumber + 1),
ellipse,
lastPage
];
}
else {
return [
firstPage,
ellipse,
...$pages.slice($pages.length - 5, $pages.length)
];
}
});
}
createPageCount() {
return derived(this.pages, ($pages) => {
return $pages.length;
});
}
createIsAllSelected() {
return derived([this.selected, this.pagedRows, this.filteredRows, this.selectScope], ([$selected, $pagedRows, $filteredRows, $selectScope]) => {
const rowCount = $selectScope === 'currentPage' ? $pagedRows.length : $filteredRows.length;
if (rowCount === $selected.length && rowCount !== 0) {
return true;
}
return false;
});
}
}

View File

@@ -0,0 +1,93 @@
import FilterHelper from './helpers/FilterHelper';
import AdvancedFilterHelper from './helpers/AdvancedFilterHelper';
import CalculationHelper from './helpers/CalculationHelper';
import type { Readable, Writable } from 'svelte/store';
import type { Internationalization, Row, Field, Comparator } from './';
export type Params = {
rowsPerPage?: number;
i18n?: Internationalization;
};
export default class DataHandler<T extends Row = any> {
private context;
private sortHandler;
private selectHandler;
private pageHandler;
private searchHandler;
private filterHandler;
i18n: Internationalization;
constructor(data?: T[], params?: Params);
setRows(data: T[]): void;
getRows(): Readable<T[]>;
getAllRows(): Readable<T[]>;
getRowCount(): Readable<{
total: number;
start: number;
end: number;
}>;
getRowsPerPage(): Writable<number | null>;
getPages(param?: {
ellipsis: boolean;
}): Readable<number[]>;
getPageCount(): Readable<number>;
getPageNumber(): Readable<number>;
setPage(value: number | 'previous' | 'next'): void;
search(value: string, scope?: Field<T>[]): void;
clearSearch(): void;
sort(orderBy: Field<T>, identifier?: string): void;
sortAsc(orderBy: Field<T>, identifier?: string): void;
sortDesc(orderBy: Field<T>, identifier?: string): void;
getSort(): Writable<{
identifier?: string;
direction?: 'asc' | 'desc';
}>;
applySort(params?: {
orderBy: Field<T>;
direction?: 'asc' | 'desc';
}): void;
defineSort(orderBy: Field<T>, direction?: 'asc' | 'desc'): void;
clearSort(): void;
filter(value: string | number | null | undefined | boolean | number[], filterBy: Field<T>, comparator?: Comparator<T>): void;
getFilters(): Readable<{
value: unknown;
filterBy: Field<T>;
check: string;
}[]>;
createFilter(filterBy: Field<T>, comparator?: Comparator<T>): FilterHelper<T>;
createAdvancedFilter(filterBy: Field<T>): AdvancedFilterHelper<T>;
getFilterCount(): Readable<number>;
clearFilters(): void;
select(value: T | T[keyof T]): void;
getSelected(): Writable<(T | T[keyof T])[]>;
selectAll(params?: {
selectBy?: keyof T;
scope?: 'all' | 'currentPage';
}): void;
isAllSelected(): Readable<boolean>;
on(event: 'change' | 'clearFilters' | 'clearSearch', callback: () => void): void;
createCalculation(field: Field<T>, param?: {
precision: number;
}): CalculationHelper<T>;
translate(i18n: Internationalization): Internationalization;
/**
* @deprecated use setRows() instead
* @since v0.9.99 2023-01-16
*/
update(data: any[]): void;
/**
* @deprecated use applySort() instead
* @since v1.11.0 2023-07-11
*/
applySorting(params?: {
orderBy: Field<T>;
direction?: 'asc' | 'desc';
}): void;
/**
* @deprecated use getSort() instead
* @since v1.11.0 2023-07-11
*/
getSorted(): Writable<{
identifier?: string;
direction?: "asc" | "desc";
}>;
getTriggerChange(): Writable<number>;
}

View File

@@ -0,0 +1,170 @@
import Context from './Context';
import SortHandler from './handlers/SortHandler';
import SelectHandler from './handlers/SelectHandler';
import PageHandler from './handlers/PageHandler';
import SearchHandler from './handlers/SearchHandler';
import FilterHandler from './handlers/FilterHandler';
import FilterHelper from './helpers/FilterHelper';
import AdvancedFilterHelper from './helpers/AdvancedFilterHelper';
import CalculationHelper from './helpers/CalculationHelper';
export default class DataHandler {
context;
sortHandler;
selectHandler;
pageHandler;
searchHandler;
filterHandler;
i18n;
constructor(data = [], params = { rowsPerPage: null }) {
this.i18n = this.translate(params.i18n);
this.context = new Context(data, params);
this.sortHandler = new SortHandler(this.context);
this.selectHandler = new SelectHandler(this.context);
this.pageHandler = new PageHandler(this.context);
this.searchHandler = new SearchHandler(this.context);
this.filterHandler = new FilterHandler(this.context);
}
setRows(data) {
this.context.rawRows.set(data);
this.context.event.trigger('change');
this.applySort();
}
getRows() {
return this.context.pagedRows;
}
getAllRows() {
return this.context.filteredRows;
}
getRowCount() {
return this.context.rowCount;
}
getRowsPerPage() {
return this.context.rowsPerPage;
}
getPages(param = { ellipsis: false }) {
if (param.ellipsis) {
return this.context.pagesWithEllipsis;
}
return this.context.pages;
}
getPageCount() {
return this.context.pageCount;
}
getPageNumber() {
return this.context.pageNumber;
}
setPage(value) {
switch (value) {
case 'previous': return this.pageHandler.previous();
case 'next': return this.pageHandler.next();
default: return this.pageHandler.goto(value);
}
}
search(value, scope = null) {
this.searchHandler.set(value, scope);
}
clearSearch() {
this.searchHandler.clear();
}
sort(orderBy, identifier) {
this.setPage(1);
this.sortHandler.set(orderBy, identifier);
}
sortAsc(orderBy, identifier) {
this.setPage(1);
this.sortHandler.asc(orderBy, identifier);
}
sortDesc(orderBy, identifier) {
this.setPage(1);
this.sortHandler.desc(orderBy, identifier);
}
getSort() {
return this.context.sort;
}
applySort(params = null) {
this.sortHandler.apply(params);
}
defineSort(orderBy, direction) {
this.sortHandler.define(orderBy, direction);
}
clearSort() {
this.sortHandler.clear();
}
filter(value, filterBy, comparator = null) {
this.filterHandler.set(value, filterBy, comparator);
}
getFilters() {
return this.filterHandler.get();
}
createFilter(filterBy, comparator) {
return new FilterHelper(this.filterHandler, filterBy, comparator);
}
createAdvancedFilter(filterBy) {
return new AdvancedFilterHelper(this.filterHandler, filterBy);
}
getFilterCount() {
return this.context.filterCount;
}
clearFilters() {
this.filterHandler.clear();
}
select(value) {
this.selectHandler.set(value);
}
getSelected() {
return this.context.selected;
}
selectAll(params = {}) {
this.context.selectScope.set(params.scope === 'currentPage' ? 'currentPage' : 'all');
this.selectHandler.all(params.selectBy ?? null);
}
isAllSelected() {
return this.context.isAllSelected;
}
on(event, callback) {
this.context.event.add(event, callback);
}
createCalculation(field, param = null) {
return new CalculationHelper(this.context, field, { precision: param?.precision ?? 2 });
}
translate(i18n) {
return {
...{
search: 'Search...',
show: 'Show',
entries: 'entries',
filter: 'Filter',
rowCount: 'Showing {start} to {end} of {total} entries',
noRows: 'No entries found',
previous: 'Previous',
next: 'Next'
},
...i18n
};
}
/**
* @deprecated use setRows() instead
* @since v0.9.99 2023-01-16
*/
update(data) {
console.log('%c%s', 'color:#e65100;background:#fff3e0;font-size:12px;border-radius:4px;padding:4px;text-align:center;', `DataHandler.update(data) method is deprecated. Please use DataHandler.setRows(data) instead`);
this.context.rawRows.set(data);
}
/**
* @deprecated use applySort() instead
* @since v1.11.0 2023-07-11
*/
applySorting(params = null) {
this.applySort(params);
}
/**
* @deprecated use getSort() instead
* @since v1.11.0 2023-07-11
*/
getSorted() {
return this.getSort();
}
getTriggerChange() {
return this.context.event.triggerChange;
}
}

View File

@@ -0,0 +1,93 @@
<script lang="ts">import { Search, RowsPerPage, RowCount, Pagination } from './';
export let handler;
export let search = true;
export let rowsPerPage = true;
export let rowCount = true;
export let pagination = true;
let element;
let clientWidth = 1000;
const height = (search || rowsPerPage ? 48 : 8) + (rowCount || pagination ? 48 : 8);
handler.on('change', () => {
if (element)
element.scrollTop = 0;
});
</script>
<section bind:clientWidth class={$$props.class ?? ''}>
<header class:container={search || rowsPerPage}>
{#if search}
<Search {handler} />
{/if}
{#if rowsPerPage}
<RowsPerPage {handler} small={clientWidth < 600} />
{/if}
</header>
<article bind:this={element} style="height:calc(100% - {height}px)">
<slot />
</article>
<footer class:container={rowCount || pagination}>
{#if rowCount}
<RowCount {handler} small={clientWidth < 600} />
{/if}
{#if pagination}
<Pagination {handler} small={clientWidth < 600} />
{/if}
</footer>
</section>
<style>
section {
height: 100%;
}
section :global(table) {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
section :global(thead) {
position: sticky;
inset-block-start: 0;
z-index: 1;
}
header,
footer {
min-height: 8px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
header.container,
footer.container {
height: 48px;
}
footer {
border-top: 1px solid #e0e0e0;
}
article {
position: relative;
/* height:calc(100% - 96px); */
overflow: auto;
scrollbar-width: thin;
}
article::-webkit-scrollbar {
width: 6px;
height: 6px;
}
article::-webkit-scrollbar-track {
background: #f5f5f5;
}
article::-webkit-scrollbar-thumb {
background: #c2c2c2;
}
article::-webkit-scrollbar-thumb:hover {
background: #9e9e9e;
}
</style>

View File

@@ -0,0 +1,35 @@
import { type DataHandler, type Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
search?: boolean;
rowsPerPage?: boolean;
rowCount?: boolean;
pagination?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Datatable: $$IsomorphicComponent;
type Datatable<T extends Row> = InstanceType<typeof Datatable<T>>;
export default Datatable;

View File

@@ -0,0 +1,110 @@
<script lang="ts">export let handler;
export let small = false;
const pageNumber = handler.getPageNumber();
const pageCount = handler.getPageCount();
const pages = handler.getPages({ ellipsis: true });
</script>
<section class={$$props.class ?? ''}>
{#if small}
<button type="button"
class="small"
class:disabled={$pageNumber === 1}
on:click={() => handler.setPage(1)}
>
&#10092;&#10092;
</button>
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => handler.setPage('previous')}
>
&#10094;
</button>
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => handler.setPage('next')}
>
&#10095;
</button>
<button type="button"
class="small"
class:disabled={$pageNumber === $pageCount}
on:click={() => handler.setPage($pageCount)}
>
&#10093;&#10093;
</button>
{:else}
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => handler.setPage('previous')}
>
{@html handler.i18n.previous}
</button>
{#each $pages as page}
<button type="button"
class:active={$pageNumber === page}
class:ellipse={page === null}
on:click={() => handler.setPage(page)}
>
{page ?? '...'}
</button>
{/each}
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => handler.setPage('next')}
>
{@html handler.i18n.next}
</button>
{/if}
</section>
<style>
section {
display: flex;
}
button {
background: inherit;
height: 32px;
width: 32px;
color: #616161;
cursor: pointer;
font-size: 13px;
margin: 0;
padding: 0;
transition: all, 0.2s;
line-height: 32px;
border: 1px solid #e0e0e0;
border-right: none;
border-radius: 0;
outline: none;
}
button:first-child {
border-radius: 4px 0 0 4px;
}
button:last-child {
border-right: 1px solid #e0e0e0;
border-radius: 0 4px 4px 0;
}
button:first-child:not(.small),
button:last-child:not(.small) {
min-width: 72px;
}
button:not(.active):hover {
background: #eee;
}
button.ellipse:hover {
background: inherit;
cursor: default;
}
button.active {
background: #eee;
font-weight: bold;
cursor: default;
}
button.disabled:hover {
background: inherit;
cursor: default;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Pagination: $$IsomorphicComponent;
type Pagination<T extends Row> = InstanceType<typeof Pagination<T>>;
export default Pagination;

View File

@@ -0,0 +1,31 @@
<script lang="ts">export let handler;
export let small = false;
const rowCount = handler.getRowCount();
</script>
<aside class={$$props.class ?? ''}>
{#if small}
{#if $rowCount.total > 0}
<b>{$rowCount.start}</b>-
<b>{$rowCount.end}</b>/
<b>{$rowCount.total}</b>
{:else}
{handler.i18n.noRows}
{/if}
{:else if $rowCount.total > 0}
{@html handler.i18n.rowCount
.replace('{start}', `<b>${$rowCount.start}</b>`)
.replace('{end}', `<b>${$rowCount.end}</b>`)
.replace('{total}', `<b>${$rowCount.total}</b>`)}
{:else}
{handler.i18n.noRows}
{/if}
</aside>
<style>
aside {
color: #616161;
line-height: 32px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowCount: $$IsomorphicComponent;
type RowCount<T extends Row> = InstanceType<typeof RowCount<T>>;
export default RowCount;

View File

@@ -0,0 +1,34 @@
<script lang="ts">export let handler;
export let small = false;
const rowsPerPage = handler.getRowsPerPage();
const options = [5, 10, 20, 50, 100];
</script>
<aside class={$$props.class ?? ''}>
{#if !small}
<span>{handler.i18n.show}</span>
{/if}
<select bind:value={$rowsPerPage} on:change={() => handler.setPage(1)}>
{#each options as option}
<option value={option}>
{option}
</option>
{/each}
</select>
{#if !small}
<span>{handler.i18n.entries}</span>
{/if}
</aside>
<style>
aside {
display: flex;
justify-content: flex-start;
align-items: center;
height: 32px;
color: #757575;
}
select {
margin: 0 4px;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowsPerPage: $$IsomorphicComponent;
type RowsPerPage<T extends Row> = InstanceType<typeof RowsPerPage<T>>;
export default RowsPerPage;

View File

@@ -0,0 +1,36 @@
<script lang="ts">export let handler;
let value = '';
handler.on('clearSearch', () => value = '');
</script>
<input
class={$$props.class ?? ''}
bind:value
placeholder={handler.i18n.search}
spellcheck="false"
on:input={() => handler.search(value)}
/>
<style>
input {
border: 1px solid #e0e0e0;
border-radius: 4px;
outline: none;
padding: 0 8px;
line-height: 24px;
margin: 0;
height: 24px;
background: transparent;
width: 40%;
max-width: 176px;
min-width: 96px;
transition: all, 0.1s;
}
input:focus {
border: 1px solid #bdbdbd;
}
input::placeholder {
color: #9e9e9e;
line-height: 24px;
}
</style>

View File

@@ -0,0 +1,25 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Search: $$IsomorphicComponent;
type Search<T extends Row> = InstanceType<typeof Search<T>>;
export default Search;

View File

@@ -0,0 +1,78 @@
<script lang="ts">export let handler;
export let orderBy;
export let identifier = orderBy?.toString();
export let align = 'left';
export let rowSpan = 1;
const sort = handler.getSort();
</script>
<th
on:click={() => handler.sort(orderBy, identifier)}
class:sortable={orderBy}
class:active={$sort.identifier === identifier}
class={$$props.class ?? ''}
rowspan={rowSpan}
>
<div
class="flex"
style:justify-content={align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center'}
>
<strong>
<slot />
</strong>
<span class:asc={$sort.direction === 'asc'} class:desc={$sort.direction === 'desc'}></span>
</div>
</th>
<style>
th {
background: inherit;
padding: 8px 20px;
white-space: nowrap;
font-size: 13px;
user-select: none;
border-bottom: 1px solid #e0e0e0;
}
th.sortable {
cursor: pointer;
}
th strong {
white-space: pre-wrap;
font-size: 13.5px;
line-height: 16px;
}
th.sortable div.flex {
padding: 0;
display: flex;
align-items: center;
height: 100%;
}
th.sortable span {
padding-left: 8px;
}
th.sortable span:before,
th.sortable span:after {
border: 4px solid transparent;
content: '';
display: block;
height: 0;
width: 0;
}
th.sortable span:before {
border-bottom-color: #e0e0e0;
margin-top: 2px;
}
th.sortable span:after {
border-top-color: #e0e0e0;
margin-top: 2px;
}
th.active.sortable span.asc:before {
border-bottom-color: #9e9e9e;
}
th.active.sortable span.desc:after {
border-top-color: #9e9e9e;
}
th:not(.sortable) span {
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,35 @@
import type { DataHandler, Field, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
orderBy: Field<T>;
identifier?: string;
align?: "left" | "right" | "center";
rowSpan?: number;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Th: $$IsomorphicComponent;
type Th<T extends Row> = InstanceType<typeof Th<T>>;
export default Th;

View File

@@ -0,0 +1,45 @@
<script lang="ts">export let handler;
export let filterBy;
export let align = 'left';
export let comparator = null;
let value = '';
handler.on('clearFilters', () => value = '');
</script>
<th class={$$props.class ?? ''}>
<input
style:text-align={align}
type="text"
placeholder={handler.i18n.filter}
spellcheck="false"
bind:value
on:input={() => handler.filter(value, filterBy, comparator)}
/>
</th>
<style>
th {
border-bottom: 1px solid #e0e0e0;
}
input {
width: 100%;
height: 24px;
border: none;
text-align: left;
padding: 0 20px;
background: inherit;
outline: none;
border-radius: 0;
font-size: 14px;
font-family: Arial, Helvetica, sans-serif;
}
input::placeholder {
color: #bdbdbd;
font-style: italic;
font-size: 13px;
}
input:focus {
outline: none;
border: none;
}
</style>

View File

@@ -0,0 +1,28 @@
import type { DataHandler, Field, Comparator, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
filterBy: Field<T>;
align?: "left" | "right" | "center";
comparator?: Comparator<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const ThFilter: $$IsomorphicComponent;
type ThFilter<T extends Row> = InstanceType<typeof ThFilter<T>>;
export default ThFilter;

View File

@@ -0,0 +1,6 @@
export default class EventHandler {
private events;
triggerChange: import("svelte/store").Writable<number>;
add(event: keyof EventHandler['events'], callback: () => void): void;
trigger(event: keyof EventHandler['events']): void;
}

View File

@@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
export default class EventHandler {
events = {
change: [],
clearFilters: [],
clearSearch: []
};
triggerChange = writable(0); // legacy
add(event, callback) {
this.events[event].push(callback);
}
trigger(event) {
for (const callback of this.events[event]) {
callback();
}
/* legacy: support for triggerChange store */
if (event === 'change') {
this.triggerChange.update((store) => { return store + 1; });
}
}
}

View File

@@ -0,0 +1,19 @@
import type { Filter, Field, Comparator, EventHandler, Criterion } from '..';
import type Context from '../Context';
import { type Writable, type Readable } from 'svelte/store';
type Value = string | number | null | undefined | boolean | number[] | Criterion[];
export default class FilterHandler<Row> {
protected filters: Writable<Filter<Row>[]>;
protected event: EventHandler;
private collection;
constructor(context: Context<Row>);
set(value: Value, filterBy: Field<Row>, comparator?: Comparator<Row>, name?: string): void;
clear(): void;
get(): Readable<{
value: unknown;
filterBy: Field<Row>;
check: string;
}[]>;
private createCollection;
}
export {};

View File

@@ -0,0 +1,59 @@
import { isNotNull } from '../utils';
import { derived } from 'svelte/store';
import { parseField } from '../utils';
// type Collection<Row> = {
// value: unknown
// filterBy: Field<Row>
// set: (value: unknown, comparator: Comparator<Row>) => void,
// clear: () => void
// }
export default class FilterHandler {
filters;
event;
collection;
constructor(context) {
this.filters = context.filters;
this.event = context.event;
}
set(value, filterBy, comparator = null, name) {
const { callback, identifier, key } = parseField(filterBy, name);
const filter = { value, identifier, callback, comparator, key };
this.filters.update((store) => {
store = store.filter((item) => item.identifier !== identifier);
if (isNotNull(value)) {
store.push(filter);
}
return store;
});
}
clear() {
this.filters.set([]);
this.event.trigger('change');
this.event.trigger('clearFilters');
}
get() {
if (this.collection) {
return this.collection;
}
this.collection = this.createCollection();
return this.collection;
}
createCollection() {
return derived(this.filters, ($filters) => {
return $filters.map(({ value, callback, key, comparator }) => {
const filterBy = key ?? callback;
return {
value,
filterBy,
check: comparator ? comparator.name : 'isLike'
// set: (value: Value, comparator: Comparator<Row> = check.isLike) => {
// this.set(value, filterBy, comparator)
// },
// clear: () => {
// this.set(undefined, filterBy)
// }
};
});
});
}
}

View File

@@ -0,0 +1,11 @@
import type Context from '../Context';
export default class PageHandler<Row> {
private pageNumber;
private rowCount;
private rowsPerPage;
private event;
constructor(context: Context<Row>);
goto(number: number): void;
previous(): void;
next(): void;
}

View File

@@ -0,0 +1,34 @@
import { get } from 'svelte/store';
export default class PageHandler {
pageNumber;
rowCount;
rowsPerPage;
event;
constructor(context) {
this.pageNumber = context.pageNumber;
this.rowCount = context.rowCount;
this.rowsPerPage = context.rowsPerPage;
this.event = context.event;
}
goto(number) {
this.pageNumber.update((store) => {
const rowsPerPage = get(this.rowsPerPage);
if (rowsPerPage) {
const total = get(this.rowCount).total;
if (number >= 1 && number <= Math.ceil(total / rowsPerPage)) {
store = number;
this.event.trigger('change');
}
}
return store;
});
}
previous() {
const number = get(this.pageNumber) - 1;
this.goto(number);
}
next() {
const number = get(this.pageNumber) + 1;
this.goto(number);
}
}

View File

@@ -0,0 +1,9 @@
import type Context from '../Context';
import type { Field } from '..';
export default class SearchHandler<Row> {
private search;
private event;
constructor(context: Context<Row>);
set(value: string, scope?: Field<Row>[]): void;
clear(): void;
}

View File

@@ -0,0 +1,22 @@
export default class SearchHandler {
search;
event;
constructor(context) {
this.search = context.search;
this.event = context.event;
}
set(value, scope = null) {
this.search.update((store) => {
store = {
value: value ?? '',
scope: scope ?? null,
};
return store;
});
}
clear() {
this.search.set({ value: null, scope: null });
this.event.trigger('change');
this.event.trigger('clearSearch');
}
}

View File

@@ -0,0 +1,13 @@
import type Context from '../Context';
export default class SelectHandler<Row> {
private filteredRows;
private pagedRows;
private selected;
private scope;
private isAllSelected;
private event;
constructor(context: Context<Row>);
set(value: Row[keyof Row] | Row): void;
all(selectBy?: keyof Row): void;
clear(): void;
}

View File

@@ -0,0 +1,46 @@
import { get } from 'svelte/store';
export default class SelectHandler {
filteredRows;
pagedRows;
selected;
scope;
isAllSelected;
event;
constructor(context) {
this.filteredRows = context.filteredRows;
this.pagedRows = context.pagedRows;
this.selected = context.selected;
this.scope = context.selectScope;
this.isAllSelected = context.isAllSelected;
this.event = context.event;
}
set(value) {
const selected = get(this.selected);
if (selected.includes(value)) {
this.selected.set(selected.filter((item) => item !== value));
}
else {
this.selected.set([value, ...selected]);
}
}
all(selectBy = null) {
const isAllSelected = get(this.isAllSelected);
if (isAllSelected) {
return this.clear();
}
const scope = get(this.scope);
const rows = scope === 'currentPage' ? get(this.pagedRows) : get(this.filteredRows);
if (scope === 'currentPage') {
this.event.add('change', () => this.clear());
}
if (selectBy) {
this.selected.set(rows.map((row) => row[selectBy]));
}
else {
this.selected.set(rows);
}
}
clear() {
this.selected.set([]);
}
}

View File

@@ -0,0 +1,20 @@
import type Context from '../Context';
import type { Field } from '..';
export default class SortHandler<Row> {
private rawRows;
private event;
private sort;
private backup;
constructor(context: Context<Row>);
set(orderBy?: Field<Row>, uid?: string): void;
asc(orderBy: Field<Row>, uid?: string): void;
desc(orderBy: Field<Row>, uid?: string): void;
apply(params?: {
orderBy: Field<Row>;
direction?: 'asc' | 'desc';
}): void;
clear(): void;
define(orderBy: Field<Row>, direction?: 'asc' | 'desc'): void;
private restore;
private log;
}

View File

@@ -0,0 +1,128 @@
import { get } from 'svelte/store';
import { parseField } from '../utils';
export default class SortHandler {
rawRows;
event;
sort;
backup;
constructor(context) {
this.rawRows = context.rawRows;
this.event = context.event;
this.sort = context.sort;
this.backup = [];
}
set(orderBy = null, uid) {
if (!orderBy)
return;
const sort = get(this.sort);
const { identifier } = parseField(orderBy, uid);
if (sort.identifier !== identifier) {
this.sort.update((store) => (store.direction = null));
}
if (sort.direction === null || sort.direction === 'desc') {
this.asc(orderBy, uid);
}
else if (sort.direction === 'asc') {
this.desc(orderBy, uid);
}
}
asc(orderBy, uid) {
if (!orderBy)
return;
const { identifier, callback, key } = parseField(orderBy, uid);
this.sort.set({ identifier, callback, direction: 'asc', key });
this.rawRows.update((store) => {
store.sort((x, y) => {
const [a, b] = [callback(x), callback(y)];
if (a === b)
return 0;
if (a === null)
return 1;
if (b === null)
return -1;
if (typeof a === 'boolean')
return a === false ? 1 : -1;
if (typeof a === 'string')
return a.localeCompare(b);
if (typeof a === 'number')
return a - b;
if (typeof a === 'object')
return JSON.stringify(a).localeCompare(JSON.stringify(b));
else
return String(a).localeCompare(String(b));
});
return store;
});
this.log({ identifier, callback, direction: 'asc' });
this.event.trigger('change');
}
desc(orderBy, uid) {
if (!orderBy)
return;
const { identifier, callback, key } = parseField(orderBy, uid);
this.sort.set({ identifier, callback, direction: 'desc', key });
this.rawRows.update((store) => {
store.sort((x, y) => {
const [a, b] = [callback(x), callback(y)];
if (a === b)
return 0;
if (a === null)
return 1;
if (b === null)
return -1;
if (typeof b === 'boolean')
return b === false ? 1 : -1;
if (typeof b === 'string')
return b.localeCompare(a);
if (typeof b === 'number')
return b - a;
if (typeof b === 'object')
return JSON.stringify(b).localeCompare(JSON.stringify(a));
else
return String(b).localeCompare(String(a));
});
return store;
});
this.log({ identifier, callback, direction: 'desc' });
this.event.trigger('change');
}
apply(params = null) {
if (params) {
switch (params.direction) {
case 'asc': return this.asc(params.orderBy);
case 'desc': return this.desc(params.orderBy);
default: return this.set(params.orderBy);
}
}
else {
this.restore();
}
}
clear() {
this.backup = [];
this.sort.set({});
}
define(orderBy, direction = 'asc') {
if (!orderBy)
return;
const { identifier, callback, key } = parseField(orderBy);
this.sort.set({ identifier, callback, direction, key });
}
restore() {
for (const sort of this.backup) {
const { key, callback, direction } = sort;
const param = key ?? callback;
this[direction](param);
}
}
log(sort) {
this.backup = this.backup.filter(item => item.identifier !== sort.identifier);
if (this.backup.length >= 3) {
const [_, slot2, slot3] = this.backup;
this.backup = [slot2, slot3, sort];
}
else {
this.backup = [...this.backup, sort];
}
}
}

View File

@@ -0,0 +1,15 @@
import type { Field, Comparator } from '..';
import type FilterHandler from '../handlers/FilterHandler';
import { type Writable } from 'svelte/store';
type Value = string | number | [min: number, max: number];
export default class AdvancedFilterHandler<Row> {
private filterHandler;
private criteria;
private filterBy;
private selected;
constructor(filterHandler: FilterHandler<Row>, filterBy: Field<Row>);
set(value: Value, comparator?: Comparator<any>): void;
getSelected(): Writable<Value[]>;
clear(): void;
}
export {};

View File

@@ -0,0 +1,35 @@
import { writable } from 'svelte/store';
import { check } from '../Comparator';
export default class AdvancedFilterHandler {
filterHandler;
criteria;
filterBy;
selected;
constructor(filterHandler, filterBy) {
this.filterHandler = filterHandler;
this.filterBy = filterBy;
this.criteria = [];
this.selected = writable([]);
}
set(value, comparator = check.isLike) {
if (this.criteria.find(criterion => criterion.value === value)) {
this.criteria = this.criteria.filter(criterion => criterion.value !== value);
}
else {
this.criteria = [{ value, comparator }, ...this.criteria];
}
if (this.criteria.length === 0) {
return this.clear();
}
this.filterHandler.set(this.criteria, this.filterBy, check.whereIn);
this.selected.set(this.criteria.map(criterion => criterion.value));
}
getSelected() {
return this.selected;
}
clear() {
this.criteria = [];
this.selected.set([]);
this.filterHandler.set(undefined, this.filterBy, check.whereIn);
}
}

View File

@@ -0,0 +1,21 @@
import type { Field } from '..';
import type Context from '../Context';
import { type Readable } from 'svelte/store';
export default class CalcultationHandler<Row> {
private rawRows;
private filteredRows;
private callback;
private precision;
constructor(context: Context<Row>, field: Field<Row>, param: {
precision: number;
});
distinct(callback?: (values: any[]) => any[]): {
value: string;
count: unknown;
}[];
avg(callback?: (values: number[]) => any[]): Readable<number>;
sum(callback?: (values: number[]) => any[]): Readable<number>;
bounds(callback?: (values: number[]) => any[]): [min: number, max: number];
setPrecision(value: number): void;
private round;
}

View File

@@ -0,0 +1,59 @@
import { get, derived } from 'svelte/store';
import { parseField } from '../utils';
export default class CalcultationHandler {
rawRows;
filteredRows;
callback;
precision;
constructor(context, field, param) {
this.rawRows = context.rawRows;
this.filteredRows = context.filteredRows;
this.callback = parseField(field).callback;
this.precision = param.precision;
}
distinct(callback = null) {
const rawRows = get(this.rawRows);
const values = rawRows.map(row => this.callback(row));
const array = callback ? callback(values) : values;
const result = array.reduce((acc, curr) => {
acc[curr] = (acc[curr] ?? 0) + 1;
return acc;
}, {});
return Object.entries(result).map(([value, count]) => ({ value, count }));
}
avg(callback = null) {
return derived(this.filteredRows, $filteredRows => {
if ($filteredRows.length === 0)
return 0;
const values = $filteredRows.map(row => this.callback(row)).filter(Boolean);
const array = callback ? callback(values) : values;
return this.round(array.reduce((acc, curr) => acc + curr, 0) / array.length);
});
}
sum(callback = null) {
return derived(this.filteredRows, $filteredRows => {
const values = $filteredRows.map(row => this.callback(row));
const array = callback ? callback(values) : values;
return this.round(array.reduce((acc, curr) => acc + curr, 0));
});
}
bounds(callback = null) {
const rawRows = get(this.rawRows);
const values = rawRows.map(row => this.callback(row));
const numbers = callback ? callback(values) : values;
return [
Math.min(...numbers.filter(Boolean)),
Math.max(...numbers.filter(Boolean))
];
}
setPrecision(value) {
this.precision = value;
}
round(value) {
if (this.precision === 0) {
return Math.round(value);
}
const denominator = Math.pow(10, this.precision);
return Math.round((value + Number.EPSILON) * denominator) / denominator;
}
}

View File

@@ -0,0 +1,15 @@
import type { Field, Comparator } from '..';
import type FilterHandler from '../handlers/FilterHandler';
type Value = string | number | boolean;
export default class FilterHelper<Row> {
private filterHandler;
private filterBy;
private uid;
private comparator;
private callback;
constructor(filterHandler: FilterHandler<Row>, filterBy: Field<Row>, comparator?: Comparator<Row>);
set(value: Value, comparator?: Comparator<any>): void;
clear(): void;
on(event: 'clear', callback: () => void): void;
}
export {};

View File

@@ -0,0 +1,28 @@
import { check } from '../Comparator';
export default class FilterHelper {
filterHandler;
filterBy;
uid;
comparator;
callback;
constructor(filterHandler, filterBy, comparator) {
this.filterHandler = filterHandler;
this.filterBy = filterBy;
this.uid = 'f_' + (Math.random()).toString(28).substring(2);
this.comparator = comparator ?? check.isLike;
this.callback = () => null;
}
set(value, comparator) {
if (comparator) {
this.comparator = comparator;
}
this.filterHandler.set(value, this.filterBy, this.comparator, this.uid);
}
clear() {
this.callback();
this.filterHandler.set(undefined, this.filterBy);
}
on(event, callback) {
this.callback = callback;
}
}

View File

@@ -0,0 +1,67 @@
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import Pagination from './Pagination.svelte';
import RowCount from './RowCount.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Search from './Search.svelte';
import { check } from './Comparator';
export { DataHandler, check, Datatable, Th, ThFilter, Pagination, RowCount, RowsPerPage, Search };
export type { default as EventHandler } from './handlers/EventHandler';
export type Internationalization = {
search?: string;
show?: string;
entries?: string;
filter?: string;
rowCount?: string;
noRows?: string;
previous?: string;
next?: string;
};
export type Row = {
[key: string]: any;
};
export type Field<Row> = keyof Row | ((row: Row) => Row[keyof Row]);
export type Comparator<Row> = (entry: Row[keyof Row], value: any) => boolean;
export type Criterion = {
value: string | number | [min: number, max: number];
comparator: Comparator<Row>;
};
export type Filter<Row> = {
callback: (row: Row) => Row[keyof Row];
identifier: string;
value?: string | number | boolean | symbol | Criterion[] | number[];
comparator?: Comparator<Row>;
key?: string;
};
export type Sort<Row> = {
callback?: (row: Row) => Row[keyof Row];
identifier?: string;
direction?: 'asc' | 'desc';
key?: string;
};
/**
* @deprecated use (Row[keyof Row] | Row) instead
*
* import type { Row } from '@vincjo/datatables'
*/
export type Selectable<Row> = Row[keyof Row] | Row;
/**
* @deprecated use type Field<Row> instead
*
* import type { Field } from '@vincjo/datatables'
*/
export type FilterBy<Row> = Field<Row>;
/**
* @deprecated use type Field<Row> instead
*
* import type { Field } from '@vincjo/datatables'
*/
export type OrderBy<Row> = Field<Row>;
/**
* @deprecated use type Sort<Row> instead
*
* import type { Sort } from '@vincjo/datatables'
*/
export type Order<Row> = Sort<Row>;

View File

@@ -0,0 +1,11 @@
// Reexport your entry components here
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import Pagination from './Pagination.svelte';
import RowCount from './RowCount.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Search from './Search.svelte';
import { check } from './Comparator';
export { DataHandler, check, Datatable, Th, ThFilter, Pagination, RowCount, RowsPerPage, Search };

View File

@@ -0,0 +1,8 @@
import type { Row, Field } from './';
export declare const isNull: (value: any) => boolean;
export declare const isNotNull: (value: any) => boolean;
export declare const parseField: (field: Field<any>, uid?: string) => {
callback: (row: Row) => any;
identifier: string;
key: string;
};

View File

@@ -0,0 +1,24 @@
export const isNull = (value) => {
if (value === null || value === undefined || value === '')
return true;
return false;
};
export const isNotNull = (value) => { return !isNull(value); };
export const parseField = (field, uid) => {
const identifier = uid ?? field.toString();
if (typeof field === 'string') {
return {
callback: (row) => row[field],
identifier,
key: field
};
}
else if (typeof field === 'function') {
return {
callback: field,
identifier,
key: undefined
};
}
throw new Error(`Invalid field argument: ${String(field)}`);
};

View File

@@ -0,0 +1,37 @@
import { type Writable, type Readable } from 'svelte/store';
import type { State, Sort, Filter } from './';
import type { Params } from './DataHandler';
import EventHandler from './handlers/EventHandler';
export default class Context<Row> {
totalRows: Writable<number | undefined>;
rowsPerPage: Writable<number>;
pageNumber: Writable<number>;
event: EventHandler;
search: Writable<string>;
filters: Writable<Filter<Row>[]>;
rows: Writable<Row[]>;
rowCount: Readable<{
total: number;
start: number;
end: number;
}>;
pages: Readable<number[]>;
pagesWithEllipsis: Readable<number[]>;
pageCount: Readable<number>;
sort: Writable<Sort<Row>>;
selected: Writable<(Row | Row[keyof Row])[]>;
isAllSelected: Readable<boolean>;
selectedCount: Readable<{
count: number;
total: number;
}>;
selectBy: keyof Row | undefined;
constructor(data: Row[], params: Params);
getState(): State;
private createPages;
private createPagesWithEllipsis;
private createPageCount;
private createRowCount;
private createIsAllSelected;
private createSelectedCount;
}

View File

@@ -0,0 +1,140 @@
import { writable, get, derived } from 'svelte/store';
import EventHandler from './handlers/EventHandler';
export default class Context {
totalRows;
rowsPerPage;
pageNumber;
event;
search;
filters;
rows;
rowCount;
pages;
pagesWithEllipsis;
pageCount;
sort;
selected;
isAllSelected;
selectedCount;
selectBy;
constructor(data, params) {
this.totalRows = writable(params.totalRows);
this.rowsPerPage = writable(params.rowsPerPage);
this.pageNumber = writable(1);
this.event = new EventHandler();
this.search = writable('');
this.filters = writable([]);
this.rows = writable(data);
this.rowCount = this.createRowCount();
this.pages = this.createPages();
this.pagesWithEllipsis = this.createPagesWithEllipsis();
this.pageCount = this.createPageCount();
this.sort = writable(undefined);
this.selected = writable([]);
this.isAllSelected = this.createIsAllSelected();
this.selectedCount = this.createSelectedCount();
this.selectBy = params.selectBy ?? undefined;
}
getState() {
const pageNumber = get(this.pageNumber);
const rowsPerPage = get(this.rowsPerPage);
const sort = get(this.sort);
const filters = get(this.filters);
return {
pageNumber,
rowsPerPage,
offset: rowsPerPage * (pageNumber - 1),
search: get(this.search),
sorted: sort ?? undefined, // deprecated
sort: sort ?? undefined,
filters: filters.length > 0 ? filters : undefined,
setTotalRows: (value) => this.totalRows.set(value)
};
}
createPages() {
return derived([this.rowsPerPage, this.totalRows], ([$rowsPerPage, $totalRows]) => {
if (!$rowsPerPage || !$totalRows) {
return undefined;
}
const pages = Array.from(Array(Math.ceil($totalRows / $rowsPerPage)));
return pages.map((_, i) => {
return i + 1;
});
});
}
createPagesWithEllipsis() {
return derived([this.pages, this.pageNumber], ([$pages, $pageNumber]) => {
if (!$pages) {
return undefined;
}
if ($pages.length <= 7) {
return $pages;
}
const ellipse = null;
const firstPage = 1;
const lastPage = $pages.length;
if ($pageNumber <= 4) {
return [
...$pages.slice(0, 5),
ellipse,
lastPage
];
}
else if ($pageNumber < $pages.length - 3) {
return [
firstPage,
ellipse,
...$pages.slice($pageNumber - 2, $pageNumber + 1),
ellipse,
lastPage
];
}
else {
return [
firstPage,
ellipse,
...$pages.slice($pages.length - 5, $pages.length)
];
}
});
}
createPageCount() {
return derived(this.pages, ($pages) => {
if (!$pages)
return undefined;
return $pages.length;
});
}
createRowCount() {
return derived([this.totalRows, this.pageNumber, this.rowsPerPage], ([$totalRows, $pageNumber, $rowsPerPage]) => {
if (!$rowsPerPage || !$totalRows) {
return undefined;
}
return {
total: $totalRows,
start: $pageNumber * $rowsPerPage - $rowsPerPage + 1,
end: Math.min($pageNumber * $rowsPerPage, $totalRows)
};
});
}
createIsAllSelected() {
return derived([this.selected, this.rows], ([$selected, $rows]) => {
if ($rows.length === 0) {
return false;
}
if (this.selectBy) {
const ids = $rows.map(row => row[this.selectBy]);
return ids.every(id => $selected.includes(id));
}
return $rows.every(row => $selected.includes(row));
});
}
createSelectedCount() {
return derived([this.selected, this.totalRows], ([$selected, $totalRows]) => {
return {
count: $selected.length,
total: $totalRows
};
});
}
}

View File

@@ -0,0 +1,77 @@
import type { Writable, Readable } from 'svelte/store';
import type { Internationalization, Row, State, Sort } from './';
export type Params = {
rowsPerPage?: number;
totalRows?: number;
selectBy?: keyof Row;
i18n?: Internationalization;
};
export default class DataHandler<T extends Row = any> {
private context;
private triggerHandler;
private sortHandler;
private selectHandler;
private pageHandler;
private searchHandler;
private filterHandler;
i18n: Internationalization;
constructor(data?: T[], params?: Params);
onChange(callback: (state: State) => Promise<T[]>): void;
invalidate(): void;
setRows(data: T[]): void;
setTotalRows(value: number): void;
getRows(): Writable<T[]>;
select(value: T[keyof T] | T): void;
getSelected(): Writable<(T | T[keyof T])[]>;
selectAll(): void;
isAllSelected(): Readable<boolean>;
getSelectedCount(): Readable<{
count: number;
total: number;
}>;
clearSelection(): void;
getRowsPerPage(): Writable<number | null>;
sort(orderBy: keyof T): void;
applySort(params?: {
orderBy: keyof T;
direction?: 'asc' | 'desc';
}): void;
sortAsc(orderBy: keyof T): void;
sortDesc(orderBy: keyof T): void;
getSort(): Writable<Sort<T>>;
search(value: string): void;
clearSearch(): void;
filter(value: string | number, filterBy: keyof T): void;
clearFilters(): void;
getPages(params?: {
ellipsis: boolean;
}): Readable<number[]>;
getPageCount(): Readable<number>;
getPageNumber(): Writable<number>;
setPage(value: number | 'previous' | 'next'): void;
getRowCount(): Readable<{
total: number;
start: number;
end: number;
}>;
on(event: 'change', callback: () => void): void;
translate(i18n: Internationalization): Internationalization;
/**
*
* @depracted use on('change', callback) instead
*/
getTriggerChange(): Writable<number>;
/**
*
* @deprecated use applySort() instead
*/
applySorting(params?: {
orderBy: keyof T;
direction?: 'asc' | 'desc';
}): void;
/**
*
* @deprecated use getSort() instead
*/
getSorted(): Writable<Sort<T>>;
}

View File

@@ -0,0 +1,157 @@
import Context from './Context';
import TriggerHandler from './handlers/TriggerHandler';
import SortHandler from './handlers/SortHandler';
import SelectHandler from './handlers/SelectHandler';
import PageHandler from './handlers/PageHandler';
import SearchHandler from './handlers/SearchHandler';
import FilterHandler from './handlers/FilterHandler';
export default class DataHandler {
context;
triggerHandler;
sortHandler;
selectHandler;
pageHandler;
searchHandler;
filterHandler;
i18n;
constructor(data = [], params = { rowsPerPage: 5 }) {
this.i18n = this.translate(params.i18n);
this.context = new Context(data, params);
this.triggerHandler = new TriggerHandler(this.context);
this.sortHandler = new SortHandler(this.context);
this.selectHandler = new SelectHandler(this.context);
this.pageHandler = new PageHandler(this.context);
this.searchHandler = new SearchHandler(this.context);
this.filterHandler = new FilterHandler(this.context);
}
onChange(callback) {
this.triggerHandler.set(callback);
}
invalidate() {
this.triggerHandler.invalidate();
}
setRows(data) {
this.context.rows.set(data);
}
setTotalRows(value) {
this.context.totalRows.set(value);
}
getRows() {
return this.context.rows;
}
select(value) {
this.selectHandler.set(value);
}
getSelected() {
return this.context.selected;
}
selectAll() {
this.selectHandler.all();
}
isAllSelected() {
return this.context.isAllSelected;
}
getSelectedCount() {
return this.context.selectedCount;
}
clearSelection() {
this.selectHandler.clear();
}
getRowsPerPage() {
return this.context.rowsPerPage;
}
sort(orderBy) {
this.setPage(1);
this.sortHandler.set(orderBy);
}
applySort(params = null) {
this.sortHandler.apply(params);
}
sortAsc(orderBy) {
this.setPage(1);
this.sortHandler.asc(orderBy);
}
sortDesc(orderBy) {
this.setPage(1);
this.sortHandler.desc(orderBy);
}
getSort() {
return this.context.sort;
}
search(value) {
this.setPage(1);
this.context.search.set(value);
}
clearSearch() {
this.searchHandler.remove();
}
filter(value, filterBy) {
this.setPage(1);
return this.filterHandler.set(value, filterBy);
}
clearFilters() {
this.filterHandler.remove();
}
getPages(params = { ellipsis: false }) {
if (params.ellipsis) {
return this.context.pagesWithEllipsis;
}
return this.context.pages;
}
getPageCount() {
return this.context.pageCount;
}
getPageNumber() {
return this.context.pageNumber;
}
setPage(value) {
switch (value) {
case 'previous': return this.pageHandler.previous();
case 'next': return this.pageHandler.next();
default: return this.pageHandler.goto(value);
}
}
getRowCount() {
return this.context.rowCount;
}
on(event, callback) {
this.context.event.add(event, callback);
}
translate(i18n) {
return {
...{
search: 'Search...',
show: 'Show',
entries: 'entries',
filter: 'Filter',
rowCount: 'Showing {start} to {end} of {total} entries',
noRows: 'No entries found',
previous: 'Previous',
next: 'Next',
selectedCount: '{count} of {total} row(s).'
},
...i18n
};
}
/**
*
* @depracted use on('change', callback) instead
*/
getTriggerChange() {
return this.context.event.triggerChange;
}
/**
*
* @deprecated use applySort() instead
*/
applySorting(params = null) {
this.applySort(params);
}
/**
*
* @deprecated use getSort() instead
*/
getSorted() {
return this.getSort();
}
}

View File

@@ -0,0 +1,98 @@
<script lang="ts">import { Search, RowsPerPage, RowCount, SelectedCount, Pagination } from './';
export let handler;
export let search = true;
export let rowsPerPage = true;
export let rowCount = true;
export let selectedCount = false;
export let pagination = true;
let element;
let clientWidth = 1000;
const height = (search || rowsPerPage ? 48 : 8) + (rowCount || selectedCount || pagination ? 48 : 8);
handler.on('change', () => {
if (element)
element.scrollTop = 0;
});
</script>
<section bind:clientWidth class={$$props.class ?? ''}>
<header class:container={search || rowsPerPage}>
{#if search}
<Search {handler} />
{:else}
<div></div>
{/if}
{#if rowsPerPage}
<RowsPerPage {handler} small={clientWidth < 600} />
{/if}
</header>
<article bind:this={element} style="height:calc(100% - {height}px)">
<slot />
</article>
<footer class:container={rowCount || pagination}>
{#if selectedCount}
<SelectedCount {handler}/>
{:else if rowCount}
<RowCount {handler} small={clientWidth < 600} />
{/if}
{#if pagination}
<Pagination {handler} small={clientWidth < 600} />
{/if}
</footer>
</section>
<style>
section {
height: 100%;
}
section :global(table) {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
section :global(thead) {
position: sticky;
inset-block-start: 0;
z-index: 1;
}
header,
footer {
min-height: 8px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
header.container,
footer.container {
height: 48px;
}
footer {
border-top: 1px solid #e0e0e0;
}
article {
position: relative;
/* height:calc(100% - 96px); */
overflow: auto;
scrollbar-width: thin;
}
article::-webkit-scrollbar {
width: 6px;
height: 6px;
}
article::-webkit-scrollbar-track {
background: #f5f5f5;
}
article::-webkit-scrollbar-thumb {
background: #c2c2c2;
}
article::-webkit-scrollbar-thumb:hover {
background: #9e9e9e;
}
</style>

View File

@@ -0,0 +1,36 @@
import { type DataHandler, type Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
search?: boolean;
rowsPerPage?: boolean;
rowCount?: boolean;
selectedCount?: boolean;
pagination?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Datatable: $$IsomorphicComponent;
type Datatable<T extends Row> = InstanceType<typeof Datatable<T>>;
export default Datatable;

View File

@@ -0,0 +1,146 @@
<script lang="ts">export let handler;
export let small = false;
const pageNumber = handler.getPageNumber();
const pageCount = handler.getPageCount();
const pages = handler.getPages({ ellipsis: true });
const setPage = (value) => {
handler.setPage(value);
handler.invalidate();
};
</script>
<section class={$$props.class ?? ''}>
{#if $pages === undefined}
<button type="button"
class="small"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
&#10094;
</button>
<button type="button" class="page">page <b>{$pageNumber}</b></button>
<button type="button"
class="small"
on:click={() => setPage('next')}
>
&#10095;
</button>
{:else}
{#if small}
<button type="button"
class="small"
class:disabled={$pageNumber === 1}
on:click={() => setPage(1)}
>
&#10092;&#10092;
</button>
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
&#10094;
</button>
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
<button type="button"
class="small"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage($pageCount)}
>
&#10093;&#10093;
</button>
{:else}
<button type="button"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}
>
{@html handler.i18n.previous}
</button>
{#each $pages as page}
<button type="button"
class:active={$pageNumber === page}
class:ellipse={page === null}
on:click={() => setPage(page)}
>
{page ?? '...'}
</button>
{/each}
<button type="button"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
{@html handler.i18n.next}
</button>
{/if}
{/if}
</section>
<style>
section {
display: flex;
}
button {
background: inherit;
height: 32px;
width: 32px;
color: #616161;
cursor: pointer;
font-size: 13px;
margin: 0;
padding: 0;
transition: all, 0.2s;
line-height: 32px;
border: 1px solid #e0e0e0;
border-right: none;
border-radius: 0;
outline: none;
}
button:first-child {
border-radius: 4px 0 0 4px;
}
button:last-child {
border-right: 1px solid #e0e0e0;
border-radius: 0 4px 4px 0;
}
button:first-child:not(.small),
button:last-child:not(.small) {
min-width: 72px;
}
button:not(.active):hover {
background: #eee;
}
button.disabled:hover {
background: inherit;
cursor: default;
}
button.page {
width: 72px;
background: #fafafa;
}
button.page:hover {
background: #fafafa;
cursor: default;
}
button.ellipse:hover {
background: inherit;
cursor: default;
}
button.active {
background: #eee;
font-weight: bold;
cursor: default;
}
button.disabled {
color: #bdbdbd;
}
button.disabled:hover {
background: inherit;
cursor: default;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Pagination: $$IsomorphicComponent;
type Pagination<T extends Row> = InstanceType<typeof Pagination<T>>;
export default Pagination;

View File

@@ -0,0 +1,12 @@
<div align="center">
<img align="center" src="../../../static/logo-remote.svg" alt="logo" width="172"/>
<p align="center">
<h1 align="center" style="font-size:32px;margin:0;border:none;">svelte simple datatables</h1>
<p style="color:#eee">For server-side pagination</p>
<img src="https://img.shields.io/npm/v/@vincjo/datatables?color=%23006990" alt="npm version"/>
<img src="https://img.shields.io/github/license/vincjo/datatables?color=006990" alt="last commit"/>
</p>
</div>
<p><strong><a href="https://vincjo.fr/datatables/remote/home" rel="nofollow">Home</a></strong></p>
<p><strong><a href="https://vincjo.fr/datatables/remote/examples" rel="nofollow">Examples</a></strong></p>

View File

@@ -0,0 +1,36 @@
<script lang="ts">export let handler;
export let small = false;
const rowCount = handler.getRowCount();
</script>
{#if $rowCount === undefined}
<div></div>
{:else}
<aside class={$$props.class ?? ''}>
{#if small}
{#if $rowCount.total > 0}
<b>{$rowCount.start}</b>-
<b>{$rowCount.end}</b>/
<b>{$rowCount.total}</b>
{:else}
{handler.i18n.noRows}
{/if}
{:else if $rowCount.total > 0}
{@html handler.i18n.rowCount
.replace('{start}', `<b>${$rowCount.start}</b>`)
.replace('{end}', `<b>${$rowCount.end}</b>`)
.replace('{total}', `<b>${$rowCount.total}</b>`)}
{:else}
{handler.i18n.noRows}
{/if}
</aside>
{/if}
<style>
aside {
color: #616161;
line-height: 32px;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,26 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowCount: $$IsomorphicComponent;
type RowCount<T extends Row> = InstanceType<typeof RowCount<T>>;
export default RowCount;

View File

@@ -0,0 +1,38 @@
<script lang="ts">export let handler;
export let small = false;
export let options = [5, 10, 20];
const rowsPerPage = handler.getRowsPerPage();
const setRowsPerPage = () => {
handler.setPage(1);
handler.invalidate();
};
</script>
<aside class={$$props.class ?? ''}>
{#if !small}
<span>{handler.i18n.show}</span>
{/if}
<select bind:value={$rowsPerPage} on:change={setRowsPerPage}>
{#each options as option}
<option value={option}>
{option}
</option>
{/each}
</select>
{#if !small}
<span>{handler.i18n.entries}</span>
{/if}
</aside>
<style>
aside {
display: flex;
justify-content: flex-start;
align-items: center;
height: 32px;
color: #757575;
}
select {
margin: 0 4px;
}
</style>

View File

@@ -0,0 +1,27 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
small?: boolean;
options?: number[];
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const RowsPerPage: $$IsomorphicComponent;
type RowsPerPage<T extends Row> = InstanceType<typeof RowsPerPage<T>>;
export default RowsPerPage;

View File

@@ -0,0 +1,43 @@
<script lang="ts">export let handler;
let value = '';
let timeout;
const search = () => {
handler.search(value);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<input
class={$$props.class ?? ''}
bind:value
placeholder={handler.i18n.search}
spellcheck="false"
on:input={search}
/>
<style>
input {
border: 1px solid #e0e0e0;
border-radius: 4px;
outline: none;
padding: 0 8px;
line-height: 24px;
margin: 0;
height: 24px;
background: transparent;
width: 40%;
max-width: 176px;
min-width: 96px;
transition: all, 0.1s;
}
input:focus {
border: 1px solid #bdbdbd;
}
input::placeholder {
color: #9e9e9e;
line-height: 24px;
}
</style>

View File

@@ -0,0 +1,25 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Search: $$IsomorphicComponent;
type Search<T extends Row> = InstanceType<typeof Search<T>>;
export default Search;

View File

@@ -0,0 +1,32 @@
<script lang="ts">export let handler;
const selectedCount = handler.getSelectedCount();
</script>
<aside class={$$props.class ?? ''}>
<b>{$selectedCount.count}</b>
{#if $selectedCount.total}
of <b>{$selectedCount.total}</b>
{/if}
row(s) selected.
{#if $selectedCount.count > 0}
<button type="button" on:click={() => handler.clearSelection()}> Clear</button>
{/if}
</aside>
<style>
aside {
color: #616161;
line-height: 32px;
font-size: 14px;
}
button {
background: #fafafa;
padding: 2px 4px;
border-radius: 2px;
cursor: pointer;
border: 1px solid #e0e0e0;
}
</style>

View File

@@ -0,0 +1,25 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const SelectedCount: $$IsomorphicComponent;
type SelectedCount<T extends Row> = InstanceType<typeof SelectedCount<T>>;
export default SelectedCount;

View File

@@ -0,0 +1,81 @@
<script lang="ts">export let handler;
export let orderBy;
export let align = 'left';
export let rowSpan = 1;
const sort = handler.getSort();
const update = () => {
handler.sort(orderBy);
handler.invalidate();
};
</script>
<th
on:click={update}
class:sortable={orderBy}
class:active={$sort?.orderBy === orderBy}
class={$$props.class ?? ''}
rowspan={rowSpan}
>
<div
class="flex"
style:justify-content={align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center'}
>
<strong>
<slot />
</strong>
<span class:asc={$sort?.direction === 'asc'} class:desc={$sort?.direction === 'desc'}></span>
</div>
</th>
<style>
th {
background: inherit;
padding: 8px 20px;
white-space: nowrap;
font-size: 13px;
user-select: none;
border-bottom: 1px solid #e0e0e0;
}
th.sortable {
cursor: pointer;
}
th strong {
white-space: pre-wrap;
font-size: 13.5px;
line-height: 16px;
}
th.sortable div.flex {
padding: 0;
display: flex;
align-items: center;
height: 100%;
}
th.sortable span {
padding-left: 8px;
}
th.sortable span:before,
th.sortable span:after {
border: 4px solid transparent;
content: '';
display: block;
height: 0;
width: 0;
}
th.sortable span:before {
border-bottom-color: #e0e0e0;
margin-top: 2px;
}
th.sortable span:after {
border-top-color: #e0e0e0;
margin-top: 2px;
}
th.active.sortable span.asc:before {
border-bottom-color: #9e9e9e;
}
th.active.sortable span.desc:after {
border-top-color: #9e9e9e;
}
th:not(.sortable) span {
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,34 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
orderBy: keyof T;
align?: "left" | "right" | "center";
rowSpan?: number;
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {
default: {};
};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']> & {
children?: any;
}>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
$$slots?: ReturnType<__sveltets_Render<T>['slots']>;
children?: any;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const Th: $$IsomorphicComponent;
type Th<T extends Row> = InstanceType<typeof Th<T>>;
export default Th;

View File

@@ -0,0 +1,51 @@
<script lang="ts">export let handler;
export let filterBy;
export let align = 'left';
let value = '';
let timeout;
const filter = () => {
handler.filter(value, filterBy);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<th class={$$props.class ?? ''}>
<input
style:text-align={align}
type="text"
placeholder={handler.i18n.filter}
spellcheck="false"
bind:value
on:input={filter}
/>
</th>
<style>
th {
border-bottom: 1px solid #e0e0e0;
}
input {
width: 100%;
height: 24px;
border: none;
text-align: left;
padding: 0 20px;
background: inherit;
outline: none;
border-radius: 0;
font-size: 14px;
font-family: Arial, Helvetica, sans-serif;
}
input::placeholder {
color: #bdbdbd;
font-style: italic;
font-size: 13px;
}
input:focus {
outline: none;
border: none;
}
</style>

View File

@@ -0,0 +1,27 @@
import type { DataHandler, Row } from './';
declare class __sveltets_Render<T extends Row> {
props(): {
[x: string]: any;
handler: DataHandler<T>;
filterBy: keyof T;
align?: "left" | "right" | "center";
};
events(): {} & {
[evt: string]: CustomEvent<any>;
};
slots(): {};
bindings(): string;
exports(): {};
}
interface $$IsomorphicComponent {
new <T extends Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
} & ReturnType<__sveltets_Render<T>['exports']>;
<T extends Row>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {
$$events?: ReturnType<__sveltets_Render<T>['events']>;
}): ReturnType<__sveltets_Render<T>['exports']>;
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
}
declare const ThFilter: $$IsomorphicComponent;
type ThFilter<T extends Row> = InstanceType<typeof ThFilter<T>>;
export default ThFilter;

View File

@@ -0,0 +1,6 @@
export default class EventHandler {
private events;
triggerChange: import("svelte/store").Writable<number>;
add(event: keyof EventHandler['events'], callback: () => void): void;
trigger(event: keyof EventHandler['events']): void;
}

View File

@@ -0,0 +1,21 @@
import { writable } from 'svelte/store';
export default class EventHandler {
events = {
change: [],
clearFilters: [],
clearSearch: []
};
triggerChange = writable(0); // legacy
add(event, callback) {
this.events[event].push(callback);
}
trigger(event) {
for (const callback of this.events[event]) {
callback();
}
/* legacy: support for triggerChange store */
if (event === 'change') {
this.triggerChange.update((store) => { return store + 1; });
}
}
}

View File

@@ -0,0 +1,9 @@
import type { Filter } from '..';
import type Context from '../Context';
import type { Writable } from 'svelte/store';
export default class FilterHandler<Row> {
filters: Writable<Filter<Row>[]>;
constructor(context: Context<Row>);
set(value: string | number, filterBy: keyof Row): void;
remove(): void;
}

View File

@@ -0,0 +1,21 @@
export default class FilterHandler {
filters;
constructor(context) {
this.filters = context.filters;
}
set(value, filterBy) {
const filter = { filterBy, value };
this.filters.update((store) => {
store = store.filter((item) => {
return (item.filterBy !== filterBy) && item.value;
});
if (value) {
store.push(filter);
}
return store;
});
}
remove() {
this.filters.set([]);
}
}

View File

@@ -0,0 +1,21 @@
import type Context from '../Context';
import { type Writable, type Readable } from 'svelte/store';
import type EventHandler from './EventHandler';
export default class PageHandler<Row> {
totalRows: Writable<number>;
pageNumber: Writable<number>;
rowCount: Readable<{
total: number;
start: number;
end: number;
}>;
rowsPerPage: Writable<number | null>;
event: EventHandler;
pages: Readable<number[]>;
selected: Writable<(Row | Row[keyof Row])[]>;
constructor(context: Context<Row>);
get(): Readable<number[]>;
goto(number: number): void;
previous(): void;
next(): void;
}

View File

@@ -0,0 +1,50 @@
import { get } from 'svelte/store';
export default class PageHandler {
totalRows;
pageNumber;
rowCount;
rowsPerPage;
event;
pages;
selected;
constructor(context) {
this.totalRows = context.totalRows;
this.pageNumber = context.pageNumber;
this.rowCount = context.rowCount;
this.rowsPerPage = context.rowsPerPage;
this.event = context.event;
this.pages = context.pages;
this.selected = context.selected;
}
get() {
return this.pages;
}
goto(number) {
const rowsPerPage = get(this.rowsPerPage);
const totalRows = get(this.totalRows);
this.pageNumber.update((store) => {
if (rowsPerPage && totalRows) {
if (number >= 1 && number <= Math.ceil(totalRows / rowsPerPage)) {
store = number;
this.event.trigger('change');
}
return store;
}
else {
if (number >= 1) {
store = number;
this.event.trigger('change');
}
return store;
}
});
}
previous() {
const number = get(this.pageNumber) - 1;
this.goto(number);
}
next() {
const number = get(this.pageNumber) + 1;
this.goto(number);
}
}

View File

@@ -0,0 +1,7 @@
import type Context from '../Context';
export default class SearchHandler<Row> {
private search;
constructor(context: Context<Row>);
set(value: string): void;
remove(): void;
}

View File

@@ -0,0 +1,12 @@
export default class SearchHandler {
search;
constructor(context) {
this.search = context.search;
}
set(value) {
this.search.set(value ?? null);
}
remove() {
this.search.set(null);
}
}

View File

@@ -0,0 +1,11 @@
import type Context from '../Context';
export default class SelectHandler<Row> {
private rows;
private selected;
private isAllSelected;
private selectBy;
constructor(context: Context<Row>);
set(value: Row | Row[keyof Row]): void;
all(): void;
clear(): void;
}

View File

@@ -0,0 +1,46 @@
import { get } from 'svelte/store';
export default class SelectHandler {
rows;
selected;
isAllSelected;
selectBy;
constructor(context) {
this.rows = context.rows;
this.selected = context.selected;
this.isAllSelected = context.isAllSelected;
this.selectBy = context.selectBy;
}
set(value) {
const selected = get(this.selected);
if (selected.includes(value)) {
this.selected.set(selected.filter((item) => item !== value));
}
else {
this.selected.set([value, ...selected]);
}
}
all() {
const rows = get(this.rows);
const isAllSelected = get(this.isAllSelected);
this.selected.update(store => {
if (this.selectBy) {
return store = store.filter(item => !rows.map((row) => row[this.selectBy]).includes(item));
}
return store = store.filter(item => !rows.includes(item));
});
if (!isAllSelected) {
this.selected.update(store => {
if (this.selectBy) {
store = [...rows.map((row) => row[this.selectBy]), ...store];
}
else {
store = [...rows, ...store];
}
return store;
});
}
}
clear() {
this.selected.set([]);
}
}

View File

@@ -0,0 +1,14 @@
import type Context from '../Context';
export default class SortHandler<Row> {
private event;
private hasMultipleSort;
private sort;
constructor(context: Context<Row>);
set(orderBy?: keyof Row): void;
asc(orderBy: keyof Row): void;
desc(orderBy: keyof Row): void;
apply(params?: {
orderBy: keyof Row;
direction?: 'asc' | 'desc';
}): any;
}

View File

@@ -0,0 +1,51 @@
import { get } from 'svelte/store';
export default class SortHandler {
event;
hasMultipleSort;
sort;
constructor(context) {
this.event = context.event;
this.hasMultipleSort = false;
this.sort = context.sort;
}
set(orderBy = null) {
if (!orderBy)
return;
const sort = get(this.sort);
if (!sort || sort.orderBy !== orderBy) {
this.asc(orderBy);
}
else if (sort.direction === 'asc') {
this.desc(sort.orderBy);
}
else if (sort.direction === 'desc') {
this.asc(orderBy);
}
}
asc(orderBy) {
if (!orderBy)
return;
this.sort.set({ orderBy, direction: 'asc' });
this.event.trigger('change');
}
desc(orderBy) {
if (!orderBy)
return;
this.sort.set({ orderBy, direction: 'desc' });
this.event.trigger('change');
}
apply(params = null) {
if (params) {
switch (params.direction) {
case 'asc': return this.asc(params.orderBy);
case 'desc': return this.desc(params.orderBy);
default: return this.set(params.orderBy);
}
}
const sort = get(this.sort);
if (sort) {
return this.apply({ orderBy: sort.orderBy, direction: sort.direction });
}
return;
}
}

View File

@@ -0,0 +1,9 @@
import type { State } from '..';
import type Context from '../Context';
export default class TriggerHandler<Row> {
private context;
private reload;
constructor(context: Context<Row>);
set(callback: (state: State) => Promise<Row[]>): void;
invalidate(): Promise<void>;
}

View File

@@ -0,0 +1,19 @@
export default class TriggerHandler {
context;
reload;
constructor(context) {
this.context = context;
}
set(callback) {
this.reload = callback;
}
async invalidate() {
if (!this.reload)
return;
const state = this.context.getState();
const data = await this.reload(state);
if (data) {
this.context.rows.set(data);
}
}
}

View File

@@ -0,0 +1,59 @@
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Search from './Search.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import RowCount from './RowCount.svelte';
import SelectedCount from './SelectedCount.svelte';
import Pagination from './Pagination.svelte';
export { DataHandler, Datatable, Search, RowsPerPage, Th, ThFilter, RowCount, SelectedCount, Pagination };
export type Internationalization = {
search?: string;
show?: string;
entries?: string;
filter?: string;
rowCount?: string;
noRows?: string;
previous?: string;
next?: string;
selectedCount?: string;
};
export type Row = {
[key: string]: unknown;
};
export type Filter<Row> = {
filterBy: keyof Row;
value?: string | number | boolean;
};
export type Sort<Row> = {
orderBy?: keyof Row;
direction?: 'asc' | 'desc';
};
export type State = {
pageNumber: number;
rowsPerPage: number;
offset: number;
search: string | undefined;
sort: Sort<Row> | undefined;
filters: Filter<Row>[] | undefined;
setTotalRows: (value: number) => void;
/**
* @deprecated use 'sort' instead
*/
sorted: Sort<Row> | undefined;
};
/**
* @deprecated use (Row[keyof Row] | Row) instead
* @since v1.13.0 2023-11-14
*
* import type { Row } from '@vincjo/datatables'
*/
export type Selectable<Row> = Row[keyof Row] | Row;
/**
* @deprecated use type Sort<Row> instead
* @since v1.13.0 2023-11-14
*
* import type { Sort } from '@vincjo/datatables'
*/
export type Order<Row> = Sort<Row>;

View File

@@ -0,0 +1,11 @@
// Reexport your entry components here
import DataHandler from './DataHandler';
import Datatable from './Datatable.svelte';
import Search from './Search.svelte';
import RowsPerPage from './RowsPerPage.svelte';
import Th from './Th.svelte';
import ThFilter from './ThFilter.svelte';
import RowCount from './RowCount.svelte';
import SelectedCount from './SelectedCount.svelte';
import Pagination from './Pagination.svelte';
export { DataHandler, Datatable, Search, RowsPerPage, Th, ThFilter, RowCount, SelectedCount, Pagination };

View File

@@ -0,0 +1 @@
export { TableHandler, Datatable, Search, RowsPerPage, Th, ThSort, ThFilter, Pagination, RowCount, type Row, type State, type Filter, type Sort, type Internationalization, type TableHandlerInterface } from '../src/server/index.js';

View File

@@ -0,0 +1,5 @@
export {
// class:
TableHandler,
// components:
Datatable, Search, RowsPerPage, Th, ThSort, ThFilter, Pagination, RowCount } from '../src/server/index.js';

View File

@@ -0,0 +1,39 @@
import type { Field, TableParams } from './';
import { EventDispatcher } from '../shared';
import { type Search, type Filter, type Query, type Sort } from './core';
export default abstract class AbstractTableHandler<Row> {
protected selectBy?: Field<Row>;
protected selectScope: "all" | "currentPage";
protected highlight: boolean;
protected event: EventDispatcher;
protected rawRows: Row[];
protected search: Search<Row>;
protected sort: Sort<Row>;
filters: Filter<Row>[];
queries: Query<Row>[];
rowsPerPage: number;
currentPage: number;
element: HTMLElement;
clientWidth: number;
filterCount: number;
allRows: readonly Row[];
rows: readonly Row[];
rowCount: {
total: number;
start: number;
end: number;
selected: number;
};
pages: readonly number[];
pageCount: number;
pagesWithEllipsis: readonly number[];
selected: unknown[];
isAllSelected: boolean;
constructor(data: Row[], params: TableParams<Row>);
private createAllRows;
private createRows;
private createRowCount;
private createPages;
private createPagesWithEllipsis;
private createIsAllSelected;
}

View File

@@ -0,0 +1,119 @@
import { EventDispatcher } from '../shared';
import { data, parse } from './core';
export default class AbstractTableHandler {
selectBy;
selectScope = $state('currentPage');
highlight;
event = new EventDispatcher();
rawRows = $state.raw([]);
search = $state({ value: null });
sort = $state({});
filters = $state([]);
queries = $state([]);
rowsPerPage = $state(10);
currentPage = $state(1);
element = $state(undefined);
clientWidth = $state(1000);
filterCount = $derived(this.filters.length);
allRows = $derived(this.createAllRows());
rows = $derived(this.createRows());
rowCount = $derived(this.createRowCount());
pages = $derived(this.createPages());
pageCount = $derived(this.pages.length);
pagesWithEllipsis = $derived(this.createPagesWithEllipsis());
selected = $state([]);
isAllSelected = $derived(this.createIsAllSelected());
constructor(data, params) {
this.rawRows = data;
this.rowsPerPage = params.rowsPerPage ?? null;
this.highlight = params.highlight ?? false;
this.selectBy = params.selectBy;
}
createAllRows() {
let allRows = $state.snapshot(this.rawRows);
if (this.search.value) {
allRows = data.search(allRows, this.search, this.highlight);
this.event.dispatch('change');
}
else if (this.filters.length > 0) {
for (const filter of this.filters) {
allRows = data.filter(allRows, filter, this.highlight);
}
this.event.dispatch('change');
}
else if (this.queries.length > 0) {
for (const query of this.queries) {
allRows = data.query(allRows, query);
}
this.event.dispatch('change');
}
return allRows;
}
createRows() {
if (!this.rowsPerPage)
return this.allRows;
return this.allRows.slice((this.currentPage - 1) * this.rowsPerPage, this.currentPage * this.rowsPerPage);
}
createRowCount() {
const total = this.allRows.length;
if (!this.rowsPerPage) {
return { total: total, start: 1, end: total, selected: this.selected.length };
}
return {
total: total,
start: this.currentPage * this.rowsPerPage - this.rowsPerPage + 1,
end: Math.min(this.currentPage * this.rowsPerPage, total),
selected: this.selected.length
};
}
createPages() {
if (!this.rowsPerPage) {
return [1];
}
const pages = Array.from(Array(Math.ceil(this.allRows.length / this.rowsPerPage)));
return pages.map((_, i) => i + 1);
}
createPagesWithEllipsis() {
if (this.pageCount <= 7) {
return this.pages;
}
const ellipse = null;
const firstPage = 1;
const lastPage = this.pageCount;
if (this.currentPage <= 4) {
return [
...this.pages.slice(0, 5),
ellipse,
lastPage
];
}
else if (this.currentPage < lastPage - 3) {
return [
firstPage,
ellipse,
...this.pages.slice(this.currentPage - 2, this.currentPage + 1),
ellipse,
lastPage
];
}
else {
return [
firstPage,
ellipse,
...this.pages.slice(lastPage - 5, lastPage)
];
}
}
createIsAllSelected() {
if (this.rowCount.total === 0 || !this.selectBy) {
return false;
}
const { callback } = parse(this.selectBy);
if (this.selectScope === 'all') {
const identifiers = this.allRows.map(callback);
return identifiers.every(id => this.selected.includes(id));
}
const identifiers = this.rows.map(callback);
return identifiers.every(id => this.selected.includes(id));
}
}

View File

@@ -0,0 +1,49 @@
import AbstractTableHandler from './AbstractTableHandler.svelte';
import type { Internationalization, Row, Field, Check, TableParams, ColumnView, TableHandlerInterface } from './';
import ViewBuilder from '../shared/builders/ViewBuilder.svelte';
import SearchBuilder from './builders/SearchBuilder.svelte';
import FilterBuilder from './builders/FilterBuilder.svelte';
import QueryBuilder from './builders/QueryBuilder.svelte';
import AdvancedFilterBuilder from './builders/AdvancedFilterBuilder.svelte';
import CalculationBuilder from './builders/CalculationBuilder.svelte';
import SortBuilder from './builders/SortBuilder.svelte';
import CSVBuilder from './builders/CSVBuilder.svelte';
import RecordFilterBuilder from './builders/RecordFilterBuilder.svelte';
export default class TableHandler<T extends Row = any> extends AbstractTableHandler<T> implements TableHandlerInterface<T> {
i18n: Internationalization;
private view;
private sortHandler;
private filterHandler;
private queryHandler;
private selectHandler;
private pageHandler;
private searchHandler;
constructor(data?: T[], params?: TableParams<T>);
setRows(data: T[]): void;
setRowsPerPage(value: number): void;
setPage(value: number | 'previous' | 'next' | 'last'): void;
createSearch(scope?: Field<T>[]): SearchBuilder<T>;
clearSearch(): void;
createRecordFilter(records?: Row[]): RecordFilterBuilder;
createSort(field: Field<T>, params?: {
locales: Intl.LocalesArgument;
options: Intl.CollatorOptions;
}): SortBuilder<T>;
clearSort(): void;
clearFilters(): void;
createAdvancedFilter(field: Field<T>, check?: Check): AdvancedFilterBuilder<T>;
createFilter(field: Field<T>, check?: Check): FilterBuilder<T>;
createQuery(): QueryBuilder<T>;
select(value: unknown): void;
selectAll(params?: {
scope?: 'all' | 'currentPage';
}): void;
getSelectedRows(): T[];
clearSelection(): void;
on(event: 'change' | 'clearFilters' | 'clearSearch', callback: () => void): void;
createCalculation(field: Field<T>): CalculationBuilder<T>;
createCSV(): CSVBuilder<T>;
createView(columns: ColumnView[]): ViewBuilder<T>;
getView(): ViewBuilder<T>;
private translate;
}

View File

@@ -0,0 +1,135 @@
import { untrack } from 'svelte';
import AbstractTableHandler from './AbstractTableHandler.svelte';
import SortHandler from './handlers/SortHandler.svelte';
import FilterHandler from './handlers/FilterHandler.svelte';
import QueryHandler from './handlers/QueryHandler.svelte';
import SelectHandler from './handlers/SelectHandler.svelte';
import PageHandler from './handlers/PageHandler.svelte';
import SearchHandler from './handlers/SearchHandler.svelte';
import ViewBuilder from '../shared/builders/ViewBuilder.svelte';
import SearchBuilder from './builders/SearchBuilder.svelte';
import FilterBuilder from './builders/FilterBuilder.svelte';
import QueryBuilder from './builders/QueryBuilder.svelte';
import AdvancedFilterBuilder from './builders/AdvancedFilterBuilder.svelte';
import CalculationBuilder from './builders/CalculationBuilder.svelte';
import SortBuilder from './builders/SortBuilder.svelte';
import CSVBuilder from './builders/CSVBuilder.svelte';
import RecordFilterBuilder from './builders/RecordFilterBuilder.svelte';
export default class TableHandler extends AbstractTableHandler {
i18n;
view;
sortHandler;
filterHandler;
queryHandler;
selectHandler;
pageHandler;
searchHandler;
constructor(data = [], params = { rowsPerPage: null }) {
super(data, params);
this.translate(params.i18n);
this.sortHandler = new SortHandler(this);
this.filterHandler = new FilterHandler(this);
this.queryHandler = new QueryHandler(this);
this.selectHandler = new SelectHandler(this);
this.pageHandler = new PageHandler(this);
this.searchHandler = new SearchHandler(this);
}
setRows(data) {
const scrollTop = this.element?.scrollTop ?? 0;
this.rawRows = data;
untrack(() => {
this.event.dispatch('change');
this.sortHandler.restore();
if (this.element) {
setTimeout(() => this.element.scrollTop = scrollTop, 2);
}
});
}
setRowsPerPage(value) {
this.rowsPerPage = value;
this.setPage(1);
}
setPage(value) {
switch (value) {
case 'previous': return this.pageHandler.previous();
case 'next': return this.pageHandler.next();
case 'last': return this.pageHandler.last();
default: return this.pageHandler.goto(value);
}
}
createSearch(scope) {
return new SearchBuilder(this.searchHandler, scope);
}
clearSearch() {
this.searchHandler.clear();
this.event.dispatch('clearSearch');
this.setPage(1);
}
createRecordFilter(records) {
return new RecordFilterBuilder(records);
}
createSort(field, params) {
return new SortBuilder(this.sortHandler, field, params);
}
clearSort() {
this.sortHandler.clear();
}
clearFilters() {
this.filters = [];
this.event.dispatch('clearFilters');
this.setPage(1);
}
createAdvancedFilter(field, check) {
return new AdvancedFilterBuilder(this.filterHandler, field, check);
}
createFilter(field, check) {
return new FilterBuilder(this.filterHandler, field, check);
}
createQuery() {
return new QueryBuilder(this.queryHandler);
}
select(value) {
this.selectHandler.set(value);
}
selectAll(params = {}) {
this.selectScope = (params.scope === 'all') ? 'all' : 'currentPage';
this.selectHandler.all(this.selectScope);
}
getSelectedRows() {
return this.selectHandler.getRows();
}
clearSelection() {
this.selectHandler.clear();
}
on(event, callback) {
this.event.add(event, callback);
}
createCalculation(field) {
return new CalculationBuilder(this, field);
}
createCSV() {
return new CSVBuilder(this);
}
createView(columns) {
this.view = new ViewBuilder(this, columns);
return this.view;
}
getView() {
return this.view;
}
translate(i18n) {
this.i18n = {
...{
search: 'Search...',
show: 'Show',
entries: 'entries',
filter: 'Filter',
rowCount: 'Showing {start} to {end} of {total} entries',
noRows: 'No entries found',
previous: 'Previous',
next: 'Next'
},
...i18n
};
}
}

View File

@@ -0,0 +1,16 @@
import type { Field, Check } from '..';
import type FilterHandler from '../handlers/FilterHandler.svelte';
export default class AdvancedFilterBuilder<Row> {
criteria: unknown[];
private id;
private filterHandler;
private collection;
private field;
private check;
private isRecursive;
constructor(filterHandler: FilterHandler<Row>, field: Field<Row>, check?: Check);
set(value: unknown, check?: Check): void;
isNotRecursive(): this;
clear(): void;
private cleanup;
}

View File

@@ -0,0 +1,42 @@
import { check as comparator } from '../core';
export default class AdvancedFilterBuilder {
criteria = $state([]);
id = Math.random().toString(36).substring(2, 15);
filterHandler;
collection;
field;
check;
isRecursive = true;
constructor(filterHandler, field, check) {
this.filterHandler = filterHandler;
this.field = field;
this.collection = [];
this.check = check ?? comparator.isEqualTo;
this.cleanup();
}
set(value, check) {
if (this.collection.find(criterion => criterion.value === value)) {
this.collection = this.collection.filter(criterion => criterion.value !== value);
}
else {
this.collection = [{ value, check: check ?? this.check }, ...this.collection];
}
if (this.collection.length === 0) {
return this.clear();
}
this.filterHandler.set(this.collection, this.field, comparator.whereIn, this.id, this.isRecursive);
this.criteria = this.collection.map(criterion => criterion.value);
}
isNotRecursive() {
this.isRecursive = false;
return this;
}
clear() {
this.collection = [];
this.criteria = [];
this.filterHandler.unset(this.id);
}
cleanup() {
this.filterHandler['table'].on('clearFilters', () => this.clear());
}
}

View File

@@ -0,0 +1,9 @@
import type { TableHandler } from '..';
export default class CSVBuilder<Row> {
private table;
constructor(table: TableHandler<Row>);
download(filename: string): void;
get(): string;
private getRows;
private getHeader;
}

View File

@@ -0,0 +1,40 @@
export default class CSVBuilder {
table;
constructor(table) {
this.table = table;
}
download(filename) {
const csv = this.get();
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
get() {
const rows = this.getRows();
rows.unshift(this.getHeader().join(','));
return rows.join('\r\n');
}
getRows() {
return this.table.allRows.map(row => {
const entries = Object.entries(row).map(([_, value]) => {
if (value === null)
return '';
if (typeof value === 'number') {
return value;
}
return `"${value}"`;
});
return entries.join(',');
});
}
getHeader() {
const [row] = this.table.allRows;
return Object.entries(row).map(([key, _]) => {
return `"${key}"`;
});
}
}

View File

@@ -0,0 +1,26 @@
import type { Field, TableHandler } from '..';
type Sort = [key: 'value' | 'count', direction: 'asc' | 'desc'];
export default class CalcultationBuilder<Row> {
private callback;
private precision;
private table;
constructor(table: TableHandler<Row>, field: Field<Row>);
distinct(param?: {
sort: Sort;
}): {
value: string;
count: number;
}[];
avg(param?: {
precision: number;
}): number;
sum(param?: {
precision: number;
}): number;
median(param?: {
precision: number;
}): number;
bounds(): number[];
private round;
}
export {};

View File

@@ -0,0 +1,69 @@
import { sort, parse } from '../core';
export default class CalcultationBuilder {
callback;
precision;
table;
constructor(table, field) {
this.table = table;
this.callback = parse(field).callback;
}
distinct(param) {
const values = this.table.allRows.map(row => this.callback(row));
const aggregate = values.reduce((acc, curr) => {
if (Array.isArray(curr)) {
for (const item of curr) {
acc[item] = (acc[item] ?? 0) + 1;
}
return acc;
}
acc[curr] = (acc[curr] ?? 0) + 1;
return acc;
}, {});
const result = Object.entries(aggregate).map(([value, count]) => ({ value, count }));
if (param?.sort) {
const [field, direction] = param.sort;
if (field === 'count') {
result.sort((x, y) => sort.asc(x.value, y.value));
}
result.sort((a, b) => sort[direction](a[field], b[field]));
}
return result;
}
avg(param) {
this.precision = param?.precision ?? 2;
if (this.table.allRows.length === 0)
return 0;
const values = this.table.allRows.map(row => this.callback(row)).filter(Boolean);
return this.round(values.reduce((acc, curr) => acc + curr, 0) / values.length);
}
sum(param) {
this.precision = param?.precision ?? 2;
const values = this.table.allRows.map(row => this.callback(row));
return this.round(values.reduce((acc, curr) => acc + curr, 0));
}
median(param) {
this.precision = param?.precision ?? 2;
const values = [...this.table.allRows.map(row => this.callback(row))]
.sort((a, b) => a - b)
.filter(Boolean);
if (values.length === 0)
return null;
const half = Math.floor(values.length / 2);
return (values.length % 2
? values[half]
: this.round((values[half - 1] + values[half]) / 2));
}
bounds() {
const values = this.table.allRows.map(row => this.callback(row));
const numbers = values.filter(Boolean);
if (numbers.length === 0)
return [null, null];
return [
Math.min(...numbers),
Math.max(...numbers)
];
}
round(value) {
return Number(value.toFixed(this.precision));
}
}

View File

@@ -0,0 +1,17 @@
import type { Field, Check } from '..';
import type FilterHandler from '../handlers/FilterHandler.svelte';
import type { FilterInterface } from '../../shared';
export default class FilterBuilder<Row> implements FilterInterface {
value: unknown;
private id;
private filterHandler;
private field;
private check;
private isRecursive;
constructor(filterHandler: FilterHandler<Row>, field: Field<Row>, check?: Check);
set(check?: Check): void;
init(value?: unknown): this;
isNotRecursive(): this;
clear(): void;
private cleanup;
}

View File

@@ -0,0 +1,34 @@
import { check as comparator } from '../core';
export default class FilterBuilder {
value = $state('');
id = Math.random().toString(36).substring(2, 15);
filterHandler;
field;
check;
isRecursive = true;
constructor(filterHandler, field, check) {
this.filterHandler = filterHandler;
this.field = field;
this.check = check ?? comparator.isLike;
this.cleanup();
}
set(check) {
this.filterHandler.set(this.value, this.field, check ?? this.check, this.id, this.isRecursive);
}
init(value) {
this.value = value;
this.set();
return this;
}
isNotRecursive() {
this.isRecursive = false;
return this;
}
clear() {
this.value = '';
this.filterHandler.unset(this.id);
}
cleanup() {
this.filterHandler['table'].on('clearFilters', () => this.clear());
}
}

View File

@@ -0,0 +1,14 @@
import type QueryHandler from '../handlers/QueryHandler.svelte';
export default class QueryBuilder<Row> {
value: unknown;
private id;
private queryHandler;
private path;
private check;
constructor(queryHandler: QueryHandler<Row>);
from(path: string[]): this;
where(filter: (row: any, value?: unknown) => boolean): this;
set(value?: unknown): void;
clear(): void;
private cleanup;
}

View File

@@ -0,0 +1,31 @@
export default class QueryBuilder {
value = $state('');
id = Math.random().toString(36).substring(2, 15);
queryHandler;
path = [];
check;
constructor(queryHandler) {
this.queryHandler = queryHandler;
this.cleanup();
}
from(path) {
this.path = path;
return this;
}
where(filter) {
this.check = filter;
return this;
}
set(value) {
if (value)
this.value = value;
this.queryHandler.set(this.path, this.value, this.check, this.id);
}
clear() {
this.value = '';
this.queryHandler.unset(this.id);
}
cleanup() {
this.queryHandler['table'].on('clearFilters', () => this.clear());
}
}

View File

@@ -0,0 +1,10 @@
import type { Row } from '..';
export default class RecordFilterBuilder {
value: string;
records: readonly Row[];
private rawRecords;
private filter;
constructor(records: Row[]);
set(): void;
private createRecords;
}

Some files were not shown because too many files have changed in this diff Show More