UNPKG

platina-core

Version:

UI Kit of SberMarketing

348 lines (347 loc) 13.4 kB
import { get, writable } from 'svelte/store'; import { getContext, setContext } from 'svelte'; import { escape } from './helpers'; import { Types } from './types'; import { nanoid } from 'nanoid'; const dateRegex = /^(\d{1,2}[\/\.-]\d{1,2}[\/\.-]\d{4})|(\d{4}[\/\.-]\d{1,2}[\/\.-]\d{1,2})/; export class Table { body = writable(); header = writable(); config = writable(); controls = writable({ sort: { by: null, asc: true }, filter: {}, pinned: [], dataVersion: '' }); #rows = []; #deafultConfig = { format: (value, _type) => { if (_type === 'number' && value !== null && !Number.isNaN(Number(value))) { return value.toString(); } return value; } }; #isBrowser = typeof window !== 'undefined'; constructor() { setContext('controls', this.controls); setContext('rendered', { body: this.body, header: this.header, config: this.config }); setContext('self', this); this.controls.subscribe((controls) => { const config = get(this.config); const header = get(this.header); if (config?.persist && this.#isBrowser) { config.persist.set(controls); } this.body.update((data) => { if (!data) return []; const onSort = controls.sort.by?.onSort ?? config?.onSort ?? Table.sort; const onFilter = config?.onFilter ?? Table.filter; const filtered = onFilter instanceof Promise ? onFilter(this.#rows, controls.filter, header, config)?.then(rows) : onFilter(this.#rows, controls.filter, header, config); if (onSort instanceof Promise) { return onSort(filtered, controls.sort)?.then(data); } else { return onSort(filtered, controls.sort); } }); }); } render({ data, columns, config }) { const rows = Array.isArray(data) ? data : Table.cols2rows(data); const headController = new Head(rows, columns, config); const header = headController.header.map((h, i) => headController.prepared({ ...h, index: i }, rows)); const { pinned, notPinned } = this.splitPin(header); const orderedHeader = pinned.concat(notPinned); const body = rows; this.#rows = rows; this.header.set(orderedHeader); this.body.set(body); this.config.set(Object.assign(this.#deafultConfig, config) ?? this.#deafultConfig); if (config?.persist && this.#isBrowser) { const persistedControls = get(config.persist); if (persistedControls) { const sortKey = persistedControls.sort?.by?.name; const existedFilter = Object.keys(persistedControls.filter).every((key) => orderedHeader.some((h) => h.name === key)); const existedSort = orderedHeader.some((h) => h.name === sortKey); if (!existedSort && !existedFilter) return config?.persist.set(null); this.controls.set(persistedControls); } else { this.controls.set({ sort: { by: null, asc: true }, filter: {}, pinned: [], dataVersion: nanoid() }); } const currentConfig = get(this.config); currentConfig.head.filterable = currentConfig.head.filterable || Boolean(Object.keys(get(this.controls)?.filter).length); } } static cols2rows(cols) { const names = Object.keys(cols); const col = cols[names[0]]; const rows = []; for (const i of col.keys()) { const row = {}; for (const name of names) { row[name] = cols[name][i]; } rows.push(row); } return rows; } static filter(data, filter, header, config) { return data.filter((row) => { if (!Object.keys(filter).length) return true; return Object.entries(filter).every(([key, value]) => { const cell = row?.[key]; if (Array.isArray(value) && value.length > 0) { return value.some((val) => String(cell).includes(String(val))); } else { const head = header.find((h) => h.name.includes(key)); const formated = config.format(cell, head?.type); if (head?.type === 'number') { const cleanCell = String(cell).replace(/\D+/g, ''); const cleanValue = String(value).replace(/\D+/g, ''); return cleanCell.includes(cleanValue); } const regexp = new RegExp(escape(value)); return regexp.test(String(formated)); } }); }); } static sort(data, controls) { const head = controls.by; if (!head) return data; const order = Array.from(Array(data.length).keys()); const direction = controls.asc ? -1 : 1; order.sort((a, b) => { if (head.type === 'number' || head.type === 'boolean') { return (data[a][head.name] - data[b][head.name]) * direction; } else if (head.type === 'string') { return (data[a]?.[head.name] || '')?.localeCompare(data[b]?.[head.name] || '') * direction; } else if (head.type === 'list') { return ((Number(data[a][head.name]?.length) - Number(data[b][head.name]?.length)) * direction); } else if (head.type === 'date') { return (Date.parse(data[a][head.name]) - Date.parse(data[b][head.name])) * direction; } else { return 0; } }); return order.map((i) => data[i]); } static get stores() { return { ...getContext('rendered'), controls: getContext('controls') }; } static get self() { return getContext('self'); } splitPin(header, updateHead) { const pinned = header.filter((h) => h.pinned && (!updateHead || h.name !== updateHead.name)); const notPinned = header.filter((h) => !h.pinned && (!updateHead || h.name !== updateHead.name)); let offsetLeft = 0; for (const [i, head] of pinned.entries()) { if (head.width === undefined) continue; pinned[i].offsetLeft = offsetLeft; offsetLeft += typeof head.width === 'number' ? head.width : parseFloat(head.width); } return { pinned, notPinned, offsetLeft }; } updatePin(updateHead) { this.header.update((header) => { const { pinned, notPinned, offsetLeft } = this.splitPin(header, updateHead); updateHead.offsetLeft = offsetLeft; updateHead.pinned = !updateHead.pinned; if (updateHead.pinned) { return pinned.concat(updateHead, ...notPinned); } // Return head to the same position notPinned.splice(updateHead.index ?? 0, 0, updateHead); return pinned.concat(...notPinned); }); } // updatePinOffsets() { // this.header.update((header) => { // const { pinned, notPinned } = this.splitPin(header); // return pinned.concat(notPinned); // }); // } updatePinnedOffsetsFrom(index) { this.header.update((header) => { let offsetLeft = 0; for (let i = 0; i < header.length; i++) { const head = header[i]; if (!head.pinned) continue; // если колонка левее изменённой пересчитываем offsetLeft if (i <= index) { offsetLeft += Number(head.width); continue; } // колонка правее пересчитываем offsetLeft и изменяем для него offsetLeft head.offsetLeft = offsetLeft; offsetLeft += Number(head.width); } return header; }); } } class Head { data; columns; config; constructor(data, columns, config) { this.data = data; this.columns = columns; this.config = config; } titled(head) { if (!head.title) { head.title = head.name; } return head; } sized(head) { if (typeof head?.width === 'string') return head; const minWidth = head?.minWidth ?? this.config?.head?.minWidth ?? 60; const maxWidth = head?.maxWidth ?? this.config?.head?.maxWidth ?? 1000; head.width = head?.width ?? this.config?.head?.width ?? (head.title?.length ?? head.name?.length) * 12 + 10; head.width = head.width < minWidth ? minWidth : head.width; // set min-width head.width = (head.width ?? 0) > maxWidth ? maxWidth : head.width; // set max-width return head; } typed(head, data) { if (!head.type) { let sample; for (const row of data) { const testSample = row[head.name]; if (testSample !== '' && testSample !== null && testSample !== undefined) { sample = testSample; break; } } if (Array.isArray(sample)) { head.type = Types.List; } else if (typeof sample === 'boolean') { head.type = Types.Boolean; } else if (typeof sample === 'number') { head.type = Types.Number; } else if (!Number.isNaN(Number(sample))) { //TODO check this head.type = Types.Number; } else if ( // @ts-expect-error handle invalid date new Date(sample) !== 'Invalid Date' && !isNaN(new Date(sample).getTime()) && dateRegex.test(sample)) { head.type = Types.Date; } else if (typeof sample === 'object') { head.type = Types.Object; } else { head.type = Types.String; } } return head; } prepared(head, data) { return this.typed(this.sized(this.titled(head)), data); } get header() { if (!this.columns) { // handle undefined. Use first row of data as head const row = this.data[0]; return Object.keys(row).map((c) => { return { name: c }; }); } if (Array.isArray(this.columns) && this.columns.length) { // Handle string[] and Head[] const sample = this.columns[0]; if (typeof sample === 'string') { // Handle string[] type return this.columns.map((c) => { return { name: c }; }); } else { // We have Head[], just check if it's valid ; this.columns.map((h) => { if (!h.name) { throw TypeError('columns prop: name is required'); } }); return this.columns; } } if (typeof this.columns === 'object') { return Object.entries(this.columns).map(([k, v]) => { if (typeof v === 'string') { // Handle Record<string, string>. Expected that value is a title name:title return { name: k, title: v }; } else if (v?.title) { // Handle Record<string, Head> where string is name:{other} return { name: k, ...v }; } else { throw TypeError("columns property: can't process variant with Record<string, Head> interface. Values must be { name: {title: string, width?: string | number} };"); } }); } throw TypeError("columns property: can't identify and process format. It must satisfy one of variants string[], Head[], {name: title} or {name: Head}};"); } } export class Cell { static size2css(width) { if (typeof width === 'number') return `${width}px`; if (typeof width === 'string') return width; throw Error(`Unprocessable format of cell width: ${width}`); } } export function getTypeFromString(string) { if (!isNaN(parseFloat(string)) && isFinite(Number(string))) { return Types.Number; } const date = new Date(string); if (!isNaN(date.getTime())) { return Types.Date; } return Types.String; }