diff --git a/.eslintrc.json b/.eslintrc.json index 28469069c..3969b69e4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -65,6 +65,13 @@ ], "no-unused-expressions": "warn", "no-unused-vars": "off", + "no-warning-comments": [ + "warn", + { + "terms": ["DO NOT SUBMIT"], + "location": "anywhere" + } + ], "sort-imports": "off", "sort-keys": "off" }, diff --git a/resources/js/App.tsx b/resources/js/App.tsx index ee4dc691b..c9c9576d0 100644 --- a/resources/js/App.tsx +++ b/resources/js/App.tsx @@ -13,6 +13,7 @@ import {MemoTitle} from "./features/root/MemoTitle"; const AdminFacilitiesListPage = lazy(() => import("features/root/pages/AdminFacilitiesList.page")); const AdminUsersListPage = lazy(() => import("features/root/pages/AdminUsersList.page")); const CalendarPage = lazy(() => import("features/root/pages/Calendar.page")); +const CalendarTablePage = lazy(() => import("features/root/pages/CalendarTable.page")); const LoginPage = lazy(() => import("features/authentication/pages/Login.page")); const RootPage = lazy(() => import("features/root/pages/Root.page")); @@ -51,6 +52,7 @@ const App: VoidComponent = () => { + diff --git a/resources/js/components/ui/Table/TQueryTable.tsx b/resources/js/components/ui/Table/TQueryTable.tsx index 02f0233cb..95311e841 100644 --- a/resources/js/components/ui/Table/TQueryTable.tsx +++ b/resources/js/components/ui/Table/TQueryTable.tsx @@ -8,7 +8,6 @@ import { } from "@tanstack/solid-table"; import {createLocalStoragePersistence} from "components/persistence/persistence"; import {richJSONSerialiser} from "components/persistence/serialiser"; -import {NON_NULLABLE} from "components/utils"; import {toastMessages} from "components/utils/toast"; import {FilterH} from "data-access/memo-api/tquery/filter_utils"; import { @@ -18,7 +17,14 @@ import { tableHelper, } from "data-access/memo-api/tquery/table"; import {createTQuery} from "data-access/memo-api/tquery/tquery"; -import {ColumnName, ColumnType, DataColumnSchema, DataItem, isDataColumn} from "data-access/memo-api/tquery/types"; +import { + ColumnName, + ColumnType, + DataColumnSchema, + DataItem, + Sort, + isDataColumn, +} from "data-access/memo-api/tquery/types"; import {DEV, JSX, VoidComponent, createComputed, createEffect, createMemo, createSignal} from "solid-js"; import toast from "solid-toast"; import { @@ -70,11 +76,7 @@ export interface TQueryTableProps { /** The entity URL, must not change. */ readonly staticEntityURL: string; readonly staticTranslations?: TableTranslations; - /** - * The key part used to persist the table settings. - * If not provided, the settings are persisted with the entity URL as the key. Specifying this - * key allows persisting the settings separately for different tables showing the same entity. - */ + /** The key to use for persisting the parameters of the displayed page. If not present, nothing is persisted. */ readonly staticPersistenceKey?: string; /** * The filter that is always applied to the data, regardless of other filtering. @@ -82,6 +84,8 @@ export interface TQueryTableProps { * entity B, so only entities A related direclty to that particular entity B should be shown. */ readonly intrinsicFilter?: FilterH; + /** The sort that is always applied to the data at the end of the filter specified by the user. */ + readonly intrinsicSort?: Sort; /** The definition of the columns in the table, in their correct order. */ readonly columns: readonly PartialColumnConfig[]; readonly initialSort?: SortingState; @@ -173,16 +177,22 @@ export const TQueryTable: VoidComponent = (props) => { ["bool", {cell: tableCells.bool, size: 100}], ["date", {cell: tableCells.date}], ["datetime", {cell: tableCells.datetime}], - ["int", {cell: tableCells.int}], + ["int", {cell: tableCells.int, size: 150}], + ["list", {enableSorting: false}], + ["object", {enableSorting: false}], ["string", {}], ["text", {enableSorting: false}], ["uuid", {cell: tableCells.uuid, enableSorting: false, size: 80}], + ["uuid_list", {cell: tableCells.uuidList, enableSorting: false, size: 80}], + ["dict", {cell: tableCells.dict}], + ["dict_list", {cell: tableCells.dictList, enableSorting: false}], ]); const [allInitialised, setAllInitialised] = createSignal(false); const requestCreator = createTableRequestCreator({ columnsConfig, intrinsicFilter: () => props.intrinsicFilter, + intrinsicSort: () => props.intrinsicSort, initialSort: props.initialSort, initialPageSize: props.initialPageSize || @@ -213,25 +223,27 @@ export const TQueryTable: VoidComponent = (props) => { sorting, pagination, } = requestController; - createLocalStoragePersistence({ - key: ["TQueryTable", entityURL, props.staticPersistenceKey].filter(NON_NULLABLE).join(":"), - value: () => ({ - colVis: columnVisibility[0](), - }), - onLoad: (value) => { - // Ensure a bad (e.g. outdated) entry won't affect visibility of a columnn that cannot have - // the visibility controlled by the user. - const colVis = {...value.colVis}; - for (const col of columnsConfig()) { - if (col.columnDef.enableHiding === false) { - delete colVis[col.name]; + if (props.staticPersistenceKey) { + createLocalStoragePersistence({ + key: `TQueryTable:${props.staticPersistenceKey}`, + value: () => ({ + colVis: columnVisibility[0](), + }), + onLoad: (value) => { + // Ensure a bad (e.g. outdated) entry won't affect visibility of a columnn that cannot have + // the visibility controlled by the user. + const colVis = {...value.colVis}; + for (const col of columnsConfig()) { + if (col.columnDef.enableHiding === false) { + delete colVis[col.name]; + } } - } - columnVisibility[1](colVis); - }, - serialiser: richJSONSerialiser(), - version: [PERSISTENCE_VERSION], - }); + columnVisibility[1](colVis); + }, + serialiser: richJSONSerialiser(), + version: [PERSISTENCE_VERSION], + }); + } // Allow querying data now that the DEV columns are added and columns visibility is loaded. setAllInitialised(true); const {rowsCount, pageCount, scrollToTopSignal, filterErrors} = tableHelper({ diff --git a/resources/js/components/ui/Table/Table.module.scss b/resources/js/components/ui/Table/Table.module.scss index bcbbc66d5..f0d478891 100644 --- a/resources/js/components/ui/Table/Table.module.scss +++ b/resources/js/components/ui/Table/Table.module.scss @@ -9,48 +9,52 @@ > .scrollingWrapper { @apply overflow-x-auto; - > .tableBg { - @apply w-max bg-gray-300 mr-16; + > .scrollToTopElement { + @apply w-max; - > .table { - @apply grid p-px gap-px; + > .tableBg { + @apply w-max bg-gray-300 mr-16; - > .headerRow { - @apply contents; + > .table { + @apply grid p-px gap-px; - > .cell { - @apply outline outline-1 outline-gray-400 bg-gray-100; - min-height: 30px; + > .headerRow { + @apply contents; + + > .cell { + @apply outline outline-1 outline-gray-400 bg-gray-100; + min-height: 30px; + } } - } - > .dataRow { - @apply contents; + > .dataRow { + @apply contents; - > .cell { - @apply bg-white text-black rounded-sm px-1.5 py-1 flex items-center overflow-hidden; - min-height: 30px; - } + > .cell { + @apply bg-white text-black rounded-sm px-1.5 py-1 flex items-center overflow-hidden; + min-height: 30px; + } - &:hover > .cell { - @apply bg-hover; + &:hover > .cell { + @apply bg-hover; + } } - } - > .wideRow { - grid-column: 1 / -1; - @apply bg-white p-2 text-gray-500 text-center; - } + > .wideRow { + grid-column: 1 / -1; + @apply bg-white p-2 text-gray-500 text-center; + } - > .bottomBorder { - grid-column: 1 / -1; - @apply border-b border-b-gray-300; - margin-top: -5px; - } + > .bottomBorder { + grid-column: 1 / -1; + @apply border-b border-b-gray-300; + margin-top: -5px; + } - &.dimmed { - > .dataRow > .cell { - @apply bg-opacity-70 text-opacity-30; + &.dimmed { + > .dataRow > .cell { + @apply bg-opacity-70 text-opacity-30; + } } } } @@ -65,7 +69,7 @@ @apply pr-2; } - > .scrollingWrapper > .tableBg > .table { + > .scrollingWrapper > .scrollToTopElement > .tableBg > .table { > .headerRow > .cell { @apply sticky top-px; } diff --git a/resources/js/components/ui/Table/Table.tsx b/resources/js/components/ui/Table/Table.tsx index f29c10aa5..e8ac4dd7e 100644 --- a/resources/js/components/ui/Table/Table.tsx +++ b/resources/js/components/ui/Table/Table.tsx @@ -132,13 +132,13 @@ const DEFAULT_PROPS = { */ export const Table = (allProps: VoidProps>): JSX.Element => { const props = mergeProps(DEFAULT_PROPS, allProps); - let scrollToTopPoint: HTMLDivElement | undefined; + let scrollToTopElement: HTMLDivElement | undefined; createEffect( on( () => props.scrollToTopSignal?.(), (_input, prevInput) => { if (prevInput !== undefined) { - scrollToTopPoint?.scrollIntoView({behavior: "smooth"}); + scrollToTopElement?.scrollIntoView({behavior: "smooth"}); } }, ), @@ -196,64 +196,69 @@ export const Table = (allProps: VoidProps>): JSX.Element => { onScroll={[setLastScrollTimestamp, Date.now()]} onScrollEnd={[setLastScrollTimestamp, 0]} > -
-
-
- - {({header, column}) => ( - - {(header) => ( -
{ - const scrWrapper = scrollingWrapper(); - if (scrWrapper && e.deltaY) { - setDesiredScrollX((l = scrWrapper.scrollLeft) => - Math.min(Math.max(l + e.deltaY, 0), scrWrapper.scrollWidth - scrWrapper.clientWidth), - ); - e.preventDefault(); - } - }} - > - - - -
- )} -
- )} -
-
- - - - -
- } +
+
+
- {(rowMaybeAccessor: Row | Accessor>) => { - const row = typeof rowMaybeAccessor === "function" ? rowMaybeAccessor : () => rowMaybeAccessor; - return ( -
- - {(cell) => ( - - - +
+ + {({header, column}) => ( + + {(header) => ( +
{ + const scrWrapper = scrollingWrapper(); + if (scrWrapper && e.deltaY) { + setDesiredScrollX((l = scrWrapper.scrollLeft) => + Math.min( + Math.max(l + e.deltaY, 0), + scrWrapper.scrollWidth - scrWrapper.clientWidth, + ), + ); + e.preventDefault(); + } + }} + > + + + +
)} - +
+ )} +
+
+ + + +
- ); - }} - -
+ } + > + {(rowMaybeAccessor: Row | Accessor>) => { + const row = typeof rowMaybeAccessor === "function" ? rowMaybeAccessor : () => rowMaybeAccessor; + return ( +
+ + {(cell) => ( + + + + )} + +
+ ); + }} + +
+
diff --git a/resources/js/components/ui/Table/table_cells.tsx b/resources/js/components/ui/Table/table_cells.tsx index 69c1abefe..8d7e72c9b 100644 --- a/resources/js/components/ui/Table/table_cells.tsx +++ b/resources/js/components/ui/Table/table_cells.tsx @@ -1,9 +1,10 @@ import {CellContext, HeaderContext} from "@tanstack/solid-table"; import {DATE_FORMAT, DATE_TIME_FORMAT, NUMBER_FORMAT, useLangFunc} from "components/utils"; import {DateTime} from "luxon"; -import {JSX, Show} from "solid-js"; +import {Index, JSX, Show} from "solid-js"; import {Header} from "./Header"; import {IdColumn} from "./IdColumn"; +import {useDictionaries} from "data-access/memo-api/dictionaries"; /** The component used as header in column definition. */ export type HeaderComponent = (ctx: HeaderContext) => JSX.Element; @@ -20,17 +21,45 @@ export type CellComponent = (ctx: CellContext) => JSX.Element; /** Returns a collection of cell functions for various data types. */ export function useTableCells() { const t = useLangFunc(); + const dictionaries = useDictionaries(); return { defaultHeader: ((ctx) =>
) satisfies HeaderComponent, - default: cellFunc((v) =>
{String(v)}
), + default: cellFunc((v) =>
{defaultFormatValue(v)}
), bool: cellFunc((v) => (v ? t("bool_values.yes") : t("bool_values.no"))), date: cellFunc((v) => DateTime.fromISO(v).toLocaleString(DATE_FORMAT)), datetime: cellFunc((v) => DateTime.fromISO(v).toLocaleString(DATE_TIME_FORMAT)), int: cellFunc((v) => {NUMBER_FORMAT.format(v)}), uuid: cellFunc((v) => ), + uuidList: cellFunc((v) => ( +
+ {(id) => } +
+ )), + dict: cellFunc((v) => dictionaries()?.positionsById.get(v)?.label || "??"), + dictList: cellFunc((v) => ( +
    + {(id) =>
  • {dictionaries()?.positionsById.get(id())?.label || "??"}
  • }
    +
+ )), }; } +function defaultFormatValue(value: unknown) { + if (value == undefined) { + return ""; + } else if (Array.isArray(value)) { + return ( +
    + {(item) =>
  • {defaultFormatValue(item())}
  • }
    +
+ ); + } else if (typeof value === "object") { + return JSON.stringify(value); + } else { + return String(value); + } +} + export function cellFunc( func: (v: V, ctx: CellContext) => JSX.Element | undefined, fallback?: () => JSX.Element, diff --git a/resources/js/components/ui/Table/tquery_filters/BoolFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/BoolFilterControl.tsx index ad30477ea..e8d39135b 100644 --- a/resources/js/components/ui/Table/tquery_filters/BoolFilterControl.tsx +++ b/resources/js/components/ui/Table/tquery_filters/BoolFilterControl.tsx @@ -15,19 +15,19 @@ export const BoolFilterControl: FilterControl { @@ -60,7 +60,7 @@ export const BoolFilterControl: FilterControl { setValue(value!); - props.setFilter(buildFilter(value!)); + props.setFilter(buildFilter()); }} nullable={false} small diff --git a/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.module.scss b/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.module.scss index bfb15174b..cb3abbad0 100644 --- a/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.module.scss +++ b/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.module.scss @@ -29,9 +29,9 @@ } .valuesSyncer { - width: 6px; + width: 5px; height: 55%; - @apply row-span-2 self-center -mr-0.5; + @apply row-span-2 self-center; @apply border-2 border-r-0 border-current text-black text-opacity-30 cursor-pointer; border-start-start-radius: 3px; border-end-start-radius: 3px; diff --git a/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.tsx b/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.tsx index 795c0ad4c..18bdfa28c 100644 --- a/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.tsx +++ b/resources/js/components/ui/Table/tquery_filters/ColumnFilterController.tsx @@ -3,6 +3,8 @@ import {FilterIconButton, useTable} from ".."; import {BoolFilterControl} from "./BoolFilterControl"; import s from "./ColumnFilterController.module.scss"; import {DateTimeFilterControl} from "./DateTimeFilterControl"; +import {DictFilterControl} from "./DictFilterControl"; +import {DictListFilterControl} from "./DictListFilterControl"; import {IntFilterControl} from "./IntFilterControl"; import {TextualFilterControl} from "./TextualFilterControl"; import {UuidFilterControl} from "./UuidFilterControl"; @@ -53,10 +55,12 @@ export const ColumnFilterController: VoidComponent = (props) return () => ; case "uuid": return () => ; - case "dict": + case "uuid_list": return undefined; // TODO: Implement. + case "dict": + return () => ; case "dict_list": - return undefined; // TODO: Implement. + return () => ; default: return meta satisfies never; } diff --git a/resources/js/components/ui/Table/tquery_filters/DateTimeFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/DateTimeFilterControl.tsx index 6a7d1b036..d2213ece1 100644 --- a/resources/js/components/ui/Table/tquery_filters/DateTimeFilterControl.tsx +++ b/resources/js/components/ui/Table/tquery_filters/DateTimeFilterControl.tsx @@ -101,7 +101,7 @@ export const DateTimeFilterControl: VoidComponent = (props) => { const syncActive = () => !!lower() || !!upper(); return (
{t("range.from")}
diff --git a/resources/js/components/ui/Table/tquery_filters/DictFilterControl.tsx b/resources/js/components/ui/Table/tquery_filters/DictFilterControl.tsx new file mode 100644 index 000000000..efd0929de --- /dev/null +++ b/resources/js/components/ui/Table/tquery_filters/DictFilterControl.tsx @@ -0,0 +1,97 @@ +import {Select, SelectItem} from "components/ui/form/Select"; +import {useLangFunc} from "components/utils"; +import {useDictionaries} from "data-access/memo-api/dictionaries"; +import {FilterH} from "data-access/memo-api/tquery/filter_utils"; +import {VoidComponent, createComputed, createMemo, createSignal} from "solid-js"; +import {SelectItemLabelOnList, makeSelectItem} from "./select_items"; +import {FilterControlProps} from "./types"; + +interface Props extends FilterControlProps { + readonly dictionaryId: string; +} + +export const DictFilterControl: VoidComponent = (props) => { + const t = useLangFunc(); + const dictionaries = useDictionaries(); + + const [value, setValue] = createSignal([]); + createComputed(() => { + if (!props.filter) { + setValue([]); + } + // Ignore other external filter changes. + }); + function buildFilter(): FilterH | undefined { + if (!value().length) { + return undefined; + } else if (value().includes("*")) { + return {type: "column", column: props.name, op: "null", inv: true}; + } else { + const hasNull = value().includes("null"); + return { + type: "op", + op: "|", + val: [ + hasNull ? {type: "column", column: props.name, op: "null"} : "never", + {type: "column", column: props.name, op: "in", val: value().filter((v) => v !== "null")}, + ], + }; + } + } + const items = createMemo(() => { + const items: SelectItem[] = []; + if (props.nullable) { + items.push( + makeSelectItem({ + symbol: "*", + description: t("tables.filter.non_null_value"), + label: () => , + }), + { + value: "__separator__", + label: () =>
, + disabled: true, + }, + makeSelectItem({ + value: "null", + symbol: "‘’", + text: `'' ${t("tables.filter.null_value")}`, + description: t("tables.filter.null_value"), + label: () => , + }), + ); + } + for (const position of dictionaries()?.get(props.dictionaryId)?.activePositions || []) { + items.push({ + value: position.id, + text: position.label, + }); + } + return items; + }); + return ( +
+