diff --git a/eslint.config.mjs b/eslint.config.mjs index 3dc58c8f99..42c4d7d70c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ import yocoPackageJSON from './packages/yoco/package.json' assert { type: 'json' const trustedDependencies = new Set([ '@metatypes/typography', '@metatypes/units', + '@tanstack/table-core', 'filesize', 'zod', ]) diff --git a/packages/documentation/components/CodePreview.vue b/packages/documentation/components/CodePreviewLegacy.vue similarity index 97% rename from packages/documentation/components/CodePreview.vue rename to packages/documentation/components/CodePreviewLegacy.vue index 6400c5b0cf..687a0f3f39 100644 --- a/packages/documentation/components/CodePreview.vue +++ b/packages/documentation/components/CodePreviewLegacy.vue @@ -14,7 +14,7 @@ import { computed, defineComponent, ref } from 'vue' export default defineComponent({ - name: 'CodePreview', + name: 'CodePreviewLegacy', props: { vueSlotLabel: { default: 'Kotti-UI', type: String }, styleSlotLabel: { default: 'Kotti-Style', type: String }, diff --git a/packages/documentation/data/menu.ts b/packages/documentation/data/menu.ts index 999afe9dc0..cd0cdc6e9d 100644 --- a/packages/documentation/data/menu.ts +++ b/packages/documentation/data/menu.ts @@ -22,6 +22,8 @@ import { KtNavbar, KtPagination, KtPopover, + KtStandardTable, + KtTable, KtTableLegacy, KtTag, KtToaster, @@ -173,6 +175,8 @@ export const menu: Array
= [ makeComponentMenuItem(KtModal), makeComponentMenuItem(KtPagination), makeComponentMenuItem(KtPopover), + makeComponentMenuItem(KtStandardTable), + makeComponentMenuItem(KtTable), makeComponentMenuItem(KtTableLegacy), makeComponentMenuItem(KtTag), makeComponentMenuItem(KtToaster), diff --git a/packages/documentation/pages/usage/components/data/standard-table.ts b/packages/documentation/pages/usage/components/data/standard-table.ts new file mode 100644 index 0000000000..edfaa47512 --- /dev/null +++ b/packages/documentation/pages/usage/components/data/standard-table.ts @@ -0,0 +1,162 @@ +export const todos = [ + { + completed: true, + dueDate: '2025-01-20', + effort: 2, + id: 1, + priority: 'Medium', + tag: 'tag-1', + todo: 'Watch a classic movie', + userId: 68, + }, + { + completed: false, + dueDate: '2025-02-20', + effort: 8, + id: 2, + priority: 'Medium', + tag: 'tag-1', + todo: 'Contribute code or a monetary donation to an open-source software project', + userId: 69, + }, + { + completed: false, + dueDate: '2025-01-10', + effort: 0.5, + id: 3, + priority: 'Medium', + tag: 'tag-3', + todo: 'Invite some friends over for a game night', + userId: 104, + }, + { + completed: true, + dueDate: '2025-03-20', + effort: 0.5, + id: 4, + priority: 'Medium', + tag: 'tag-2', + todo: "Text a friend you haven't talked to in a long time", + userId: 2, + }, + { + completed: true, + dueDate: '2025-01-20', + effort: 2, + id: 5, + priority: 'High', + tag: 'tag-2', + todo: "Plan a vacation you've always wanted to take", + userId: 162, + }, + { + completed: false, + dueDate: '2025-04-20', + effort: 1, + id: 6, + priority: 'Low', + tag: 'tag-1', + todo: 'Clean out car', + userId: 71, + }, + { + completed: true, + dueDate: '2025-01-01', + effort: 2, + id: 7, + priority: 'Low', + tag: 'tag-1', + todo: 'Create a cookbook with favorite recipes', + userId: 53, + }, + { + completed: false, + dueDate: '2025-02-28', + effort: 4, + id: 8, + priority: 'Medium', + tag: 'tag-1', + todo: 'Create a compost pile', + userId: 13, + }, + { + completed: true, + dueDate: '2025-02-01', + effort: 1, + id: 9, + priority: 'Medium', + tag: 'tag-1', + todo: 'Take a hike at a local park', + userId: 37, + }, + { + completed: true, + dueDate: '2025-03-20', + effort: 4, + id: 10, + priority: 'Medium', + tag: 'tag-2', + todo: 'Take a class at local community center that interests you', + userId: 65, + }, + { + completed: true, + dueDate: '2025-10-20', + effort: 2, + id: 11, + priority: 'Low', + tag: 'tag-3', + todo: 'Research a topic interested in', + userId: 130, + }, + { + completed: false, + dueDate: '2025-03-01', + effort: 4, + id: 12, + priority: 'High', + tag: 'tag-1', + todo: 'Plan a trip to another country', + userId: 140, + }, + { + completed: false, + dueDate: '2025-06-01', + effort: 40, + id: 13, + priority: 'High', + tag: 'tag-2', + todo: 'Improve touch typing', + userId: 178, + }, + { + completed: false, + dueDate: '2025-11-20', + effort: 30, + id: 14, + priority: 'Low', + tag: 'tag-1', + todo: 'Learn Express.js', + userId: 194, + }, + { + completed: false, + dueDate: '2025-12-20', + effort: 20, + id: 15, + priority: 'Low', + tag: 'tag-3', + todo: 'Learn calligraphy', + userId: 80, + }, + { + completed: true, + dueDate: '2025-01-10', + effort: 10, + id: 16, + priority: 'Medium', + tag: 'tag-1', + todo: 'Go to the gym', + userId: 142, + }, +] diff --git a/packages/documentation/pages/usage/components/drawer.vue b/packages/documentation/pages/usage/components/drawer.vue index 03a003d172..d5d2f95259 100644 --- a/packages/documentation/pages/usage/components/drawer.vue +++ b/packages/documentation/pages/usage/components/drawer.vue @@ -74,7 +74,7 @@ You can also customize the width of drawer by setting both `defaultWidth` and `e When the `disallowCloseOutside` flag is set, it prevents the user from accidentally closing the drawer by clicking outside of the drawer. - +
@@ -124,7 +124,7 @@ When the `disallowCloseOutside` flag is set, it prevents the user from accidenta
-
+ ## Usage @@ -143,14 +143,14 @@ import { defineComponent } from 'vue' import { KtDrawer } from '@3yourmind/kotti-ui' -import CodePreview from '~/components/CodePreview.vue' +import CodePreviewLegacy from '~/components/CodePreviewLegacy.vue' import ComponentInfo from '~/components/ComponentInfo.vue' export default defineComponent({ name: 'DocumentationPageUsageComponentsDrawer', components: { ComponentInfo, - CodePreview, + CodePreviewLegacy, }, data() { return { diff --git a/packages/documentation/pages/usage/components/standard-table.vue b/packages/documentation/pages/usage/components/standard-table.vue new file mode 100644 index 0000000000..57fb5213ce --- /dev/null +++ b/packages/documentation/pages/usage/components/standard-table.vue @@ -0,0 +1,607 @@ + + + + + + + diff --git a/packages/documentation/styles/tables.scss b/packages/documentation/styles/tables.scss index dc5a3e15dd..d0eed067d8 100644 --- a/packages/documentation/styles/tables.scss +++ b/packages/documentation/styles/tables.scss @@ -1,6 +1,6 @@ @import '@3yourmind/kotti-ui/source/kotti-style/_variables.scss'; -table:not(.kt-table-legacy) { +*:not(.kt-table) > table:not(.kt-table-legacy) { width: 100%; hyphens: auto; table-layout: auto; diff --git a/packages/eslint-config/source/index.ts b/packages/eslint-config/source/index.ts index 356ec344d5..3343c6f4c1 100644 --- a/packages/eslint-config/source/index.ts +++ b/packages/eslint-config/source/index.ts @@ -140,11 +140,15 @@ const baseConfig = tseslint.config({ radix: 'error', 'valid-typeof': 'error', + // prettier + 'prettier/prettier': 'warn', + // SonarJS 'sonarjs/no-collapsible-if': 'off', // replaced by unicorn/no-lonely-if 'sonarjs/no-duplicate-string': 'off', 'sonarjs/no-redundant-jump': 'off', 'sonarjs/no-small-switch': 'off', + // Unicorn 'unicorn/catch-error-name': 'warn', 'unicorn/consistent-destructuring': 'warn', @@ -332,14 +336,14 @@ export default { rules: { '@eslint-community/eslint-comments/no-duplicate-disable': 'off', 'perfectionist/sort-array-includes': [ - 'error', + 'warn', { partitionByComment: true }, ], - 'perfectionist/sort-classes': ['error', { partitionByComment: true }], - 'perfectionist/sort-enums': ['error', { partitionByComment: true }], - 'perfectionist/sort-exports': ['error', { partitionByComment: true }], + 'perfectionist/sort-classes': ['warn', { partitionByComment: true }], + 'perfectionist/sort-enums': ['warn', { partitionByComment: true }], + 'perfectionist/sort-exports': ['warn', { partitionByComment: true }], 'perfectionist/sort-imports': [ - 'error', + 'warn', { customGroups: { type: { @@ -364,12 +368,9 @@ export default { type: 'natural', }, ], - 'perfectionist/sort-interfaces': [ - 'error', - { partitionByComment: true }, - ], + 'perfectionist/sort-interfaces': ['warn', { partitionByComment: true }], 'perfectionist/sort-intersection-types': [ - 'error', + 'warn', { groups: ['named', 'object', 'function', 'unknown', 'nullish'], order: 'asc', @@ -377,29 +378,29 @@ export default { type: 'natural', }, ], - 'perfectionist/sort-maps': ['error', { partitionByComment: true }], + 'perfectionist/sort-maps': ['warn', { partitionByComment: true }], 'perfectionist/sort-named-exports': [ - 'error', + 'warn', { partitionByComment: true }, ], 'perfectionist/sort-named-imports': [ - 'error', + 'warn', { partitionByComment: true }, ], 'perfectionist/sort-object-types': [ - 'error', + 'warn', { partitionByComment: true }, ], 'perfectionist/sort-objects': [ - 'error', + 'warn', { ignorePattern: ['defineComponent'], partitionByComment: true, }, ], - 'perfectionist/sort-sets': ['error', { partitionByComment: true }], + 'perfectionist/sort-sets': ['warn', { partitionByComment: true }], 'perfectionist/sort-union-types': [ - 'error', + 'warn', { groups: ['named', 'object', 'function', 'unknown', 'nullish'], order: 'asc', @@ -408,7 +409,7 @@ export default { }, ], 'perfectionist/sort-variable-declarations': [ - 'error', + 'warn', { partitionByComment: true }, ], 'perfectionist/sort-vue-attributes': 'off', diff --git a/packages/kotti-ui/package.json b/packages/kotti-ui/package.json index a28b4b60ae..b791c190b7 100644 --- a/packages/kotti-ui/package.json +++ b/packages/kotti-ui/package.json @@ -8,6 +8,7 @@ "@3yourmind/yoco": "^2.7.0", "@metatypes/typography": "^0.5.0", "@metatypes/units": "^0.5.0", + "@tanstack/table-core": "^8.20.5", "big.js": "^6.2.1", "core-js": "3.6.5", "dayjs": "1.x", diff --git a/packages/kotti-ui/source/constants.ts b/packages/kotti-ui/source/constants.ts index ed8ed0f52b..86c90b4ac0 100644 --- a/packages/kotti-ui/source/constants.ts +++ b/packages/kotti-ui/source/constants.ts @@ -1,3 +1,6 @@ +export const DEFAULT_DEBOUNCE = 500 + +export const ISO8601 = 'YYYY-MM-DD' export const ISO8601_SECONDS = 'YYYY-MM-DD HH:mm:ss' export const ONE_HUNDRED_PERCENT = 100 diff --git a/packages/kotti-ui/source/globals.d.ts b/packages/kotti-ui/source/globals.d.ts index f7a099d53d..39cb270be4 100644 --- a/packages/kotti-ui/source/globals.d.ts +++ b/packages/kotti-ui/source/globals.d.ts @@ -1,5 +1,7 @@ import type { z } from 'zod' +import '@tanstack/table-core' + declare global { interface Window { /** @@ -9,3 +11,19 @@ declare global { lastZodSchema?: z.ZodTypeAny } } + +declare module '@tanstack/table-core' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + cellClasses: Record | string + headerClasses: Record | string + type: + | 'boolean' + | 'custom' + | 'date' + | 'datetime' + | 'integer' + | 'numerical' + | 'text' + } +} diff --git a/packages/kotti-ui/source/index.ts b/packages/kotti-ui/source/index.ts index d097100df0..2d4f44a960 100644 --- a/packages/kotti-ui/source/index.ts +++ b/packages/kotti-ui/source/index.ts @@ -91,6 +91,8 @@ import { KtPopover } from './kotti-popover' export * from './kotti-popover' import { KtRow } from './kotti-row' export * from './kotti-row' +import { KtStandardTable, KtTable } from './kotti-table' +export * from './kotti-table' import { KtTableLegacy, KtTableLegacyColumn, @@ -162,7 +164,9 @@ export default { KtPagination, KtPopover, KtRow, + KtStandardTable, KtSplitButton, + KtTable, KtTableLegacy, KtTableLegacyColumn, KtTableLegacyConsumer, diff --git a/packages/kotti-ui/source/kotti-field-currency/KtFieldCurrency.vue b/packages/kotti-ui/source/kotti-field-currency/KtFieldCurrency.vue index 7dc2814241..54ff1ebe56 100644 --- a/packages/kotti-ui/source/kotti-field-currency/KtFieldCurrency.vue +++ b/packages/kotti-ui/source/kotti-field-currency/KtFieldCurrency.vue @@ -16,7 +16,7 @@ import { useField, useForceUpdate } from '../kotti-field/hooks' import { useI18nContext } from '../kotti-i18n/hooks' import type { KottiI18n } from '../kotti-i18n/types' import { makeProps } from '../make-props' -import { DecimalSeparator } from '../types/kotti' +import { DecimalSeparator } from '../types/decimal-separator' import { isNumberInRange } from '../utilities' import { KOTTI_FIELD_CURRENCY_SUPPORTS, VALID_REGEX } from './constants' diff --git a/packages/kotti-ui/source/kotti-field-currency/utilities.test.ts b/packages/kotti-ui/source/kotti-field-currency/utilities.test.ts index 5e1f5c70dd..d6adfa142b 100644 --- a/packages/kotti-ui/source/kotti-field-currency/utilities.test.ts +++ b/packages/kotti-ui/source/kotti-field-currency/utilities.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DecimalSeparator } from '../types/kotti' +import { DecimalSeparator } from '../types/decimal-separator' import { VALID_REGEX } from './constants' import { toFixedPrecisionString } from './utilities' diff --git a/packages/kotti-ui/source/kotti-field-currency/utilities.ts b/packages/kotti-ui/source/kotti-field-currency/utilities.ts index 60c6266655..dc10d67853 100644 --- a/packages/kotti-ui/source/kotti-field-currency/utilities.ts +++ b/packages/kotti-ui/source/kotti-field-currency/utilities.ts @@ -1,6 +1,6 @@ import Big from 'big.js' -import type { DecimalSeparator } from '../types/kotti' +import type { DecimalSeparator } from '../types/decimal-separator' import { DECIMAL_SEPARATORS_CHARACTER_SET } from '../utilities' export const toNumber = ( diff --git a/packages/kotti-ui/source/kotti-field-number/constants.ts b/packages/kotti-ui/source/kotti-field-number/constants.ts index c764ce60c5..a86b090153 100644 --- a/packages/kotti-ui/source/kotti-field-number/constants.ts +++ b/packages/kotti-ui/source/kotti-field-number/constants.ts @@ -1,5 +1,5 @@ import type { KottiField } from '../kotti-field/types' -import { DecimalSeparator } from '../types/kotti' +import { DecimalSeparator } from '../types/decimal-separator' import { DECIMAL_SEPARATORS_CHARACTER_SET } from '../utilities' export const KOTTI_FIELD_NUMBER_SUPPORTS: KottiField.Supports = { diff --git a/packages/kotti-ui/source/kotti-field-number/utilities.ts b/packages/kotti-ui/source/kotti-field-number/utilities.ts index 61eb49352e..6f63bad2e0 100644 --- a/packages/kotti-ui/source/kotti-field-number/utilities.ts +++ b/packages/kotti-ui/source/kotti-field-number/utilities.ts @@ -1,6 +1,6 @@ import Big from 'big.js' -import type { DecimalSeparator } from '../types/kotti' +import type { DecimalSeparator } from '../types/decimal-separator' import { DECIMAL_SEPARATORS_CHARACTER_SET } from '../utilities' import { diff --git a/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggle.vue b/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggle.vue index 88077a213c..1db06beaf6 100644 --- a/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggle.vue +++ b/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggle.vue @@ -53,8 +53,8 @@ import FieldHelpText from '../kotti-field/components/FieldHelpText.vue' import { useField, useForceUpdate } from '../kotti-field/hooks' import { useTranslationNamespace } from '../kotti-i18n/hooks' import { makeProps } from '../make-props' +import ToggleInner from '../shared-components/toggle-inner/ToggleInner.vue' -import ToggleInner from './components/ToggleInner.vue' import { KOTTI_FIELD_TOGGLE_SUPPORTS } from './constants' import { KottiFieldToggle } from './types' @@ -138,5 +138,27 @@ export default defineComponent({ display: flex; align-items: center; } + + .kt-field-toggle-inner__svg { + flex-shrink: 0; + } + + .kt-field-toggle-inner__svg--is-box { + // align checkbox with the center of the first line of the label + // (assumption: font-size comes from common parent element) + // > starting point is upper end of the container (flex-start) + // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) + // > (-8px) Put it up half the height of the checkbox height (16px) + transform: translateY(calc(0.75em - 8px)); + } + + .kt-field-toggle-inner__svg--is-switch { + // align switch with the center of the first line of the label + // (assumption: font-size comes from common parent element) + // > starting point is upper end of the container (flex-start) + // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) + // > (-10px) Put it up half the height of the switch height (20px) + transform: translateY(calc(0.75em - 10px)); + } } diff --git a/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggleGroup.vue b/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggleGroup.vue index 89c08081fe..6d84f19314 100644 --- a/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggleGroup.vue +++ b/packages/kotti-ui/source/kotti-field-toggle/KtFieldToggleGroup.vue @@ -41,8 +41,8 @@ import { KtField } from '../kotti-field' import FieldHelpText from '../kotti-field/components/FieldHelpText.vue' import { useField, useForceUpdate } from '../kotti-field/hooks' import { makeProps } from '../make-props' +import ToggleInner from '../shared-components/toggle-inner/ToggleInner.vue' -import ToggleInner from './components/ToggleInner.vue' import { KOTTI_FIELD_TOGGLE_SUPPORTS } from './constants' import { KottiFieldToggleGroup } from './types' @@ -151,5 +151,27 @@ export default defineComponent({ &__content { font-size: var(--font-size-small); } + + .kt-field-toggle-inner__svg { + flex-shrink: 0; + } + + .kt-field-toggle-inner__svg--is-box { + // align checkbox with the center of the first line of the label + // (assumption: font-size comes from common parent element) + // > starting point is upper end of the container (flex-start) + // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) + // > (-8px) Put it up half the height of the checkbox height (16px) + transform: translateY(calc(0.75em - 8px)); + } + + .kt-field-toggle-inner__svg--is-switch { + // align switch with the center of the first line of the label + // (assumption: font-size comes from common parent element) + // > starting point is upper end of the container (flex-start) + // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) + // > (-10px) Put it up half the height of the switch height (20px) + transform: translateY(calc(0.75em - 10px)); + } } diff --git a/packages/kotti-ui/source/kotti-i18n/hooks.ts b/packages/kotti-ui/source/kotti-i18n/hooks.ts index 34ab4bc720..c27ab864cc 100644 --- a/packages/kotti-ui/source/kotti-i18n/hooks.ts +++ b/packages/kotti-ui/source/kotti-i18n/hooks.ts @@ -2,7 +2,7 @@ import elementLocale from 'element-ui/lib/locale/index.js' import type { Ref, UnwrapRef } from 'vue' import { computed, inject, provide, reactive, watch } from 'vue' -import { DecimalSeparator } from '../types/kotti' +import { DecimalSeparator } from '../types/decimal-separator' import { KT_I18N_CONTEXT } from './constants' import { deDE } from './locales/de-DE' diff --git a/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts b/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts index b81b6c7767..f701ef35fe 100644 --- a/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts +++ b/packages/kotti-ui/source/kotti-i18n/locales/de-DE.ts @@ -137,6 +137,26 @@ export const deDE: KottiI18n.Messages = { menuExpand: 'Menü einblenden', quickLinksTitle: 'Schnellzugriff', }, + KtStandardTable: { + clearAll: 'Clear All', + editColumns: 'Edit Columns', + editFilters: 'Edit Filters', + endDate: 'End', + lastMonth: 'Last Month', + lastWeek: 'Last Week', + lastYear: 'Last Year', + max: 'Max.', + min: 'Min.', + moreThan: 'More than', + resultsCounter: + 'No items | {range} of {total} item | {range} of {total} items', + rowsPerPage: 'Rows per page', + search: 'Search', + showAll: 'Show All', + startDate: 'Start', + today: 'Today', + upTo: 'Up to', + }, KtValueLabel: { notSet: 'Nicht festgelegt', }, diff --git a/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts b/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts index ff4a75aece..a866fb9d96 100644 --- a/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts +++ b/packages/kotti-ui/source/kotti-i18n/locales/en-US.ts @@ -137,6 +137,26 @@ export const enUS: KottiI18n.Messages = { menuExpand: 'Expand menu', quickLinksTitle: 'Quick Links', }, + KtStandardTable: { + clearAll: 'Clear All', + editColumns: 'Edit Columns', + editFilters: 'Edit Filters', + endDate: 'End', + lastMonth: 'Last Month', + lastWeek: 'Last Week', + lastYear: 'Last Year', + max: 'Max.', + min: 'Min.', + moreThan: 'More than', + resultsCounter: + 'No items | {range} of {total} item | {range} of {total} items', + rowsPerPage: 'Rows per page', + search: 'Search', + showAll: 'Show All', + startDate: 'Start', + today: 'Today', + upTo: 'Up to', + }, KtValueLabel: { notSet: 'Not Set', }, diff --git a/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts b/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts index 753b3e78d7..16999a8ef1 100644 --- a/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts +++ b/packages/kotti-ui/source/kotti-i18n/locales/es-ES.ts @@ -137,6 +137,26 @@ export const esES: KottiI18n.Messages = { menuExpand: 'Expandir el menú', quickLinksTitle: 'Enlaces rápidos', }, + KtStandardTable: { + clearAll: 'Despejar todo', + editColumns: 'Editar columnas', + editFilters: 'Editar filtros', + endDate: 'Finalización', + lastMonth: 'Mes pasado', + lastWeek: 'Semana pasada', + lastYear: 'Año pasado', + max: 'Máx.', + min: 'Mín.', + moreThan: 'Más de', + resultsCounter: + 'No hay artículos | {range} de {total} artículo | {range} de {total} artículos', + rowsPerPage: 'Filas por página', + search: 'Buscar', + showAll: 'Mostrar todo', + startDate: 'Inicio', + today: 'Hoy', + upTo: 'Hasta', + }, KtValueLabel: { notSet: 'No Establecido', }, diff --git a/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts b/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts index 63ce56d7d6..2f05955387 100644 --- a/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts +++ b/packages/kotti-ui/source/kotti-i18n/locales/fr-FR.ts @@ -137,6 +137,26 @@ export const frFR: KottiI18n.Messages = { menuExpand: 'Étendre le menu', quickLinksTitle: 'Liens Rapides', }, + KtStandardTable: { + clearAll: 'Clear All', + editColumns: 'Edit Columns', + editFilters: 'Edit Filters', + endDate: 'End', + lastMonth: 'Last Month', + lastWeek: 'Last Week', + lastYear: 'Last Year', + max: 'Max.', + min: 'Min.', + moreThan: 'More than', + resultsCounter: + 'No items | {range} of {total} item | {range} of {total} items', + rowsPerPage: 'Rows per page', + search: 'Search', + showAll: 'Show All', + startDate: 'Start', + today: 'Today', + upTo: 'Up to', + }, KtValueLabel: { notSet: 'Non définie', }, diff --git a/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts b/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts index 8233d708a9..bc7e8dd8cf 100644 --- a/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts +++ b/packages/kotti-ui/source/kotti-i18n/locales/ja-JP.ts @@ -137,6 +137,26 @@ export const jaJP: KottiI18n.Messages = { menuExpand: '拡張メニュー', quickLinksTitle: 'クイックリンク', }, + KtStandardTable: { + clearAll: 'Clear All', + editColumns: 'Edit Columns', + editFilters: 'Edit Filters', + endDate: 'End', + lastMonth: 'Last Month', + lastWeek: 'Last Week', + lastYear: 'Last Year', + max: 'Max.', + min: 'Min.', + moreThan: 'More than', + resultsCounter: + 'No items | {range} of {total} item | {range} of {total} items', + rowsPerPage: 'Rows per page', + search: 'Search', + showAll: 'Show All', + startDate: 'Start', + today: 'Today', + upTo: 'Up to', + }, KtValueLabel: { notSet: '未設定', }, diff --git a/packages/kotti-ui/source/kotti-i18n/types.ts b/packages/kotti-ui/source/kotti-i18n/types.ts index 41a9dfea79..e0bb968bbf 100644 --- a/packages/kotti-ui/source/kotti-i18n/types.ts +++ b/packages/kotti-ui/source/kotti-i18n/types.ts @@ -1,4 +1,5 @@ import type { Ref } from 'vue' +import { z } from 'zod' import type { KottiBanner } from '../kotti-banner/types' import type { KottiComment } from '../kotti-comment/types' @@ -9,8 +10,9 @@ import type { Shared as KottiFieldSelectShared } from '../kotti-field-select/typ import type { KottiFilters } from '../kotti-filters/types' import type { KottiFormSubmit } from '../kotti-form-submit/types' import type { KottiNavbar } from '../kotti-navbar/types' +import type { KottiStandardTable } from '../kotti-table/standard-table/types' import type { KottiValueLabel } from '../kotti-value-label/types' -import type { DecimalSeparator } from '../types/kotti' +import { DecimalSeparator } from '../types/decimal-separator' export type DeepPartial = T extends Record ? { [K in keyof T]?: DeepPartial } : T @@ -28,9 +30,12 @@ export module KottiI18n { { decimalPlaces: number; symbol: string } > - export type NumberFormat = { - decimalSeparator: DecimalSeparator - } + export const numberFormatSchema = z + .object({ + decimalSeparator: z.nativeEnum(DecimalSeparator), + }) + .strict() + export type NumberFormat = z.output export type Messages = { KtBanner: KottiBanner.Translations @@ -42,6 +47,7 @@ export module KottiI18n { KtFilters: KottiFilters.Translations KtFormSubmit: KottiFormSubmit.Translations KtNavbar: KottiNavbar.Translations + KtStandardTable: KottiStandardTable.Translations KtValueLabel: KottiValueLabel.Translations } diff --git a/packages/kotti-ui/source/kotti-table/KtStandardTable.vue b/packages/kotti-ui/source/kotti-table/KtStandardTable.vue new file mode 100644 index 0000000000..a25b324ff5 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/KtStandardTable.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/KtTable.vue b/packages/kotti-ui/source/kotti-table/KtTable.vue new file mode 100644 index 0000000000..97508019e2 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/KtTable.vue @@ -0,0 +1,602 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/index.ts b/packages/kotti-ui/source/kotti-table/index.ts new file mode 100644 index 0000000000..dcbce3e8f0 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/index.ts @@ -0,0 +1,91 @@ +import type { Kotti } from '../types' +import { MetaDesignType } from '../types/kotti' +import { attachMeta, makeInstallable } from '../utilities' + +import KtStandardTableVue from './KtStandardTable.vue' +import KtTableVue from './KtTable.vue' +import { useKottiStandardTable as _useKottiStandardTable } from './standard-table/hooks' +import { KottiStandardTable } from './standard-table/types' +import { useKottiTable as _useKottiTable } from './table/hooks' +import { KottiTable } from './table/types' + +const TABLE_META: Kotti.Meta = { + addedVersion: '7.4.0', + deprecated: null, + designs: { + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/file/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=128%3A0', + }, + slots: { + // actions: { description: 'E.g. edit/delete row actions', scope: null }, + // default: { + // description: + // 'Could contain columns. Can be used together with columns prop...', + // scope: null, + // }, + // empty: { + // description: + // 'Alternative to emptyText prop. Shown when the Table is empty', + // scope: null, + // }, + // expand: { + // description: 'Per column, allows showing more info on-demand', + // scope: { + // row: { description: null, type: 'object' }, + // rowIndex: { description: null, type: 'integer' }, + // }, + // }, + // header: { + // description: 'Customizes column header', + // scope: {}, // FIXME: This is missing + // }, + // loading: { + // description: 'Alternative to loadingText prop. Shown when loading', + // scope: null, + // }, + }, + typeScript: { + namespace: 'Kotti.Table', + schema: KottiTable.propsSchema, + }, +} + +const STANDARD_META: Kotti.Meta = { + addedVersion: '7.4.0', + deprecated: null, + designs: { + type: MetaDesignType.FIGMA, + url: 'https://www.figma.com/design/0yFVivSWXgFf2ddEF92zkf/Kotti-Design-System?node-id=6305-10646&node-type=canvas&t=8lzEM5nlkrh8aUMF-0', + }, + slots: { + 'controls-actions': { + description: 'slot next to the table controls section', + scope: null, + }, + 'header-actions': { + description: 'slot next to the table title', + scope: null, + }, + 'info-actions': { + description: 'slot next to the applied filters section', + scope: null, + }, + table: { + description: 'slot to show custom content instead of the KtTable', + scope: null, + }, + }, + typeScript: { + namespace: 'Kotti.StandardTable', + schema: KottiStandardTable.propsSchema, + }, +} + +export const KtTable = attachMeta(makeInstallable(KtTableVue), TABLE_META) +export const useKottiTable = _useKottiTable + +export const KtStandardTable = attachMeta( + makeInstallable(KtStandardTableVue), + STANDARD_META, +) +export const useKottiStandardTable = _useKottiStandardTable diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/Columns.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/Columns.vue new file mode 100644 index 0000000000..823cc420b4 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/Columns.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/FilterList.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/FilterList.vue new file mode 100644 index 0000000000..d23aa5b79e --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/FilterList.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/Filters.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/Filters.vue new file mode 100644 index 0000000000..fface0380a --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/Filters.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/PageSize.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/PageSize.vue new file mode 100644 index 0000000000..857d3e6869 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/PageSize.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/Pagination.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/Pagination.vue new file mode 100644 index 0000000000..85b93a5798 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/Pagination.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/Search.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/Search.vue new file mode 100644 index 0000000000..596409cbae --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/Search.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/filters/Boolean.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/Boolean.vue new file mode 100644 index 0000000000..baee67b188 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/Boolean.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/filters/DateRange.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/DateRange.vue new file mode 100644 index 0000000000..68d536844f --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/DateRange.vue @@ -0,0 +1,59 @@ + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/filters/MultiSelect.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/MultiSelect.vue new file mode 100644 index 0000000000..272eddc252 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/MultiSelect.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/filters/NumberRange.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/NumberRange.vue new file mode 100644 index 0000000000..ac0e5aa3b1 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/NumberRange.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/components/filters/SingleSelect.vue b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/SingleSelect.vue new file mode 100644 index 0000000000..845f5b0195 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/components/filters/SingleSelect.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/kotti-ui/source/kotti-table/standard-table/constants.ts b/packages/kotti-ui/source/kotti-table/standard-table/constants.ts new file mode 100644 index 0000000000..129ee5ea81 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PAGE_SIZE = 10 +// eslint-disable-next-line no-magic-numbers +export const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] +export const MIN_PAGE_SIZE = 5 diff --git a/packages/kotti-ui/source/kotti-table/standard-table/context.ts b/packages/kotti-ui/source/kotti-table/standard-table/context.ts new file mode 100644 index 0000000000..4c686adeef --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/context.ts @@ -0,0 +1,55 @@ +import { inject, provide, type Ref } from 'vue' + +import type { KottiFieldText } from '../../kotti-field-text/types' +import type { KottiTable } from '../table/types' +import type { AnyRow } from '../table/types' + +import type { KottiStandardTable } from './types' + +export type StandardTableContext< + ROW extends AnyRow, + COLUMN_IDS extends string = string, +> = Ref<{ + internal: { + columns: KottiTable.Column[] + filters: KottiStandardTable.FilterInternal[] + getAppliedFilters: () => KottiStandardTable.AppliedFilter[] + getFilter: ( + id: KottiStandardTable.FilterInternal['id'], + ) => KottiStandardTable.FilterInternal | null + getSearchValue: () => KottiFieldText.Value + isLoading: boolean + options?: KottiStandardTable.Options + pageSizeOptions: number[] + paginationType: KottiStandardTable.PaginationType + setAppliedFilters: (value: KottiStandardTable.AppliedFilter[]) => void + setSearchValue: (value: KottiFieldText.Value) => void + } +}> + +const getStandardTableContextKey = (id: string): string => + `kt-standard-table-${id}` + +export const useProvideStandardTableContext = ( + id: string, + standardTableContext: StandardTableContext, +): void => { + provide>( + getStandardTableContextKey(id), + standardTableContext, + ) +} + +export const useStandardTableContext = ( + id: string, +): StandardTableContext => { + const context = inject>( + getStandardTableContextKey(id), + ) + + if (!context) { + throw new Error(`KtStandardTable: could not find context for “${id}”`) + } + + return context +} diff --git a/packages/kotti-ui/source/kotti-table/standard-table/hooks.ts b/packages/kotti-ui/source/kotti-table/standard-table/hooks.ts new file mode 100644 index 0000000000..f1f5d4d596 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/hooks.ts @@ -0,0 +1,134 @@ +import type { ColumnFiltersState } from '@tanstack/table-core' +import type { Ref, UnwrapRef } from 'vue' +import { computed, ref, watch } from 'vue' +import { z } from 'zod' + +import type { KottiFieldText } from '../../kotti-field-text/types' +import type { KottiTableParameter } from '../table/hooks' +import { + paramsSchema as KottiTableHookParamsSchema, + useKottiTable, +} from '../table/hooks' +import type { AnyRow } from '../table/types' + +import type { StandardTableContext } from './context' +import { useProvideStandardTableContext } from './context' +import { KottiStandardTable } from './types' + +type KottiStandardTableParameters< + ROW extends AnyRow, + COLUMN_IDS extends string = string, +> = Ref<{ + filters?: KottiStandardTable.Filter[] + id: string + isLoading?: boolean + options?: KottiStandardTable.Options + pagination: KottiStandardTable.Pagination + table: Omit< + UnwrapRef>, + 'id' | 'pagination' + > +}> + +const paramsSchema = z.object({ + filters: KottiStandardTable.filterSchema.array().default(() => []), + id: z.string(), + isLoading: z.boolean().default(false), + options: KottiStandardTable.optionsSchema.optional(), + pagination: KottiStandardTable.paginationSchema, + table: KottiTableHookParamsSchema.omit({ + id: true, + pagination: true, + }), +}) + +const mapToColumnFilters = ( + appliedFilters: KottiStandardTable.AppliedFilter[], +): ColumnFiltersState => + appliedFilters.map(({ id, value }) => ({ + id, + value, + })) + +export const useKottiStandardTable = ( + _params: KottiStandardTableParameters, +): { + context: StandardTableContext + tableHook: ReturnType> +} => { + const params = computed(() => paramsSchema.parse(_params.value)) + + const appliedFilters = ref([]) + /** + * https://github.com/TanStack/table/discussions/4670 + */ + const globalFilter = ref(' ') + + const tableHook = useKottiTable( + computed(() => ({ + ..._params.value.table, + columnFilters: + params.value.pagination.type === KottiStandardTable.PaginationType.LOCAL + ? mapToColumnFilters(appliedFilters.value) + : undefined, + globalFilter: + params.value.pagination.type === KottiStandardTable.PaginationType.LOCAL + ? globalFilter.value + : undefined, + id: params.value.id, + pagination: + params.value.pagination.type === KottiStandardTable.PaginationType.LOCAL + ? { + state: { + pageIndex: 0, + pageSize: params.value.pagination.pageSize, + }, + type: KottiStandardTable.PaginationType.LOCAL, + } + : { + rowCount: params.value.pagination.rowCount, + state: { + pageIndex: 0, + pageSize: params.value.pagination.pageSize, + }, + type: KottiStandardTable.PaginationType.REMOTE, + }, + })), + ) + + const standardTableContext: StandardTableContext = computed(() => ({ + internal: { + columns: _params.value.table.columns, + filters: params.value.filters, + getAppliedFilters: () => appliedFilters.value, + getFilter: (id) => + params.value.filters.find((filter) => filter.id === id) ?? null, + getSearchValue: () => globalFilter.value, + isLoading: params.value.isLoading, + options: params.value.options, + pageSizeOptions: params.value.pagination.pageSizeOptions, + paginationType: params.value.pagination.type, + setAppliedFilters: (value: KottiStandardTable.AppliedFilter[]) => { + appliedFilters.value = value + }, + setSearchValue: (value: KottiFieldText.Value) => { + globalFilter.value = value + }, + }, + })) + useProvideStandardTableContext(params.value.id, standardTableContext) + + watch( + () => + tableHook.tableContext.value.internal.table.value.getState().pagination + .pageSize, + () => { + tableHook.tableContext.value.internal.table.value.setPageIndex(0) + }, + ) + + return { + context: standardTableContext, + tableHook: tableHook, + } +} diff --git a/packages/kotti-ui/source/kotti-table/standard-table/types.ts b/packages/kotti-ui/source/kotti-table/standard-table/types.ts new file mode 100644 index 0000000000..d61ead8d73 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/types.ts @@ -0,0 +1,374 @@ +import { z } from 'zod' + +import { KottiFieldDateRange } from '../../kotti-field-date/types' +import { KottiFieldNumber } from '../../kotti-field-number/types' +import { + KottiFieldMultiSelect, + KottiFieldSingleSelect, +} from '../../kotti-field-select/types' +import { KottiFieldText } from '../../kotti-field-text/types' +import { KottiFieldToggle } from '../../kotti-field-toggle/types' +import { KottiPopover } from '../../kotti-popover/types' +import { KottiTable } from '../table/types' + +import { + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SIZE_OPTIONS, + MIN_PAGE_SIZE, +} from './constants' + +export namespace KottiStandardTable { + export enum FilterType { + BOOLEAN = 'BOOLEAN', + DATE_RANGE = 'DATE_RANGE', + MULTI_SELECT = 'MULTI_SELECT', + NUMBER_RANGE = 'NUMBER_RANGE', + SINGLE_SELECT = 'SINGLE_SELECT', + } + export namespace FilterOperation { + export enum Boolean { + EQUAL = 'EQUAL', + } + + export enum DateRange { + IN_RANGE = 'IN_RANGE', + } + + export enum MultiEnum { + ONE_OF = 'ONE_OF', // OR + } + + export enum NumberRange { + IN_RANGE = 'IN_RANGE', + } + + export enum SingleEnum { + EQUAL = 'EQUAL', + } + + export const schema = z.union([ + z.nativeEnum(Boolean), + z.nativeEnum(DateRange), + z.nativeEnum(MultiEnum), + z.nativeEnum(NumberRange), + z.nativeEnum(SingleEnum), + ]) + } + + export enum PaginationType { + LOCAL = 'LOCAL', + REMOTE = 'REMOTE', + } + + const sharedFilterSchema = z.object({ + dataTest: z.string().optional(), + displayInline: z.boolean().default(false), + id: z.string(), + label: z.string(), + }) + + const booleanFilterSchema = sharedFilterSchema.extend({ + defaultValue: KottiFieldToggle.valueSchema.optional(), + operations: z + .nativeEnum(FilterOperation.Boolean) + .array() + .nonempty() + .default([FilterOperation.Boolean.EQUAL]), + slotLabels: z.tuple([z.string(), z.string()]).optional(), + type: z.literal(FilterType.BOOLEAN), + }) + + const dateRangeFilterSchema = sharedFilterSchema.extend({ + defaultValue: KottiFieldDateRange.valueSchema.optional(), + operations: z + .nativeEnum(FilterOperation.DateRange) + .array() + .nonempty() + .default([FilterOperation.DateRange.IN_RANGE]), + type: z.literal(FilterType.DATE_RANGE), + }) + + const multiSelectFilterSchema = sharedFilterSchema + .merge( + KottiFieldMultiSelect.propsSchema.pick({ + isUnsorted: true, + options: true, + }), + ) + .extend({ + defaultValue: KottiFieldMultiSelect.valueSchema.optional(), + operations: z + .nativeEnum(FilterOperation.MultiEnum) + .array() + .nonempty() + .default([FilterOperation.MultiEnum.ONE_OF]), + type: z.literal(FilterType.MULTI_SELECT), + }) + + const numberRangeFilterSchema = sharedFilterSchema + .merge( + KottiFieldNumber.propsSchema.pick({ + decimalPlaces: true, + }), + ) + .extend({ + defaultValue: z + .tuple([KottiFieldNumber.valueSchema, KottiFieldNumber.valueSchema]) + .optional(), + operations: z + .nativeEnum(FilterOperation.NumberRange) + .array() + .nonempty() + .default([FilterOperation.NumberRange.IN_RANGE]), + type: z.literal(FilterType.NUMBER_RANGE), + unit: KottiFieldNumber.propsSchema.shape.prefix, + }) + + const singleSelectFilterSchema = sharedFilterSchema + .merge( + KottiFieldSingleSelect.propsSchema.pick({ + isUnsorted: true, + options: true, + }), + ) + .extend({ + defaultValue: KottiFieldSingleSelect.valueSchema.optional(), + operations: z + .nativeEnum(FilterOperation.SingleEnum) + .array() + .nonempty() + .default([FilterOperation.SingleEnum.EQUAL]), + type: z.literal(FilterType.SINGLE_SELECT), + }) + + export const filterSchema = z.discriminatedUnion('type', [ + booleanFilterSchema, + dateRangeFilterSchema, + multiSelectFilterSchema, + numberRangeFilterSchema, + singleSelectFilterSchema, + ]) + export type Filter = z.input + export type FilterInternal = z.output + + export const filterValueSchema = z.union([ + KottiFieldToggle.valueSchema, + KottiFieldDateRange.valueSchema, + KottiFieldMultiSelect.valueSchema, + z.tuple([KottiFieldNumber.valueSchema, KottiFieldNumber.valueSchema]), + KottiFieldSingleSelect.valueSchema, + ]) + export type FilterValue = z.output + + export const appliedFilterSchema = sharedFilterSchema + .pick({ id: true }) + .extend({ + operation: FilterOperation.schema, + value: filterValueSchema, + }) + export type AppliedFilter = z.output + + export const optionsSchema = z.object({ + hideControls: z + .object({ + columns: z.boolean().default(false), + filters: z.boolean().default(false), + search: z.boolean().default(false), + }) + .optional(), + popoversSize: z + .object({ + columns: KottiPopover.propsSchema.shape.size, + filters: KottiPopover.propsSchema.shape.size, + }) + .optional(), + searchPlaceholder: z.string().optional(), + }) + export type Options = z.input + + const sharedPaginationSchema = z.object({ + pageIndex: z.number().int().finite().min(0), + pageSize: z.number().int().finite().gt(0), + pageSizeOptions: z.array(z.number().int().finite().min(MIN_PAGE_SIZE)), + rowCount: z.number().int().finite().min(0), + }) + + export const paginationSchema = z + .discriminatedUnion('type', [ + z.object({ + pageSize: + sharedPaginationSchema.shape.pageSize.default(DEFAULT_PAGE_SIZE), + pageSizeOptions: sharedPaginationSchema.shape.pageSizeOptions.default( + () => DEFAULT_PAGE_SIZE_OPTIONS, + ), + type: z.literal(PaginationType.LOCAL), + }), + z.object({ + pageSize: + sharedPaginationSchema.shape.pageSize.default(DEFAULT_PAGE_SIZE), + pageSizeOptions: sharedPaginationSchema.shape.pageSizeOptions.default( + () => DEFAULT_PAGE_SIZE_OPTIONS, + ), + rowCount: sharedPaginationSchema.shape.rowCount, + type: z.literal(PaginationType.REMOTE), + }), + ]) + .default({ + pageSize: DEFAULT_PAGE_SIZE, + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + type: PaginationType.LOCAL, + }) + export type Pagination = z.input + + export const propsSchema = z.object({ + tableId: z.string().min(1, { message: 'Field cannot be empty' }), + title: z.string().optional(), + }) + export type Props = z.input + + export namespace BooleanFilter { + export const propsSchema = z.object({ + filter: booleanFilterSchema, + isLoading: z.boolean().default(false), + value: KottiFieldToggle.valueSchema.default(null), + }) + } + export namespace DateRangeFilter { + export const propsSchema = z.object({ + filter: dateRangeFilterSchema, + isLoading: z.boolean().default(false), + value: KottiFieldDateRange.valueSchema.default([null, null]), + }) + } + export namespace MultiSelectFilter { + export const propsSchema = z.object({ + filter: multiSelectFilterSchema, + isLoading: z.boolean().default(false), + value: KottiFieldMultiSelect.valueSchema.default(() => []), + }) + } + export namespace NumberRangeFilter { + export const propsSchema = z.object({ + filter: numberRangeFilterSchema, + isLoading: z.boolean().default(false), + value: z + .tuple([KottiFieldNumber.valueSchema, KottiFieldNumber.valueSchema]) + .default([null, null]), + }) + } + export namespace SingleSelectFilter { + export const propsSchema = z.object({ + filter: singleSelectFilterSchema, + isLoading: z.boolean().default(false), + value: KottiFieldSingleSelect.valueSchema.default(null), + }) + } + + export namespace FilterList { + export const propsSchema = z.object({ + filters: z.array(filterSchema).default(() => []), + isLoading: z.boolean().default(false), + value: z.array(appliedFilterSchema).default(() => []), + }) + export type Props = z.output + } + + export namespace TableColumns { + export const propsSchema = z.object({ + isLoading: z.boolean().default(false), + options: z + .object({ + key: z.string(), + label: z.string(), + }) + .array(), + size: KottiPopover.propsSchema.shape.size.default( + KottiPopover.Size.MEDIUM, + ), + value: z.record(z.string(), z.boolean()), + }) + export type Props = z.output + } + + export namespace TableFilters { + export const propsSchema = z.object({ + filters: z.array(filterSchema).default(() => []), + isLoading: z.boolean().default(false), + size: KottiPopover.propsSchema.shape.size.default( + KottiPopover.Size.MEDIUM, + ), + value: z.array(appliedFilterSchema).default(() => []), + }) + export type Props = z.output + } + + export namespace TablePageSize { + export const propsSchema = sharedPaginationSchema + .pick({ + pageSize: true, + pageSizeOptions: true, + }) + .extend({ + isLoading: z.boolean().default(false), + }) + export type Props = z.output + } + + export namespace TablePagination { + export const propsSchema = sharedPaginationSchema + .pick({ + pageIndex: true, + pageSize: true, + rowCount: true, + }) + .extend({ + isLoading: z.boolean().default(false), + }) + export type Props = z.output + } + + export namespace TableSearch { + export const propsSchema = z.object({ + dataTest: z.string().optional(), + isLoading: z.boolean().default(false), + placeholder: z.string().optional(), + value: KottiFieldText.valueSchema.default(null), + }) + export type Props = z.output + } + + export namespace Events { + const updateDataFetchDependencies = z.object({ + filters: appliedFilterSchema.array(), + ordering: KottiTable.orderingSchema, + pagination: sharedPaginationSchema.pick({ + pageIndex: true, + pageSize: true, + }), + search: KottiFieldText.valueSchema, + }) + export type UpdateDataFetchDependencies = z.output< + typeof updateDataFetchDependencies + > + } + + export type Translations = { + clearAll: string + editColumns: string + editFilters: string + endDate: string + lastMonth: string + lastWeek: string + lastYear: string + max: string + min: string + moreThan: string + resultsCounter: string + rowsPerPage: string + search: string + showAll: string + startDate: string + today: string + upTo: string + } +} diff --git a/packages/kotti-ui/source/kotti-table/standard-table/utilities/date.ts b/packages/kotti-ui/source/kotti-table/standard-table/utilities/date.ts new file mode 100644 index 0000000000..1125a8b485 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/utilities/date.ts @@ -0,0 +1,23 @@ +import dayjs from 'dayjs' +import type { ManipulateType } from 'dayjs' + +import { ISO8601 } from '../../../constants' + +/** + * Returns formatted today's date. Default template is ISO8601. + * @param templateFormat dayjs compatible datetime format string + * @returns formatted date + */ +export const today = (templateFormat: string = ISO8601): string => + dayjs().format(templateFormat) + +/** + * Returns formatted today's date with the specified amount of time subtracted. Default template is ISO8601. + * @param unit dayjs time unit + * @param templateFormat dayjs compatible datetime format string + * @returns formatted date + */ +export const getLast = ( + unit: ManipulateType, + templateFormat: string = ISO8601, +): string => dayjs().subtract(1, unit).format(templateFormat) diff --git a/packages/kotti-ui/source/kotti-table/standard-table/utilities/filters.ts b/packages/kotti-ui/source/kotti-table/standard-table/utilities/filters.ts new file mode 100644 index 0000000000..4abd3521fa --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/utilities/filters.ts @@ -0,0 +1,118 @@ +import { Dashes } from '@metatypes/typography' + +import type { KottiFieldDateRange } from '../../../kotti-field-date/types' +import type { KottiFieldNumber } from '../../../kotti-field-number/types' +import type { + KottiFieldMultiSelect, + KottiFieldSingleSelect, +} from '../../../kotti-field-select/types' +import type { KottiFieldToggle } from '../../../kotti-field-toggle/types' +import { useTranslationNamespace } from '../../../kotti-i18n/hooks' +import { KottiStandardTable } from '../types' + +/** + * Returns the empty nullish value + * @param filter the filter + * @returns the empty value + */ +export const getEmptyValue = ( + filter: KottiStandardTable.FilterInternal, +): KottiStandardTable.FilterValue => { + switch (filter.type) { + case KottiStandardTable.FilterType.DATE_RANGE: + case KottiStandardTable.FilterType.NUMBER_RANGE: + return [null, null] + case KottiStandardTable.FilterType.MULTI_SELECT: + return [] + default: + return null + } +} + +/** + * Returns the option label + * @param options the options array + * @param value the option value + * @returns the option label + */ +const getOptionLabel = ( + options: KottiFieldSingleSelect.Props['options'], + value: KottiFieldSingleSelect.Value, +): string => options.find((option) => option.value === value)?.label ?? '' + +/** + * Formats the filter value as a human readably string + * @param value the value + * @param filter the filter + * @returns the value as a formated string + */ +export const formatFilterValue = ( + value: KottiStandardTable.FilterValue, + filter: KottiStandardTable.FilterInternal, +): string => { + switch (filter.type) { + case KottiStandardTable.FilterType.BOOLEAN: { + const _value = value as KottiFieldToggle.Value + return _value ? 'true' : '' + } + case KottiStandardTable.FilterType.DATE_RANGE: { + const _value = value as KottiFieldDateRange.Value + return _value[0] === null ? '' : _value.join(Dashes.EnDash) + } + case KottiStandardTable.FilterType.MULTI_SELECT: { + const _value = value as KottiFieldMultiSelect.Value + return _value.map((v) => getOptionLabel(filter.options, v)).join(', ') + } + case KottiStandardTable.FilterType.NUMBER_RANGE: { + const _value = value as [KottiFieldNumber.Value, KottiFieldNumber.Value] + const [min, max] = _value + + if (min === null && max === null) return '' + + const unit = filter.unit ? ` ${filter.unit}` : '' + + if (min !== null && max !== null) + return min === max + ? `${min}${unit}` + : `${min}${Dashes.EnDash}${max}${unit}` + + const translations = useTranslationNamespace('KtStandardTable') + + return max !== null + ? `${translations.value.upTo} ${max}${unit}` + : min !== null + ? `${translations.value.moreThan} ${min}${unit}` + : '' + } + case KottiStandardTable.FilterType.SINGLE_SELECT: { + const _value = value as KottiFieldSingleSelect.Value + return getOptionLabel(filter.options, _value) + } + } +} + +/** + * Checks if the value is nullish + * @param value the field value + * @returns true if the value is nullish, false otherwise + */ +export const isEmptyValue = (value: KottiStandardTable.FilterValue): boolean => + Array.isArray(value) + ? value.length === 0 || (value[0] === null && value[1] === null) + : value === null + +/** + * Re-orders the Number Range filter value to be [min, max]. + * Value is re-ordered only if both, min and max, are not null values. + * @param range the Number Range filter value + * @returns the re-ordered range value + */ +export const reOrderRange = ( + range: [KottiFieldNumber.Value, KottiFieldNumber.Value], +): [KottiFieldNumber.Value, KottiFieldNumber.Value] => { + const [min, max] = range + + if (min !== null && max !== null && min > max) return [max, min] + + return range +} diff --git a/packages/kotti-ui/source/kotti-table/standard-table/utilities/translation.ts b/packages/kotti-ui/source/kotti-table/standard-table/utilities/translation.ts new file mode 100644 index 0000000000..8b22463e20 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/standard-table/utilities/translation.ts @@ -0,0 +1,32 @@ +/** + * Applies text pluralization + * @param translation the translated text with pluralization cases separated by `|` + * @param count the amount of elements + * @param values a token-value dictionary to replace tokens in the translated text + * @returns the pluralized text + */ +export const pluralize = ( + translation: string, + count: number, + values: Record, +): string => { + const cases = translation.split('|') + + if (cases.length === 0) { + throw new Error('Invalid translation string') + } + + let result: string = ( + count === 0 + ? cases[0] + : count === 1 + ? cases[1] ?? cases[0] + : cases[2] ?? cases[1] ?? cases[0] + ) as string + + Object.entries(values).forEach(([key, value]) => { + result = result.replaceAll(`{${key}}`, String(value)) + }) + + return result +} diff --git a/packages/kotti-ui/source/kotti-table/table/column.ts b/packages/kotti-ui/source/kotti-table/table/column.ts new file mode 100644 index 0000000000..146c02db0f --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/column.ts @@ -0,0 +1,84 @@ +import type { KottiI18n } from '../../kotti-i18n/types' + +import type { KottiTable } from './types' + +type ResolvedColumnDisplay> = { + align: 'center' | 'left' | 'right' + disableCellClick: boolean + formatter: + | (( + value: unknown, + context: { + numberFormat: KottiI18n.NumberFormat + options: OPTIONS + }, + ) => string | null) + | null + isNumeric: boolean +} + +const boolean: ResolvedColumnDisplay> = { + align: 'left', + disableCellClick: false, + // TODO: ask how boolean should be displayed + formatter: (value: unknown) => (value ? 'TRUE' : 'FALSE'), + isNumeric: true, +} + +const date: ResolvedColumnDisplay> = { + align: 'left', + disableCellClick: false, + formatter: (value: unknown) => value as string, + isNumeric: true, +} + +const datetime: ResolvedColumnDisplay> = { + align: 'left', + disableCellClick: false, + formatter: (value: unknown) => (value as string).replace('T', ' '), + isNumeric: true, +} + +const integer: ResolvedColumnDisplay> = { + align: 'right', + disableCellClick: false, + formatter: (value: unknown) => + value === null ? null : String(Math.round(value as number)), + isNumeric: true, +} + +const numerical: ResolvedColumnDisplay<{ decimalPlaces?: number }> = { + align: 'right', + disableCellClick: false, + formatter: (value, context) => + value === null + ? null + : (value as number) + .toFixed(context.options.decimalPlaces ?? 2) + .replace('.', context.numberFormat.decimalSeparator), + isNumeric: true, +} + +const text: ResolvedColumnDisplay> = { + align: 'left', + disableCellClick: false, + formatter: (value: unknown) => value as string, + isNumeric: false, +} + +const columnDisplayMap = { + boolean, + date, + datetime, + integer, + numerical, + text, +} + +export const resolveColumnDisplay = >( + display: KottiTable.ColumnDisplay, +): ResolvedColumnDisplay => { + if (display.type === 'custom') return display + + return columnDisplayMap[display.type] as ResolvedColumnDisplay +} diff --git a/packages/kotti-ui/source/kotti-table/table/context.ts b/packages/kotti-ui/source/kotti-table/table/context.ts new file mode 100644 index 0000000000..a811bc73ea --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/context.ts @@ -0,0 +1,38 @@ +import type { Table } from '@tanstack/table-core' +import { inject, provide, type Ref } from 'vue' + +import type { AnyRow, GetRowBehavior, KottiTable } from './types' + +export type TableContext = Ref<{ + internal: { + getColumnIndex: (columnId: string) => number + getOrdering: () => KottiTable.Ordering[] + getRowBehavior: GetRowBehavior + hasDragAndDrop: boolean + isExpandable: boolean + isSelectable: boolean + setDraggedColumnIndex: (columnId: number | null) => void + setDropTargetColumnIndex: (columnId: number | null) => void + swapDraggedAndDropTarget: () => void + table: Ref> + } +}> + +const getTableContextKey = (id: string): string => `kt-table-${id}` + +export const useProvideTableContext = ( + id: string, + tableContext: TableContext, +): void => { + provide>(getTableContextKey(id), tableContext) +} + +export const useTableContext = ( + id: string, +): TableContext => { + const context = inject>(getTableContextKey(id)) + + if (!context) throw new Error(`KtTable: could not find context for “${id}”`) + + return context +} diff --git a/packages/kotti-ui/source/kotti-table/table/hooks.ts b/packages/kotti-ui/source/kotti-table/table/hooks.ts new file mode 100644 index 0000000000..d67145b81d --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/hooks.ts @@ -0,0 +1,543 @@ +import { Dashes } from '@metatypes/typography' +import type { + CellContext, + ColumnFiltersState, + HeaderContext, + PaginationState, + RowSelectionState, + SortingState, + VisibilityState, +} from '@tanstack/table-core' +import { + createColumnHelper, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getPaginationRowModel, +} from '@tanstack/table-core' +import { computed, h, ref, type Ref, watch } from 'vue' +import { z } from 'zod' + +import { Yoco } from '@3yourmind/yoco' + +import { useI18nContext } from '../../kotti-i18n/hooks' +import ToggleInner from '../../shared-components/toggle-inner/ToggleInner.vue' + +import { resolveColumnDisplay } from './column' +import { type TableContext, useProvideTableContext } from './context' +import { useState, useVueTable } from './tanstack-table' +import type { GetRowBehavior } from './types' +import type { AnyRow } from './types' +import { KottiTable } from './types' + +export const EXPANSION_COLUMN_ID = 'kt-table-inner-expand' +export const SELECTION_COLUMN_ID = 'kt-table-inner-select' +export const ARRAY_START = 2 + +export type KottiTableParameter< + ROW extends AnyRow, + COLUMN_IDS extends string = string, +> = Ref<{ + columnFilters?: ColumnFiltersState + columns: KottiTable.Column[] + data: ROW[] + getRowBehavior: GetRowBehavior + globalFilter?: string | null + hasDragAndDrop?: boolean + id: string + isExpandable?: boolean + isSelectable?: boolean //{ + // mode: 'single-page' | 'global' // Consider negative selection for global case + // onSelectionUpdate: (updated: Record) => void + // selectedRows: Ref> + //} + pagination?: KottiTable.Pagination +}> + +export const paramsSchema = z + .object({ + //TODO: + // actions : list of buttons (based on baseOptionSchema) + columnFilters: z + .object({ + id: z.string(), + // Zod schema `z.unknown()` incorrectly infers the property as optional + value: z.any(), + }) + .array() + .optional(), + columns: z.array(KottiTable.columnSchema), + data: z.array(z.any()), + /** + * Keep in sync with type expression + * @see GetRowBehavior + */ + getRowBehavior: z + .function() + .args(z.object({ row: z.record(z.unknown()), rowIndex: z.number() })) + .returns( + z.object({ + classes: z.array(z.string()).optional(), + click: z + .union([ + z.object({ + component: z.null(), + onClick: z + .function() + .args() + .returns(z.union([z.void(), z.promise(z.void())])), + }), + z.object({ + component: z.literal('a'), + on: z.record(z.unknown()).optional(), + props: z.object({ href: z.string() }).passthrough(), + }), + z.object({ + component: z.string().regex(/^[^a]($|.+)/), + on: z.record(z.unknown()).optional(), + props: z.record(z.unknown()).optional(), + }), + z.literal('expand'), + ]) + .optional(), + disable: z + .object({ + click: z.boolean(), + expand: z.boolean(), + select: z.boolean(), + }) + .optional(), + id: z.string(), + }), + ), + globalFilter: z.string().nullable().optional(), + hasDragAndDrop: z.boolean().default(false), + id: z.string(), + isExpandable: z.boolean().default(false), + isSelectable: z.boolean().default(false), + pagination: KottiTable.paginationSchema.optional(), + }) + .strict() + +// TODO: check for Exclude<> issue with generic +export const useKottiTable = ( + _params: KottiTableParameter, +): { + columnOrder: Ref + hiddenColumns: Ref> + ordering: Ref + rowSelection: Ref + tableContext: TableContext +} => { + const params = computed(() => paramsSchema.parse(_params.value)) + const columnHelper = createColumnHelper() + const i18nContext = useI18nContext() + + const columnIdSet = computed>( + () => new Set(params.value.columns.map((c) => c.id)), + ) + + const ordering = ref([]) + const columnOrderInternal = ref([ + EXPANSION_COLUMN_ID, + SELECTION_COLUMN_ID, + ...params.value.columns.map(({ id }) => id), + ]) + // const hasActionSlot = ref(false) + + // watch( + // () => params, + // () => { + // columnOrderInternal.value = [ + // ...(params.value.isExpandable ? [EXPANSION_COLUMN_ID] : []), + // ...(params.value.selection ? [SELECTION_COLUMN_ID] : []), + // ...columnOrderInternal.value.filter( + // (columnId) => + // ![EXPANSION_COLUMN_ID, SELECTION_COLUMN_ID].includes(columnId), + // ), + // ] + // }, + // { immediate: true }, + // ) + + // TODO: should we do this + const [pagination, setPagination] = useState( + params.value.pagination?.state ?? { + pageIndex: 0, + pageSize: 10, + }, + ) + const [rowSelection, setRowSelection] = useState({}) + const [sorting, setSorting] = useState([]) + const [visibilityState, setVisibiltyState] = useState( + Object.fromEntries(params.value.columns.map((column) => [column.id, true])), + ) + + const draggedColumnIndex = ref(null) + const dropTargetColumnIndex = ref(null) + const successfullyDroppedColumnId = ref(null) + + const moveColumnTo = (fromIndex: number, toIndex: number): string[] => { + // console.log({ fromIndex, name: 'moveColumnTo', toIndex }) + const droppedColumnId = columnOrderInternal.value[fromIndex] + if (!droppedColumnId) throw new Error('index is out of bound') + + const spliced = columnOrderInternal.value.toSpliced(fromIndex, 1) + spliced.splice( + toIndex > fromIndex ? toIndex - 1 : toIndex, + 0, + droppedColumnId, + ) + // TODO setting this should not happen in this util function + successfullyDroppedColumnId.value = droppedColumnId + return spliced + } + + watch( + () => sorting.value, + () => { + ordering.value = sorting.value.map((x) => ({ + id: x.id, + value: x.desc ? 'descending' : 'ascending', + })) + }, + ) + + const table = useVueTable( + computed(() => ({ + columns: [ + ...(params.value.isExpandable + ? [ + columnHelper.display({ + cell: ({ row }: CellContext) => { + const rowBehavior = params.value.getRowBehavior({ + row: row.original, + rowIndex: row.index, + }) + const isDisabled = rowBehavior.disable?.expand ?? false + + return h( + 'div', + { + class: { + 'kt-table-expand': true, + yoco: true, + }, + domProps: { + ariaDisabled: String(isDisabled), + ariaExpanded: String(row.getIsExpanded()), + role: 'button', + // tabindex: 0, focus css + }, + on: { + click: (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + if (isDisabled) return + row.toggleExpanded(!row.getIsExpanded()) + }, + }, + }, + row.getIsExpanded() + ? Yoco.Icon.CHEVRON_DOWN + : Yoco.Icon.CHEVRON_RIGHT, + ) + }, + id: EXPANSION_COLUMN_ID, + meta: { + cellClasses: 'kt-table-cell kt-table-cell--is-body', + disableCellClick: true, + headerClasses: 'kt-table-cell kt-table-cell--is-header', + type: 'text', + }, + }), + ] + : []), + ...(params.value.isSelectable + ? [ + columnHelper.display({ + cell: ({ row }: CellContext) => + h( + 'div', + { + class: 'kt-table-selection', + on: { + click: (event: MouseEvent) => { + event.stopPropagation() + event.preventDefault() + row.toggleSelected(!row.getIsSelected()) + }, + }, + }, + [ + h(ToggleInner, { + props: { + component: 'div', + inputProps: { + // TODO: pass data-test + // TODO: disable when row is disabled + disabled: !row.getCanSelect(), // TODO: make ToggleInner not stupid + id: `${params.value.id}-${row.id}-select`, + }, + isDisabled: !row.getCanSelect(), + value: row.getIsSelected(), + }, + }), + ], + ), + header: ({ table }: HeaderContext) => + h( + 'div', + { + on: { + click: () => { + table.toggleAllRowsSelected( + !table.getIsAllRowsSelected(), + ) + }, + }, + }, + [ + h(ToggleInner, { + props: { + component: 'div', + inputProps: { + // TODO: pass data-test + id: `${params.value.id}-header-select-all`, + }, + isDisabled: false, + value: table.getIsAllRowsSelected(), + }, + }), + ], + ), + id: SELECTION_COLUMN_ID, + meta: { + cellClasses: 'kt-table-cell kt-table-cell--is-body', + disableCellClick: true, + headerClasses: 'kt-table-cell kt-table-cell--is-header', + type: 'text', + }, + }), + ] + : []), + ...params.value.columns.map((column) => { + const columnDisplay = resolveColumnDisplay(column.display) + const index = columnOrderInternal.value.indexOf(column.id) + + // TODO: The alignmentClass generation is a bit complex. You could simplify this by directly joining classes without filtering when boolean values are true, or consider a helper function to manage conditional classes. — ChatGippety + const getCellClasses = ( + cellType: 'body' | 'header', + ): Record => ({ + [`kt-table-cell--is-${cellType}`]: true, + [`kt-table-cell--is-${columnDisplay.align}-aligned`]: true, + 'kt-table-cell': true, + 'kt-table-cell--displays-number': columnDisplay.isNumeric, + 'kt-table-cell--has-drop-indicator': + index === dropTargetColumnIndex.value, + 'kt-table-cell--has-drop-indicator-right': + index + 1 === dropTargetColumnIndex.value && + columnOrderInternal.value.length - 1 === index, + 'kt-table-cell--is-dragged': index === draggedColumnIndex.value, + 'kt-table-cell--was-successfully-dropped': + column.id === successfullyDroppedColumnId.value, + }) + + return columnHelper.accessor(column.getData, { + cell: (info) => { + if (columnDisplay.formatter) { + return ( + columnDisplay.formatter(info.getValue(), { + numberFormat: i18nContext.numberFormat, + options: column.display, + }) ?? Dashes.EmDash + ) + } + return info.getValue() ?? Dashes.EmDash + }, + enableSorting: column.isSortable, + filterFn: column.filterFn ?? undefined, + header: () => h('div', { style: { flex: 1 } }, column.label), + id: column.id, + meta: { + cellClasses: getCellClasses('body'), + disableCellClick: columnDisplay.disableCellClick, + headerClasses: getCellClasses('header'), + type: column.display.type, + }, + }) + }), + // Column for actions may not need to exist in tanstack + // ...(hasActionSlot.value + // ? [ + // columnHelper.display({ + // id: ACTION_COLUMN_ID, + // meta: { + // cellClasses: 'kt-table-cell kt-table-cell--is-actions', + // headerClasses: 'kt-table-cell', + // meta: '', + // }, + // }), + // ] + // : []), + ], + data: params.value.data, + enableRowSelection: (row) => { + if (!params.value.isSelectable) return false + + const behavior = params.value.getRowBehavior({ + row: row.original, + rowIndex: row.index, + }) + return !behavior.disable?.select + }, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: params.value.isExpandable + ? getExpandedRowModel() + : undefined, + getFilteredRowModel: params.value.globalFilter + ? getFilteredRowModel() + : undefined, + getPaginationRowModel: + params.value.pagination?.type === KottiTable.PaginationType.LOCAL + ? getPaginationRowModel() + : undefined, + getRowId: (row, rowIndex) => + params.value.getRowBehavior({ row, rowIndex }).id, + manualPagination: + params.value.pagination?.type === KottiTable.PaginationType.REMOTE, + onColumnFiltersChange: (updaterOrValue) => { + if (!params.value.columnFilters) + throw new Error('columnFilters not found') + + const updatedColumnFilters = + typeof updaterOrValue === 'function' + ? updaterOrValue(params.value.columnFilters as ColumnFiltersState) + : updaterOrValue + + params.value.columnFilters = updatedColumnFilters + }, + onColumnVisibilityChange: setVisibiltyState, + onGlobalFilterChange: (updaterOrValue) => { + if (!params.value.globalFilter) + throw new Error('globalFilter not found') + + const updatedGlobalFilter = + typeof updaterOrValue === 'function' + ? updaterOrValue(params.value.globalFilter) + : updaterOrValue + + params.value.globalFilter = updatedGlobalFilter + }, + onPaginationChange: params.value.pagination ? setPagination : undefined, + onRowSelectionChange: setRowSelection, + // onRowSelectionChange: (updateOrValue) => { + // if (!params.selection) throw new Error('no selection available') + + // const updatedSelection = + // typeof updateOrValue === 'function' + // ? updateOrValue(params.selection.selectedRows.value) + // : updateOrValue + // params.selection.selectedRows.value = updatedSelection + // }, + onSortingChange: setSorting, + // onSortingChange: (_x) => { + // ordering.value = tryUpdater(_x).map((x) => ({ + // id: x.id, + // value: x.desc ? 'descending' : 'ascending', + // })) + // }, + rowCount: + params.value.pagination?.type === KottiTable.PaginationType.REMOTE + ? params.value.pagination.rowCount + : undefined, + state: { + // Zod schema `z.unknown()` incorrectly infers the property as optional + columnFilters: (params.value.columnFilters ?? undefined) as + | ColumnFiltersState + | undefined, + columnOrder: columnOrderInternal.value, + columnVisibility: visibilityState.value, + globalFilter: params.value.globalFilter ?? undefined, + pagination: params.value.pagination ? pagination.value : undefined, + rowSelection: rowSelection.value, + sorting: sorting.value, + // sorting: ordering.value.map((x) => ({ + // desc: x.value === 'descending', + // id: x.id, + // })), + }, + })), + ) + + const tableContext: TableContext = computed(() => ({ + internal: { + getColumnIndex: (columnId: string) => { + return columnOrderInternal.value.indexOf(columnId) + }, + getOrdering: () => { + return ordering.value + }, + getRowBehavior: params.value.getRowBehavior, + hasDragAndDrop: Boolean(params.value.hasDragAndDrop), + isExpandable: Boolean(params.value.isExpandable), + isSelectable: Boolean(params.value.isSelectable), + setDraggedColumnIndex: (columnIndex: number | null) => { + draggedColumnIndex.value = columnIndex + }, + setDropTargetColumnIndex: (columnIndex: number | null) => { + dropTargetColumnIndex.value = columnIndex + }, + swapDraggedAndDropTarget: () => { + if ( + dropTargetColumnIndex.value === null || + draggedColumnIndex.value === null + ) + return + + columnOrderInternal.value = moveColumnTo( + draggedColumnIndex.value, + dropTargetColumnIndex.value, + ) + }, + table, + }, + })) + useProvideTableContext(params.value.id, tableContext) + + return { + columnOrder: computed({ + get: () => columnOrderInternal.value.toSpliced(0, ARRAY_START), + set: (value) => { + columnOrderInternal.value = [ + EXPANSION_COLUMN_ID, + SELECTION_COLUMN_ID, + ...value, + ] + }, + }), + hiddenColumns: computed({ + get: () => { + const result = new Set() + + for (const id of columnIdSet.value) { + if (visibilityState.value[id] === false) result.add(id) + } + + return result + }, + set: (newSet) => { + const newVisibilityState: VisibilityState = {} + + for (const id of columnIdSet.value) { + newVisibilityState[id] = !newSet.has(id) + } + + visibilityState.value = newVisibilityState + }, + }), + ordering, + rowSelection, // TODO: rename + tableContext, + } +} diff --git a/packages/kotti-ui/source/kotti-table/table/row.ts b/packages/kotti-ui/source/kotti-table/table/row.ts new file mode 100644 index 0000000000..dc4e3cb0aa --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/row.ts @@ -0,0 +1,44 @@ +import type { Row } from '@tanstack/table-core' + +import type { AnyRow, GetRowBehavior } from './types' + +type RowBehavior = ReturnType> + +type RowCellWrapper = { + component: string + on?: Record + props?: Record +} + +export const DEFAULT_CELL_WRAPPER: RowCellWrapper = { + component: 'div', +} + +export const getCellWrapComponent = ( + clickBehavior: RowBehavior['click'], + row: Row, +): RowCellWrapper => { + if (!clickBehavior) return DEFAULT_CELL_WRAPPER + + if (clickBehavior === 'expand') + return { + component: 'div', + on: { + click: () => { + row.toggleExpanded() + }, + }, + } + + if (clickBehavior.component === null) + return { + component: 'div', + on: { + click: () => { + void clickBehavior.onClick() + }, + }, + } + + return clickBehavior +} diff --git a/packages/kotti-ui/source/kotti-table/table/tanstack-table/index.ts b/packages/kotti-ui/source/kotti-table/table/tanstack-table/index.ts new file mode 100644 index 0000000000..c250e70dc9 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/tanstack-table/index.ts @@ -0,0 +1,151 @@ +// Code is taken from https://github.com/TanStack/table/discussions/4930 + +import type { + RowData, + Table, + TableOptions, + TableOptionsResolved, +} from '@tanstack/table-core' +import { createTable } from '@tanstack/table-core' +import type { UnwrapRef } from 'vue' +import { + computed, + defineComponent, + h, + type Ref, + ref, + shallowRef, + unref, + watch, +} from 'vue' + +// const [state, setState] = useState(false) + +// setState(true) +// setState((x) => !x) + +/** + * "use react" + * + * @deprecated + */ +export const useState = ( + initialState: T, +): [Ref>, (updater: T | ((prevState: T) => T)) => void] => { + const state = ref(initialState) + + return [ + state, + (updater) => { + if (typeof updater === 'function') { + state.value = (updater as (prevState: UnwrapRef) => UnwrapRef)( + state.value, + ) + return + } + + state.value = updater as UnwrapRef + }, + ] +} + +// TODO: wtf + +// Such a silly hack to not get a new element +const createTextVNode = (text: string) => h('span', text).children?.[0] + +/** + * Vue 2 Compatible version of `FlexRender` + * + * Props: + * - `render` {any} - A function, component, string or number + * - `props` {object} - Props to pass to the render function or component + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const FlexRender = defineComponent({ + name: 'FlexRender', + props: ['render', 'props'], + setup: (props) => () => { + if (typeof props.render === 'function') { + const rendered = props.render(props.props) + + // If it is a VNODE we have to wrap it in a function + if (typeof rendered === 'object') { + return rendered + } + + return createTextVNode(rendered) + } + + if (typeof props.render === 'object') { + return h(props.render, props.props) + } + + return createTextVNode(props.render) + }, +}) + +/** + * Vue 2 reactivity wrapper around TanStack Table Core `createTable` + * @example + * + * ```ts + * // Simple usage with a static data set and no controlled state + * const table = useVueTable({ + * getCoreRowModel: getCoreRowModel(), + * data, + * columns, + * }); + * + * // Usage with controlled state + * const table = useVueTable(computed(() => ({ + * getCoreRowModel: getCoreRowModel(), + * data: data.value, + * columns, + * }))); + * ``` + */ +export const useVueTable = ( + options: Ref>, +): Ref> => { + const optionsRef = computed(() => unref(options)) + const resolvedOptions: TableOptionsResolved = { + onStateChange: () => {}, // noop + renderFallbackValue: null, + state: {}, // Dummy state + ...optionsRef.value, + } + + // There is some hacky stuff happening here to force vue to re-render the table. + // Hopefully this only has to live until we reach vue 3 and we can go to using the + // public vue package. + const internalTable = createTable(resolvedOptions) + const table = shallowRef(internalTable) + const state = shallowRef(internalTable.initialState) + + watch( + [optionsRef, state], + ([optionsVal, stateVal]) => { + internalTable.setOptions((prev) => ({ + ...prev, + ...optionsVal, + onStateChange: (updater) => { + state.value = + typeof updater === 'function' ? updater(stateVal) : updater + + optionsVal.onStateChange?.(updater) + }, + state: { + ...stateVal, + ...optionsVal.state, + }, + })) + + // Force rerender + table.value = { ...internalTable } + }, + { immediate: true }, + ) + + return table +} diff --git a/packages/kotti-ui/source/kotti-table/table/types.ts b/packages/kotti-ui/source/kotti-table/table/types.ts new file mode 100644 index 0000000000..7e025961b4 --- /dev/null +++ b/packages/kotti-ui/source/kotti-table/table/types.ts @@ -0,0 +1,198 @@ +import type { FilterFnOption } from '@tanstack/table-core' +import type { VNode } from 'vue' +import { z } from 'zod' + +import { KottiI18n } from '../../kotti-i18n/types' + +// TODO: move somewhere else +export type AnyRow = Record + +/** + * @see {@link ./hooks.ts paramsSchema} + */ +export type GetRowBehavior< + ROW extends AnyRow, + ROW_BEHAVIOR_CLICK_COMPONENT extends string = string, +> = (params: { row: ROW; rowIndex: number }) => { + classes?: string[] + click?: + | { + /** + * For example for opening drawers. Should not be used for navigation. Also consider using normal link with + * a query parameter instead. + */ + component: null + onClick: () => Promise | void + } + | 'expand' + | ({ + /** + * This should be used in most use cases and would usually be a `router-link` + */ + component: ROW_BEHAVIOR_CLICK_COMPONENT + on?: Record + } & (ROW_BEHAVIOR_CLICK_COMPONENT extends 'a' + ? { + props: { + [k: string]: unknown + href: string + } + } + : { props?: Record })) + disable?: { + // actions/canHover: { icon: Yoco.Icon; onClick: () => Promise | void }[] + click: boolean + expand: boolean + select: boolean + } + id: string +} + +export module KottiTable { + /** + * Keep in sync with its type expression + * @see DataDisplay + */ + export const columnDisplaySchema = z.discriminatedUnion('type', [ + // TODO: consider not exporting schemas + // TODO: truncate text, ask how default behavior + // TODO (nice-to-have): attachments, needs design + // TODO (nice-to-have): image, array of urls as data, needs render functions + // TODO (nice-to-have): tuples with separator (e.g. "1234 x 23") + z + .object({ + align: z.enum(['center', 'left', 'right']), + disableCellClick: z.boolean(), + formatter: z + .function() + .args( + z.unknown(), + z + .object({ + numberFormat: KottiI18n.numberFormatSchema, + options: z.object({}).passthrough(), + }) + .strict(), + ) + .returns(z.string().nullable()) + .nullable(), + isNumeric: z.boolean(), + type: z.literal('custom'), + }) + .strict(), + z + .object({ + decimalPlaces: z.number().int().finite().min(0).default(2), + type: z.literal('numerical'), + }) + .strict(), + z.object({ type: z.literal('boolean') }).strict(), + z.object({ type: z.literal('date') }).strict(), + z.object({ type: z.literal('datetime') }).strict(), + z.object({ type: z.literal('integer') }).strict(), + z.object({ type: z.literal('text') }).strict(), + ]) + export type ColumnDisplay = z.input + + export const columnSchema = z + .object({ + display: columnDisplaySchema, + // TODO: getData should understand display.type + filterFn: z.function().args(z.any()).returns(z.boolean()).optional(), + getData: z.function().args(z.any()).returns(z.unknown()), + id: z.string(), + isSortable: z.boolean().default(false), + label: z.string(), + }) + .strict() + + /** + * Keep in sync with its schema + * @see columnDisplaySchema + */ + type DataDisplay = + | { + display: { + align: 'center' | 'left' | 'right' + disableCellClick: boolean + formatter?: unknown + isNumeric: boolean + type: 'custom' + } + getData: (row: ROW) => VNode | string | null + } + | { + display: { type: 'boolean' } + getData: (row: ROW) => boolean + } + | { + display: { type: 'date' } | { type: 'date-time' } | { type: 'text' } + getData: (row: ROW) => string + } + | { + display: + | { decimalPlaces: number; type: 'numerical' } + | { type: 'integer' } + getData: (row: ROW) => number + } + + export type Column< + ROW extends AnyRow, + COLUMN_IDS extends string = string, + > = DataDisplay & { + filterFn?: FilterFnOption + id: COLUMN_IDS + isSortable?: boolean + label: string + } + + // TODO: maybe not an export + export const orderingSchema = z.array( + z + .object({ + id: z.string(), + value: z.enum(['ascending', 'descending']), + }) + .strict(), + ) + + export type Ordering = { + id: string + value: 'ascending' | 'descending' + } + + export const propsSchema = z + .object({ + emptyText: z.string().default('No data'), // TODO translate + isLoading: z.boolean().default(false), + // TODO: desired? + // TODO: bug: can lead to header cells wrapping content + isNotScrollable: z.boolean().default(false), + tableId: z.string(), + }) + .strict() + + export enum PaginationType { + LOCAL = 'LOCAL', + REMOTE = 'REMOTE', + } + + export const paginationSchema = z.discriminatedUnion('type', [ + z.object({ + state: z.object({ + pageIndex: z.number().int().finite().min(0), + pageSize: z.number().int().finite().gt(0), + }), + type: z.literal(PaginationType.LOCAL), + }), + z.object({ + rowCount: z.number().int().finite().min(0), + state: z.object({ + pageIndex: z.number().int().finite().min(0), + pageSize: z.number().int().finite().gt(0), + }), + type: z.literal(PaginationType.REMOTE), + }), + ]) + export type Pagination = z.output +} diff --git a/packages/kotti-ui/source/locales/input.json b/packages/kotti-ui/source/locales/input.json index 07acefe9b4..73598ba4a2 100644 --- a/packages/kotti-ui/source/locales/input.json +++ b/packages/kotti-ui/source/locales/input.json @@ -109,6 +109,25 @@ "menuExpand": "Expand menu", "quickLinksTitle": "Quick Links" }, + "ktStandardTable": { + "clearAll": "Clear All", + "editColumns": "Edit Columns", + "editFilters": "Edit Filters", + "endDate": "End", + "lastMonth": "Last Month", + "lastWeek": "Last Week", + "lastYear": "Last Year", + "max": "Max.", + "min": "Min.", + "moreThan": "More than", + "resultsCounter": "No items | {range} of {total} item | {range} of {total} items", + "rowsPerPage": "Rows per page", + "search": "Search", + "showAll": "Show All", + "startDate": "Start", + "today": "Today", + "upTo": "Up to" + }, "ktValueLabel": { "notSet": "Not Set" } diff --git a/packages/kotti-ui/source/kotti-field-toggle/components/ToggleBox.vue b/packages/kotti-ui/source/shared-components/toggle-inner/ToggleBox.vue similarity index 100% rename from packages/kotti-ui/source/kotti-field-toggle/components/ToggleBox.vue rename to packages/kotti-ui/source/shared-components/toggle-inner/ToggleBox.vue diff --git a/packages/kotti-ui/source/kotti-field-toggle/components/ToggleInner.vue b/packages/kotti-ui/source/shared-components/toggle-inner/ToggleInner.vue similarity index 70% rename from packages/kotti-ui/source/kotti-field-toggle/components/ToggleInner.vue rename to packages/kotti-ui/source/shared-components/toggle-inner/ToggleInner.vue index 3648450882..87d99b354f 100644 --- a/packages/kotti-ui/source/kotti-field-toggle/components/ToggleInner.vue +++ b/packages/kotti-ui/source/shared-components/toggle-inner/ToggleInner.vue @@ -20,7 +20,7 @@ import { computed, defineComponent, type PropType } from 'vue' import type { InputHTMLAttributes } from 'vue/types/jsx' import type { KottiField } from '../../kotti-field/types' -import { KottiFieldToggle } from '../types' +import { KottiFieldToggle } from '../../kotti-field-toggle/types' import ToggleBox from './ToggleBox.vue' import ToggleSwitch from './ToggleSwitch.vue' @@ -84,28 +84,6 @@ export default defineComponent({ display: none; } - &__svg { - flex-shrink: 0; - - &--is-box { - // align checkbox with the center of the first line of the label - // (assumption: font-size comes from common parent element) - // > starting point is upper end of the container (flex-start) - // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) - // > (-8px) Put it up half the height of the checkbox height (16px) - transform: translateY(calc(0.75em - 8px)); - } - - &--is-switch { - // align switch with the center of the first line of the label - // (assumption: font-size comes from common parent element) - // > starting point is upper end of the container (flex-start) - // > (+0.75em) Put upper edge of element into center (since line-height = 1.5 * font-size) - // > (-10px) Put it up half the height of the switch height (20px) - transform: translateY(calc(0.75em - 10px)); - } - } - &--is-disabled { color: var(--text-05); cursor: not-allowed; diff --git a/packages/kotti-ui/source/kotti-field-toggle/components/ToggleSwitch.vue b/packages/kotti-ui/source/shared-components/toggle-inner/ToggleSwitch.vue similarity index 100% rename from packages/kotti-ui/source/kotti-field-toggle/components/ToggleSwitch.vue rename to packages/kotti-ui/source/shared-components/toggle-inner/ToggleSwitch.vue diff --git a/packages/kotti-ui/source/types/kotti.ts b/packages/kotti-ui/source/types/kotti.ts index 049f2af80a..a000e59928 100644 --- a/packages/kotti-ui/source/types/kotti.ts +++ b/packages/kotti-ui/source/types/kotti.ts @@ -66,6 +66,8 @@ export { KottiPagination as Pagination } from '../kotti-pagination/types' export { KottiPopover as Popover } from '../kotti-popover/types' export { KottiRow as Row } from '../kotti-row/types' export { KottiTableLegacy as TableLegacy } from '../kotti-table-legacy/types' +export { KottiStandardTable as StandardTable } from '../kotti-table/standard-table/types' +export { KottiTable as Table } from '../kotti-table/table/types' export { KottiTag as Tag } from '../kotti-tag/types' export { KottiUserMenu as UserMenu } from '../kotti-user-menu/types' export { KottiValueLabel as ValueLabel } from '../kotti-value-label/types' diff --git a/packages/kotti-ui/source/utilities.ts b/packages/kotti-ui/source/utilities.ts index c0a8185ca8..d60ebbd725 100644 --- a/packages/kotti-ui/source/utilities.ts +++ b/packages/kotti-ui/source/utilities.ts @@ -2,7 +2,7 @@ import type { ComponentPublicInstanceConstructor } from 'vue/types/v3-component- import type { Vue as _Vue } from 'vue/types/vue' import type { Kotti } from './types' -import { DecimalSeparator } from './types/kotti' +import { DecimalSeparator } from './types/decimal-separator' /** * Takes a Vue Component and assigns a meta object which diff --git a/yarn.lock b/yarn.lock index 77677aed5b..28de5bd615 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2905,6 +2905,11 @@ ignore "^5.1.8" p-map "^4.0.0" +"@tanstack/table-core@^8.20.5": + version "8.20.5" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d" + integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"