From 89f13c3fbc1fa6714bf13b49eabb71581bd75f21 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:07:14 +0100 Subject: [PATCH] feat(ee): bases (#2295) * feat(ee): bases Table and kanban UI, formula engine package, and the base-embed editor extension. * - default status - type fix - error helper * fix: base trash list handling * feat: base nodeview menu * feat: translation * fix number precision * feat(base): add focused-cell atom and cell coordinate types * feat(base): add cell focus-ring style * feat(base): add pure next-cell navigation helper * feat(base): keyboard navigation controller and grid wiring * update offerings * feat(base): cell focus ring, click-to-focus, and gridcell ARIA * feat(base): row ARIA index and selected state * feat(base): seed editor value on type-to-edit for free-text cells * feat(base): make column headers keyboard-focusable as tab stops * fix(base): remove focus outline on grid container * fix(base): show cell focus ring only while the grid is focused * feat(base): keyboard-navigate the row-number column for selection * fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order * fix(base): tab from long-text editor moves to next cell instead of leaving the table * fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop * fix(base): show cell focus ring only while the grid body itself is focused * fix(base): render view-tab rename as an inline pill so the tab band height stays put * fix(base): refer to the feature as 'base' rather than 'database' * fix: change permissions object shape * license file * fix tsconfig * fix base cache * fix: preserve sidebar title/icon on partial page updates * fix: skip duplicate row fetch when opening new kanban card * fix refetch * fix focus * fix spacing * fix(base): select grid cell on mousedown to avoid stale focus ring flash The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell. Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints. * fix: activate New row button via keyboard (Enter/Space) The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell. Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys. * fix(base): keep add-property popover within viewport on mobile Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it. Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active. * fix(base): enable grid cell editing on touch devices Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable. * feat(editor): add base and kanban inserts to the toolbar * feat(base): insert row below via Shift+Enter on the primary cell * fix(base): place caret at end instead of selecting all when editing cells * fix(base): prevent popover inputs from losing focus on mobile in row detail modal * fix grid cells on mobile * sync * fix: read-only export * feat(base): add prefixed nanoid id schemas and generators * feat(base): enforce strict property/choice id validation * feat(base): make property id varchar with per-base composite pk * feat(base): pass property id as text to cell extractors * feat(base): scope property lookups per base and generate property ids in repo * feat(base): generate status template choice ids as nanoid * feat(base): generate choice ids as nanoid on the client * chore(base): seed choice ids with nanoid * fix(base): mint kanban choice ids as nanoid * sync * sync * sync --- Dockerfile | 2 + apps/client/package.json | 5 +- .../public/locales/en-US/translation.json | 60 +- apps/client/src/App.tsx | 3 + .../src/components/common/page-list-icon.tsx | 18 + .../src/components/common/recent-changes.tsx | 14 +- .../ui/destination-picker/page-row.tsx | 3 +- apps/client/src/ee/LICENSE | 40 +- apps/client/src/ee/base/atoms/base-atoms.ts | 55 ++ .../ee/base/atoms/formula-recompute-atom.ts | 3 + .../src/ee/base/atoms/reference-store-atom.ts | 20 + .../src/ee/base/atoms/view-draft-atom.ts | 21 + .../ee/base/components/base-embed-title.tsx | 91 +++ .../base/components/base-table-skeleton.tsx | 92 +++ .../src/ee/base/components/base-table.tsx | 70 ++ .../src/ee/base/components/base-toolbar.tsx | 295 +++++++ .../components/base-view-draft-banner.tsx | 45 ++ .../src/ee/base/components/base-view.tsx | 541 +++++++++++++ .../base/components/cells/badge-overflow.tsx | 100 +++ .../base/components/cells/cell-checkbox.tsx | 44 ++ .../base/components/cells/cell-created-at.tsx | 22 + .../ee/base/components/cells/cell-date.tsx | 146 ++++ .../ee/base/components/cells/cell-email.tsx | 61 ++ .../ee/base/components/cells/cell-file.tsx | 236 ++++++ .../ee/base/components/cells/cell-formula.tsx | 38 + .../components/cells/cell-last-edited-at.tsx | 22 + .../components/cells/cell-last-edited-by.tsx | 41 + .../base/components/cells/cell-long-text.tsx | 151 ++++ .../components/cells/cell-multi-select.tsx | 289 +++++++ .../ee/base/components/cells/cell-number.tsx | 158 ++++ .../ee/base/components/cells/cell-page.tsx | 339 +++++++++ .../ee/base/components/cells/cell-person.tsx | 247 ++++++ .../ee/base/components/cells/cell-select.tsx | 239 ++++++ .../ee/base/components/cells/cell-status.tsx | 202 +++++ .../ee/base/components/cells/cell-text.tsx | 60 ++ .../src/ee/base/components/cells/cell-url.tsx | 70 ++ .../base/components/cells/cell-value-equal.ts | 30 + .../ee/base/components/cells/choice-badge.tsx | 29 + .../ee/base/components/cells/choice-color.ts | 25 + .../base/components/cells/choice-picker.tsx | 258 +++++++ .../components/cells/person-read-list.tsx | 40 + .../components/formula/formula-editor.tsx | 189 +++++ .../base/components/formula/formula-input.tsx | 40 + .../formula/formula-property-editor.tsx | 48 ++ .../components/formula/function-palette.tsx | 83 ++ .../components/formula/property-chip-row.tsx | 56 ++ .../base/components/grid/add-row-button.tsx | 32 + .../grid/base-drop-edge-indicator.tsx | 10 + .../src/ee/base/components/grid/grid-cell.tsx | 301 ++++++++ .../base/components/grid/grid-container.tsx | 565 ++++++++++++++ .../base/components/grid/grid-ghost-rows.tsx | 32 + .../base/components/grid/grid-header-cell.tsx | 356 +++++++++ .../ee/base/components/grid/grid-header.tsx | 64 ++ .../src/ee/base/components/grid/grid-row.tsx | 198 +++++ .../base/components/grid/row-number-cell.tsx | 111 +++ .../grid/row-number-header-cell.tsx | 51 ++ .../components/grid/selection-action-bar.tsx | 52 ++ .../ee/base/components/kanban/base-kanban.tsx | 213 ++++++ .../kanban/card-field/card-field.tsx | 339 +++++++++ .../kanban/kanban-add-card-button.tsx | 28 + .../kanban/kanban-card-properties.tsx | 251 ++++++ .../ee/base/components/kanban/kanban-card.tsx | 85 +++ .../kanban/kanban-column-header.tsx | 76 ++ .../base/components/kanban/kanban-column.tsx | 163 ++++ .../components/kanban/kanban-empty-state.tsx | 99 +++ .../kanban/kanban-group-by-picker.tsx | 115 +++ .../components/property/choice-editor.tsx | 673 ++++++++++++++++ .../components/property/conversion-warning.ts | 127 ++++ .../property/create-property-popover.tsx | 417 ++++++++++ .../property/default-value-picker.tsx | 98 +++ .../components/property/property-menu.tsx | 589 ++++++++++++++ .../components/property/property-options.tsx | 622 +++++++++++++++ .../property/property-type-picker.tsx | 71 ++ .../row-detail-modal/fields/detail-field.tsx | 142 ++++ .../fields/field-cell-adapter.tsx | 80 ++ .../row-detail-modal/fields/field-choice.tsx | 104 +++ .../row-detail-modal/fields/field-date.tsx | 76 ++ .../fields/field-long-text.tsx | 69 ++ .../row-detail-modal/fields/field-number.tsx | 89 +++ .../row-detail-modal/fields/field-text.tsx | 89 +++ .../row-detail-modal/property-row.tsx | 118 +++ .../row-detail-modal/row-detail-modal.tsx | 440 +++++++++++ .../row-detail-modal/row-detail-title.tsx | 71 ++ .../components/views/filter-date-input.tsx | 201 +++++ .../components/views/filter-person-input.tsx | 246 ++++++ .../components/views/relative-date-presets.ts | 35 + .../components/views/view-create-menu.tsx | 140 ++++ .../components/views/view-filter-config.tsx | 499 ++++++++++++ .../views/view-property-visibility.tsx | 165 ++++ .../base/components/views/view-renderer.tsx | 59 ++ .../components/views/view-sort-config.tsx | 223 ++++++ .../ee/base/components/views/view-tabs.tsx | 418 ++++++++++ .../src/ee/base/constants/currencies.ts | 39 + .../src/ee/base/context/base-editable.tsx | 22 + .../src/ee/base/context/grid-row-order.tsx | 12 + .../client/src/ee/base/context/row-expand.tsx | 11 + .../src/ee/base/formatters/cell-formatters.ts | 22 + .../src/ee/base/hooks/use-base-socket.ts | 382 ++++++++++ .../src/ee/base/hooks/use-base-table.ts | 376 +++++++++ .../src/ee/base/hooks/use-column-resize.ts | 26 + .../base/hooks/use-delete-selected-rows.tsx | 55 ++ .../ee/base/hooks/use-editable-text-cell.ts | 100 +++ .../src/ee/base/hooks/use-escape-close.ts | 12 + .../src/ee/base/hooks/use-formula-parser.ts | 107 +++ .../src/ee/base/hooks/use-grid-autoscroll.ts | 120 +++ .../ee/base/hooks/use-grid-keyboard-nav.ts | 317 ++++++++ .../base/hooks/use-horizontal-scroll-sync.ts | 62 ++ .../ee/base/hooks/use-kanban-autoscroll.ts | 70 ++ .../src/ee/base/hooks/use-kanban-card-dnd.ts | 80 ++ .../src/ee/base/hooks/use-kanban-card-drop.ts | 34 + .../ee/base/hooks/use-kanban-column-dnd.ts | 63 ++ .../src/ee/base/hooks/use-kanban-columns.ts | 52 ++ .../ee/base/hooks/use-list-keyboard-nav.ts | 62 ++ .../src/ee/base/hooks/use-person-search.ts | 32 + .../src/ee/base/hooks/use-row-autoscroll.ts | 28 + .../src/ee/base/hooks/use-row-detail-modal.ts | 33 + .../src/ee/base/hooks/use-row-selection.ts | 101 +++ .../src/ee/base/hooks/use-view-draft.ts | 167 ++++ apps/client/src/ee/base/pages/base-page.tsx | 35 + .../property-type.descriptor.ts | 42 + .../property-types/property-type.registry.tsx | 263 +++++++ .../base/queries/base-page-resolver-query.ts | 61 ++ .../ee/base/queries/base-property-query.ts | 171 +++++ apps/client/src/ee/base/queries/base-query.ts | 132 ++++ .../src/ee/base/queries/base-row-query.ts | 574 ++++++++++++++ .../src/ee/base/queries/base-view-query.ts | 155 ++++ .../src/ee/base/queries/page-expand-loader.ts | 57 ++ .../src/ee/base/reference/reference-store.ts | 60 ++ .../src/ee/base/services/base-service.ts | 177 +++++ .../ee/base/services/format-kanban-count.ts | 7 + .../ee/base/services/kanban-column-filter.ts | 16 + .../styles/base-table-skeleton.module.css | 53 ++ .../ee/base/styles/base-toolbar.module.css | 10 + .../src/ee/base/styles/base-view.module.css | 5 + .../src/ee/base/styles/cells.module.css | 487 ++++++++++++ .../src/ee/base/styles/formula.module.css | 46 ++ .../client/src/ee/base/styles/grid.module.css | 717 ++++++++++++++++++ .../src/ee/base/styles/kanban.module.css | 159 ++++ .../src/ee/base/styles/property.module.css | 23 + .../base/styles/row-detail-modal.module.css | 447 +++++++++++ .../src/ee/base/styles/views.module.css | 16 + apps/client/src/ee/base/types/base.types.ts | 388 ++++++++++ .../client/src/ee/base/types/react-table.d.ts | 8 + .../src/ee/base/utils/generate-base-id.ts | 5 + .../client/src/ee/base/utils/grid-cell-nav.ts | 33 + apps/client/src/ee/features.ts | 1 + .../src/ee/licence/components/oss-details.tsx | 2 + .../src/features/editor/atoms/editor-atoms.ts | 2 + .../components/base-embed/base-embed-view.tsx | 181 +++++ .../base-embed/base-embed.module.css | 76 ++ .../base-embed/insert-base-embed.ts | 67 ++ .../empty-page-get-started.module.css | 29 + .../empty-page/empty-page-get-started.tsx | 82 ++ .../groups/more-inserts-group.tsx | 19 + .../components/link/link-editor-panel.tsx | 4 +- .../components/mention/mention-list.tsx | 5 +- .../components/slash-menu/command-list.tsx | 26 +- .../components/slash-menu/menu-items.ts | 20 + .../slash-menu/slash-menu.module.css | 5 + .../features/editor/extensions/drag-handle.ts | 41 +- .../features/editor/extensions/extensions.ts | 12 +- .../src/features/editor/full-editor.tsx | 3 + .../src/features/editor/page-editor.tsx | 10 + .../src/features/editor/styles/base-embed.css | 17 + .../src/features/editor/styles/core.css | 29 + .../features/editor/styles/editor.module.css | 2 + .../src/features/editor/styles/index.css | 1 + .../src/features/editor/title-editor.tsx | 20 +- .../features/favorite/types/favorite.types.ts | 1 + .../home/components/created-by-me.tsx | 18 +- .../home/components/favorites-pages.tsx | 21 +- .../components/notification-item.tsx | 4 +- .../components/backlinks-list.tsx | 4 +- .../components/breadcrumbs/breadcrumb.tsx | 20 +- .../components/header/page-header-menu.tsx | 68 +- apps/client/src/features/page/page.utils.ts | 14 + .../src/features/page/queries/page-query.ts | 7 +- .../components/trash-page-content-modal.tsx | 14 +- .../features/page/trash/components/trash.tsx | 20 +- .../page/tree/components/doc-tree-row.tsx | 11 +- .../tree/components/space-tree-node-menu.tsx | 3 +- .../page/tree/components/space-tree-row.tsx | 12 +- .../page/tree/components/space-tree.tsx | 3 +- .../features/page/tree/styles/tree.module.css | 2 - apps/client/src/features/page/tree/types.ts | 1 + .../src/features/page/tree/utils/utils.ts | 5 +- .../src/features/page/types/page.types.ts | 1 + .../space/permissions/permissions.type.ts | 3 + .../websocket/use-query-subscription.ts | 43 +- .../src/features/websocket/use-tree-socket.ts | 5 + apps/client/src/lib/api-client.ts | 1 + apps/client/src/lib/api-error.ts | 17 + apps/client/src/lib/index.ts | 1 + .../src/pages/favorites/favorites-page.tsx | 4 +- apps/client/src/pages/page/page.tsx | 68 +- apps/client/src/theme.ts | 1 + apps/server/package.json | 6 +- .../src/collaboration/collaboration.util.ts | 2 + .../src/common/events/event.contants.ts | 25 + apps/server/src/common/features.ts | 1 + apps/server/src/common/helpers/constants.ts | 7 +- .../server/src/common/helpers/nanoid.utils.ts | 7 +- .../attachment/services/attachment.service.ts | 2 +- .../src/core/page/dto/create-page.dto.ts | 1 + .../src/core/page/services/page.service.ts | 38 +- .../migrations/20260529T125146-bases.ts | 249 ++++++ .../database/repos/favorite/favorite.repo.ts | 1 + .../src/database/repos/page/page.repo.ts | 1 + apps/server/src/database/types/db.d.ts | 49 ++ .../server/src/database/types/entity.types.ts | 18 + apps/server/src/ee | 2 +- .../queue/constants/queue.constants.ts | 5 + .../queue/constants/queue.interface.ts | 44 ++ .../src/integrations/queue/queue.module.ts | 8 + apps/server/src/ws/base-realtime.bridge.ts | 49 ++ apps/server/src/ws/ws.gateway.ts | 20 +- apps/server/src/ws/ws.module.ts | 3 +- apps/server/src/ws/ws.utils.ts | 11 + apps/server/tsconfig.json | 8 +- packages/base-formula/.gitignore | 2 + packages/base-formula/LICENSE | 37 + packages/base-formula/bench/formula-bench.ts | 329 ++++++++ packages/base-formula/package.json | 28 + packages/base-formula/src/ast.ts | 33 + packages/base-formula/src/error.ts | 41 + packages/base-formula/src/eval.ts | 126 +++ packages/base-formula/src/format.ts | 31 + .../base-formula/src/functions/coercion.ts | 17 + packages/base-formula/src/functions/date.ts | 53 ++ packages/base-formula/src/functions/index.ts | 7 + packages/base-formula/src/functions/logic.ts | 11 + packages/base-formula/src/functions/math.ts | 160 ++++ .../base-formula/src/functions/registry.ts | 24 + packages/base-formula/src/functions/string.ts | 35 + packages/base-formula/src/graph.ts | 88 +++ packages/base-formula/src/index.client.ts | 14 + packages/base-formula/src/index.server.ts | 15 + packages/base-formula/src/number.ts | 10 + packages/base-formula/src/parser.ts | 201 +++++ packages/base-formula/src/resolver.ts | 68 ++ packages/base-formula/src/tokenizer.ts | 158 ++++ packages/base-formula/src/typecheck.ts | 89 +++ packages/base-formula/src/types.ts | 74 ++ packages/base-formula/tsconfig.json | 12 + packages/editor-ext/src/index.ts | 2 +- .../src/lib/base-embed/base-embed.ts | 133 ++++ .../editor-ext/src/lib/base-embed/index.ts | 2 + .../src/lib/table/header-pin/index.ts | 1 + pnpm-lock.yaml | 59 +- 249 files changed, 23435 insertions(+), 183 deletions(-) create mode 100644 apps/client/src/components/common/page-list-icon.tsx create mode 100644 apps/client/src/ee/base/atoms/base-atoms.ts create mode 100644 apps/client/src/ee/base/atoms/formula-recompute-atom.ts create mode 100644 apps/client/src/ee/base/atoms/reference-store-atom.ts create mode 100644 apps/client/src/ee/base/atoms/view-draft-atom.ts create mode 100644 apps/client/src/ee/base/components/base-embed-title.tsx create mode 100644 apps/client/src/ee/base/components/base-table-skeleton.tsx create mode 100644 apps/client/src/ee/base/components/base-table.tsx create mode 100644 apps/client/src/ee/base/components/base-toolbar.tsx create mode 100644 apps/client/src/ee/base/components/base-view-draft-banner.tsx create mode 100644 apps/client/src/ee/base/components/base-view.tsx create mode 100644 apps/client/src/ee/base/components/cells/badge-overflow.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-checkbox.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-created-at.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-date.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-email.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-file.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-formula.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-long-text.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-multi-select.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-number.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-page.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-person.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-select.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-status.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-text.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-url.tsx create mode 100644 apps/client/src/ee/base/components/cells/cell-value-equal.ts create mode 100644 apps/client/src/ee/base/components/cells/choice-badge.tsx create mode 100644 apps/client/src/ee/base/components/cells/choice-color.ts create mode 100644 apps/client/src/ee/base/components/cells/choice-picker.tsx create mode 100644 apps/client/src/ee/base/components/cells/person-read-list.tsx create mode 100644 apps/client/src/ee/base/components/formula/formula-editor.tsx create mode 100644 apps/client/src/ee/base/components/formula/formula-input.tsx create mode 100644 apps/client/src/ee/base/components/formula/formula-property-editor.tsx create mode 100644 apps/client/src/ee/base/components/formula/function-palette.tsx create mode 100644 apps/client/src/ee/base/components/formula/property-chip-row.tsx create mode 100644 apps/client/src/ee/base/components/grid/add-row-button.tsx create mode 100644 apps/client/src/ee/base/components/grid/base-drop-edge-indicator.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-cell.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-container.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-ghost-rows.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-header-cell.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-header.tsx create mode 100644 apps/client/src/ee/base/components/grid/grid-row.tsx create mode 100644 apps/client/src/ee/base/components/grid/row-number-cell.tsx create mode 100644 apps/client/src/ee/base/components/grid/row-number-header-cell.tsx create mode 100644 apps/client/src/ee/base/components/grid/selection-action-bar.tsx create mode 100644 apps/client/src/ee/base/components/kanban/base-kanban.tsx create mode 100644 apps/client/src/ee/base/components/kanban/card-field/card-field.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-card.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-column-header.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-column.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx create mode 100644 apps/client/src/ee/base/components/kanban/kanban-group-by-picker.tsx create mode 100644 apps/client/src/ee/base/components/property/choice-editor.tsx create mode 100644 apps/client/src/ee/base/components/property/conversion-warning.ts create mode 100644 apps/client/src/ee/base/components/property/create-property-popover.tsx create mode 100644 apps/client/src/ee/base/components/property/default-value-picker.tsx create mode 100644 apps/client/src/ee/base/components/property/property-menu.tsx create mode 100644 apps/client/src/ee/base/components/property/property-options.tsx create mode 100644 apps/client/src/ee/base/components/property/property-type-picker.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/detail-field.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-cell-adapter.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-choice.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-date.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/property-row.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx create mode 100644 apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx create mode 100644 apps/client/src/ee/base/components/views/filter-date-input.tsx create mode 100644 apps/client/src/ee/base/components/views/filter-person-input.tsx create mode 100644 apps/client/src/ee/base/components/views/relative-date-presets.ts create mode 100644 apps/client/src/ee/base/components/views/view-create-menu.tsx create mode 100644 apps/client/src/ee/base/components/views/view-filter-config.tsx create mode 100644 apps/client/src/ee/base/components/views/view-property-visibility.tsx create mode 100644 apps/client/src/ee/base/components/views/view-renderer.tsx create mode 100644 apps/client/src/ee/base/components/views/view-sort-config.tsx create mode 100644 apps/client/src/ee/base/components/views/view-tabs.tsx create mode 100644 apps/client/src/ee/base/constants/currencies.ts create mode 100644 apps/client/src/ee/base/context/base-editable.tsx create mode 100644 apps/client/src/ee/base/context/grid-row-order.tsx create mode 100644 apps/client/src/ee/base/context/row-expand.tsx create mode 100644 apps/client/src/ee/base/formatters/cell-formatters.ts create mode 100644 apps/client/src/ee/base/hooks/use-base-socket.ts create mode 100644 apps/client/src/ee/base/hooks/use-base-table.ts create mode 100644 apps/client/src/ee/base/hooks/use-column-resize.ts create mode 100644 apps/client/src/ee/base/hooks/use-delete-selected-rows.tsx create mode 100644 apps/client/src/ee/base/hooks/use-editable-text-cell.ts create mode 100644 apps/client/src/ee/base/hooks/use-escape-close.ts create mode 100644 apps/client/src/ee/base/hooks/use-formula-parser.ts create mode 100644 apps/client/src/ee/base/hooks/use-grid-autoscroll.ts create mode 100644 apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts create mode 100644 apps/client/src/ee/base/hooks/use-horizontal-scroll-sync.ts create mode 100644 apps/client/src/ee/base/hooks/use-kanban-autoscroll.ts create mode 100644 apps/client/src/ee/base/hooks/use-kanban-card-dnd.ts create mode 100644 apps/client/src/ee/base/hooks/use-kanban-card-drop.ts create mode 100644 apps/client/src/ee/base/hooks/use-kanban-column-dnd.ts create mode 100644 apps/client/src/ee/base/hooks/use-kanban-columns.ts create mode 100644 apps/client/src/ee/base/hooks/use-list-keyboard-nav.ts create mode 100644 apps/client/src/ee/base/hooks/use-person-search.ts create mode 100644 apps/client/src/ee/base/hooks/use-row-autoscroll.ts create mode 100644 apps/client/src/ee/base/hooks/use-row-detail-modal.ts create mode 100644 apps/client/src/ee/base/hooks/use-row-selection.ts create mode 100644 apps/client/src/ee/base/hooks/use-view-draft.ts create mode 100644 apps/client/src/ee/base/pages/base-page.tsx create mode 100644 apps/client/src/ee/base/property-types/property-type.descriptor.ts create mode 100644 apps/client/src/ee/base/property-types/property-type.registry.tsx create mode 100644 apps/client/src/ee/base/queries/base-page-resolver-query.ts create mode 100644 apps/client/src/ee/base/queries/base-property-query.ts create mode 100644 apps/client/src/ee/base/queries/base-query.ts create mode 100644 apps/client/src/ee/base/queries/base-row-query.ts create mode 100644 apps/client/src/ee/base/queries/base-view-query.ts create mode 100644 apps/client/src/ee/base/queries/page-expand-loader.ts create mode 100644 apps/client/src/ee/base/reference/reference-store.ts create mode 100644 apps/client/src/ee/base/services/base-service.ts create mode 100644 apps/client/src/ee/base/services/format-kanban-count.ts create mode 100644 apps/client/src/ee/base/services/kanban-column-filter.ts create mode 100644 apps/client/src/ee/base/styles/base-table-skeleton.module.css create mode 100644 apps/client/src/ee/base/styles/base-toolbar.module.css create mode 100644 apps/client/src/ee/base/styles/base-view.module.css create mode 100644 apps/client/src/ee/base/styles/cells.module.css create mode 100644 apps/client/src/ee/base/styles/formula.module.css create mode 100644 apps/client/src/ee/base/styles/grid.module.css create mode 100644 apps/client/src/ee/base/styles/kanban.module.css create mode 100644 apps/client/src/ee/base/styles/property.module.css create mode 100644 apps/client/src/ee/base/styles/row-detail-modal.module.css create mode 100644 apps/client/src/ee/base/styles/views.module.css create mode 100644 apps/client/src/ee/base/types/base.types.ts create mode 100644 apps/client/src/ee/base/types/react-table.d.ts create mode 100644 apps/client/src/ee/base/utils/generate-base-id.ts create mode 100644 apps/client/src/ee/base/utils/grid-cell-nav.ts create mode 100644 apps/client/src/features/editor/components/base-embed/base-embed-view.tsx create mode 100644 apps/client/src/features/editor/components/base-embed/base-embed.module.css create mode 100644 apps/client/src/features/editor/components/base-embed/insert-base-embed.ts create mode 100644 apps/client/src/features/editor/components/empty-page/empty-page-get-started.module.css create mode 100644 apps/client/src/features/editor/components/empty-page/empty-page-get-started.tsx create mode 100644 apps/client/src/features/editor/styles/base-embed.css create mode 100644 apps/client/src/lib/api-error.ts create mode 100644 apps/server/src/database/migrations/20260529T125146-bases.ts create mode 100644 apps/server/src/ws/base-realtime.bridge.ts create mode 100644 packages/base-formula/.gitignore create mode 100644 packages/base-formula/LICENSE create mode 100644 packages/base-formula/bench/formula-bench.ts create mode 100644 packages/base-formula/package.json create mode 100644 packages/base-formula/src/ast.ts create mode 100644 packages/base-formula/src/error.ts create mode 100644 packages/base-formula/src/eval.ts create mode 100644 packages/base-formula/src/format.ts create mode 100644 packages/base-formula/src/functions/coercion.ts create mode 100644 packages/base-formula/src/functions/date.ts create mode 100644 packages/base-formula/src/functions/index.ts create mode 100644 packages/base-formula/src/functions/logic.ts create mode 100644 packages/base-formula/src/functions/math.ts create mode 100644 packages/base-formula/src/functions/registry.ts create mode 100644 packages/base-formula/src/functions/string.ts create mode 100644 packages/base-formula/src/graph.ts create mode 100644 packages/base-formula/src/index.client.ts create mode 100644 packages/base-formula/src/index.server.ts create mode 100644 packages/base-formula/src/number.ts create mode 100644 packages/base-formula/src/parser.ts create mode 100644 packages/base-formula/src/resolver.ts create mode 100644 packages/base-formula/src/tokenizer.ts create mode 100644 packages/base-formula/src/typecheck.ts create mode 100644 packages/base-formula/src/types.ts create mode 100644 packages/base-formula/tsconfig.json create mode 100644 packages/editor-ext/src/lib/base-embed/base-embed.ts create mode 100644 packages/editor-ext/src/lib/base-embed/index.ts diff --git a/Dockerfile b/Dockerfile index d665e254b..85d39981e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json # Copy packages COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json +COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist +COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json # Copy root package files COPY --from=builder /app/package.json /app/package.json diff --git a/apps/client/package.json b/apps/client/package.json index e2c479ce4..46c076520 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -18,6 +18,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", "@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4", "@casl/react": "5.0.1", + "@docmost/base-formula": "workspace:*", "@docmost/editor-ext": "workspace:*", "@excalidraw/excalidraw": "0.18.0-3a5ef40", "@mantine/core": "9.3.2", @@ -32,7 +33,8 @@ "@slidoapp/emoji-mart-react": "1.1.5", "@tabler/icons-react": "3.40.0", "@tanstack/react-query": "5.90.17", - "@tanstack/react-virtual": "3.13.24", + "@tanstack/react-table": "8.21.3", + "@tanstack/react-virtual": "3.14.3", "alfaaz": "1.1.0", "axios": "1.16.0", "blueimp-load-image": "5.16.0", @@ -50,6 +52,7 @@ "mantine-form-zod-resolver": "1.3.0", "mermaid": "11.15.0", "mitt": "3.0.1", + "nanoid": "3.3.8", "posthog-js": "1.391.2", "react": "19.2.7", "react-clear-modal": "^2.0.18", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 10e6588a4..392fae178 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -41,6 +41,8 @@ "Dark": "Dark", "Date": "Date", "Delete": "Delete", + "Remove from page": "Remove from page", + "Base options": "Base options", "Delete group": "Delete group", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "Description": "Description", @@ -76,6 +78,24 @@ "Failed to import pages": "Failed to import pages", "Failed to load page. An error occurred.": "Failed to load page. An error occurred.", "Failed to update data": "Failed to update data", + "Failed to create base": "Failed to create base", + "Failed to update base": "Failed to update base", + "Failed to delete base": "Failed to delete base", + "Failed to create property": "Failed to create property", + "Failed to update property": "Failed to update property", + "Failed to delete property": "Failed to delete property", + "Failed to reorder property": "Failed to reorder property", + "Failed to create view": "Failed to create view", + "Failed to update view": "Failed to update view", + "Failed to delete view": "Failed to delete view", + "Failed to create row": "Failed to create row", + "Failed to update row": "Failed to update row", + "Failed to delete row": "Failed to delete row", + "Failed to delete rows": "Failed to delete rows", + "Failed to reorder row": "Failed to reorder row", + "Failed to move card": "Failed to move card", + "Failed to add card": "Failed to add card", + "Failed to export CSV": "Failed to export CSV", "Favorite spaces": "Favorite spaces", "Favorite spaces appear here": "Favorite spaces appear here", "Favorites": "Favorites", @@ -599,6 +619,8 @@ "Deleted by": "Deleted by", "Deleted at": "Deleted at", "Preview": "Preview", + "Base preview unavailable": "Base preview unavailable", + "Restore this base to view its contents.": "Restore this base to view its contents.", "Subpages": "Subpages", "Failed to load subpages": "Failed to load subpages", "No subpages": "No subpages", @@ -1093,5 +1115,41 @@ "Toggle allow personal spaces": "Toggle allow personal spaces", "Create personal space": "Create personal space", "Personal space": "Personal space", - "{{name}}'s space": "{{name}}'s space" + "{{name}}'s space": "{{name}}'s space", + "Apply": "Apply", + "Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.", + "Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.", + "Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.", + "Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.", + "Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.", + "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).", + "Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.", + "Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.", + "Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.", + "Cells will be replaced with the option name.": "Cells will be replaced with the option name.", + "Cells will be replaced with the page title.": "Cells will be replaced with the page title.", + "Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.", + "Change type": "Change type", + "Change type to {{label}}?": "Change type to {{label}}?", + "Converting…": "Converting…", + "Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.", + "Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.", + "Previous record": "Previous record", + "Next record": "Next record", + "Record actions": "Record actions", + "Delete record": "Delete record", + "Delete record?": "Delete record?", + "This action cannot be undone.": "This action cannot be undone.", + "to navigate": "to navigate", + "to close": "to close", + "Expand row {{number}}": "Expand row {{number}}", + "Saving…": "Saving…", + "Read-only": "Read-only", + "Loading…": "Loading…", + "Updated {{when}}": "Updated {{when}}", + "Add property": "Add property", + "Create property": "Create property", + "Hide properties": "Hide properties", + "Find a property type": "Find a property type", + "Properties": "Properties" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 789b48601..ab291ffea 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; +import BasePage from "@/ee/base/pages/base-page.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; import TemplateList from "@/ee/template/pages/template-list"; @@ -106,6 +107,8 @@ export default function App() { element={} /> + } /> + } /> {icon}; + } + return ( + + {isBase ? : } + + ); +} diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index f37b26f67..00b3146e2 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -4,15 +4,15 @@ import { UnstyledButton, Badge, Table, - ThemeIcon, Button, } from "@mantine/core"; import { Link } from "react-router-dom"; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; -import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts"; import { formattedDate } from "@/lib/time.ts"; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; -import { IconFileDescription, IconFiles } from "@tabler/icons-react"; +import { PageListIcon } from "@/components/common/page-list-icon"; +import { IconFiles } from "@tabler/icons-react"; import { EmptyState } from "@/components/ui/empty-state.tsx"; import { getSpaceUrl } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; @@ -50,14 +50,10 @@ export default function RecentChanges({ spaceId }: Props) { to={buildPageUrl(page?.space.slug, page.slugId, page.title)} > - {page.icon || ( - - - - )} + - {page.title || t("Untitled")} + {getPageTitle(page.title, page.isBase, t)} diff --git a/apps/client/src/components/ui/destination-picker/page-row.tsx b/apps/client/src/components/ui/destination-picker/page-row.tsx index a8f63b394..a9e068f70 100644 --- a/apps/client/src/components/ui/destination-picker/page-row.tsx +++ b/apps/client/src/components/ui/destination-picker/page-row.tsx @@ -3,6 +3,7 @@ import { ActionIcon } from "@mantine/core"; import { IconChevronRight, IconFileDescription } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { IPage } from "@/features/page/types/page.types"; +import { getPageTitle } from "@/features/page/page.utils"; import { PageChildren } from "./page-children"; import classes from "./destination-picker.module.css"; @@ -95,7 +96,7 @@ export function PageRow({
- {page.title || t("Untitled")} + {getPageTitle(page.title, page.isBase, t)}
diff --git a/apps/client/src/ee/LICENSE b/apps/client/src/ee/LICENSE index db59ed95f..fabacae06 100644 --- a/apps/client/src/ee/LICENSE +++ b/apps/client/src/ee/LICENSE @@ -1 +1,39 @@ -Files in this directory are subject to the Docmost Enterprise Edition license. \ No newline at end of file +Files in this directory are subject to the Docmost Enterprise Edition license. + + The Docmost Enterprise License (the “Enterprise License”) + Copyright (c) 2023-present Docmost, Inc + + +With regard to the Docmost Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Docmost Subscription Terms of Service, available +at https://docmost.com/terms (the “Enterprise Terms”), or other +agreement governing the use of the Software, as agreed by you and Docmost, Inc., +and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats. +Subject to the foregoing sentence, you are free to +modify this Software and publish patches to the Software. You agree that Docmost +and/or its licensors (as applicable) retain all right, title and interest in and +to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Docmost Enterprise Edition subscription for the correct +number of user seats. Notwithstanding the foregoing, you may copy and modify +the Software for development and testing purposes, without requiring a +subscription. You agree that Docmost and/or its licensors (as applicable) retain +all right, title and interest in and to all such modifications. You are not +granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Docmost Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/apps/client/src/ee/base/atoms/base-atoms.ts b/apps/client/src/ee/base/atoms/base-atoms.ts new file mode 100644 index 000000000..9404e0410 --- /dev/null +++ b/apps/client/src/ee/base/atoms/base-atoms.ts @@ -0,0 +1,55 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import { EditingCell, FocusedCell } from "@/ee/base/types/base.types"; + +// Atoms are scoped per-base via `pageId` so that two BaseTable instances on +// the same page don't share UI state. + +export const activeViewIdAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const editingCellAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export type FormulaEditorTarget = { + propertyId: string; + rowId: string | null; +} | null; + +export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) => + atom(false), +); + +export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) => + atom(0), +); + +export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) => + atom>(new Set()), +); + +export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const focusedCellAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export type PendingTypeInsert = { + rowId: string; + propertyId: string; + char: string; +} | null; + +export const pendingTypeInsertAtom = atom(null); diff --git a/apps/client/src/ee/base/atoms/formula-recompute-atom.ts b/apps/client/src/ee/base/atoms/formula-recompute-atom.ts new file mode 100644 index 000000000..320c89beb --- /dev/null +++ b/apps/client/src/ee/base/atoms/formula-recompute-atom.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const formulaRecomputeAtom = atom>({}); diff --git a/apps/client/src/ee/base/atoms/reference-store-atom.ts b/apps/client/src/ee/base/atoms/reference-store-atom.ts new file mode 100644 index 000000000..26bcdf8b6 --- /dev/null +++ b/apps/client/src/ee/base/atoms/reference-store-atom.ts @@ -0,0 +1,20 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import type { RowReferences } from "@/ee/base/types/base.types"; + +// Per-base normalized store of resolved reference entities, hydrated from each +// rows-page `references`. Keyed by pageId, matching base-atoms.ts. +export const referenceStoreAtomFamily = atomFamily((_pageId: string) => + atom({ users: {}, pages: {} }), +); + +export function mergeReferences( + prev: RowReferences, + next: RowReferences | undefined, +): RowReferences { + if (!next) return prev; + return { + users: { ...prev.users, ...next.users }, + pages: { ...prev.pages, ...next.pages }, + }; +} diff --git a/apps/client/src/ee/base/atoms/view-draft-atom.ts b/apps/client/src/ee/base/atoms/view-draft-atom.ts new file mode 100644 index 000000000..83482cf86 --- /dev/null +++ b/apps/client/src/ee/base/atoms/view-draft-atom.ts @@ -0,0 +1,21 @@ +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { BaseViewDraft } from "@/ee/base/types/base.types"; + +export type ViewDraftKey = { + userId: string; + pageId: string; + viewId: string; +}; + +export const viewDraftStorageKey = (k: ViewDraftKey) => + `docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`; + +// atomWithStorage handles JSON serialization and cross-tab sync. The custom +// comparator ensures the same userId/pageId/viewId triple resolves to the +// same atom instance, so Jotai's identity-equality cache hits still work. +export const viewDraftAtomFamily = atomFamily( + (k: ViewDraftKey) => + atomWithStorage(viewDraftStorageKey(k), null), + (a, b) => + a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId, +); diff --git a/apps/client/src/ee/base/components/base-embed-title.tsx b/apps/client/src/ee/base/components/base-embed-title.tsx new file mode 100644 index 000000000..7b4a69260 --- /dev/null +++ b/apps/client/src/ee/base/components/base-embed-title.tsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDebouncedCallback } from "@mantine/hooks"; +import { + usePageQuery, + useUpdateTitlePageMutation, + updatePageData, +} from "@/features/page/queries/page-query"; +import { useQueryEmit } from "@/features/websocket/use-query-emit"; +import { UpdateEvent } from "@/features/websocket/types"; +import localEmitter from "@/lib/local-emitter"; +import classes from "@/ee/base/styles/grid.module.css"; + +// Editable base name for the inline embed. Follows the TitleEditor convention +// (updatePageData + localEmitter + websocket emit) so the sidebar and other +// clients stay in sync. Standalone pages use the page TitleEditor instead. +export function BaseEmbedTitle({ pageId }: { pageId: string }) { + const { t } = useTranslation(); + const { data: page } = usePageQuery({ pageId }); + const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation(); + const emit = useQueryEmit(); + const [value, setValue] = useState(""); + const focusedRef = useRef(false); + + // Keep in sync with the persisted title but never clobber active user input. + useEffect(() => { + if (!focusedRef.current) setValue(page?.title ?? ""); + }, [page?.title]); + + const commit = useCallback(() => { + const trimmed = value.trim(); + if (!page || trimmed === (page.title ?? "")) return; + updateTitleAsync({ pageId, title: trimmed }).then((updated) => { + if (updated.title !== trimmed) return; + const event: UpdateEvent = { + operation: "updateOne", + spaceId: updated.spaceId, + entity: ["pages"], + id: updated.id, + payload: { + title: updated.title, + slugId: updated.slugId, + parentPageId: updated.parentPageId, + icon: updated.icon, + }, + }; + updatePageData(updated); + localEmitter.emit("message", event); + emit(event); + }); + }, [value, page, pageId, updateTitleAsync, emit]); + + const debouncedCommit = useDebouncedCallback(commit, 500); + + // Force-save any pending edit on unmount (e.g. navigating away mid-type). + const commitRef = useRef(commit); + useEffect(() => { + commitRef.current = commit; + }, [commit]); + useEffect(() => () => commitRef.current(), []); + + return ( + { + setValue(e.currentTarget.value); + debouncedCommit(); + }} + onFocus={() => { + focusedRef.current = true; + }} + onBlur={() => { + focusedRef.current = false; + commit(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + setValue(page?.title ?? ""); + e.currentTarget.blur(); + } + }} + /> + ); +} diff --git a/apps/client/src/ee/base/components/base-table-skeleton.tsx b/apps/client/src/ee/base/components/base-table-skeleton.tsx new file mode 100644 index 000000000..cd7dfc6fd --- /dev/null +++ b/apps/client/src/ee/base/components/base-table-skeleton.tsx @@ -0,0 +1,92 @@ +import { Skeleton } from "@mantine/core"; +import gridClasses from "@/ee/base/styles/grid.module.css"; +import classes from "@/ee/base/styles/base-table-skeleton.module.css"; + +const ROW_NUMBER_WIDTH = 64; +const COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_COUNT = 6; +const DEFAULT_ROW_COUNT = 10; + +// Deterministic widths prevent flicker between renders. +const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66]; +const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54]; + +type BaseTableSkeletonProps = { + // Match the eventual content shape to avoid a jarring size jump on swap. + rows?: number; + columns?: number; +}; + +export function BaseTableSkeleton({ + rows = DEFAULT_ROW_COUNT, + columns = DEFAULT_COLUMN_COUNT, +}: BaseTableSkeletonProps = {}) { + const gridTemplateColumns = [ + `${ROW_NUMBER_WIDTH}px`, + ...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`), + ].join(" "); + + return ( +
+
+
+ + + +
+
+ + + + +
+
+ +
+
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ + +
+
+ ))} + + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ +
+
+ ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-table.tsx b/apps/client/src/ee/base/components/base-table.tsx new file mode 100644 index 000000000..be449c3f9 --- /dev/null +++ b/apps/client/src/ee/base/components/base-table.tsx @@ -0,0 +1,70 @@ +import { GridContainer } from "@/ee/base/components/grid/grid-container"; +import { Table } from "@tanstack/react-table"; +import { + IBase, + IBaseRow, + IBaseView, +} from "@/ee/base/types/base.types"; + +type BaseTableProps = { + base: IBase; + rows: IBaseRow[]; + effectiveView: IBaseView | undefined; + table: Table; + pageId: string; + embedded?: boolean; + isFiltered: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onFetchNextPage: () => void; + onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; + onAddRow: (afterRowId?: string, focusPropertyId?: string) => void; + onColumnReorder: (columnId: string, finishIndex: number) => void; + onResizeEnd: () => void; + onRowReorder: ( + rowId: string, + targetRowId: string, + dropPosition: "above" | "below", + ) => void; + persistViewConfig: () => void; + scrollportRef: React.RefObject; + aboveBand?: React.ReactNode; +}; + +export function BaseTable({ + base, + rows: _rows, + table, + pageId, + embedded, + isFiltered, + hasNextPage, + isFetchingNextPage, + onFetchNextPage, + onCellUpdate, + onAddRow, + onColumnReorder, + onResizeEnd, + onRowReorder, + scrollportRef, + aboveBand, +}: BaseTableProps) { + return ( + + ); +} diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx new file mode 100644 index 000000000..213ce5874 --- /dev/null +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -0,0 +1,295 @@ +import { useState, useCallback, useMemo } from "react"; +import { ActionIcon, Tooltip, Badge } from "@mantine/core"; +import { Table } from "@tanstack/react-table"; +import { + IconSortAscending, + IconFilter, + IconEye, + IconDownload, + IconArrowsDiagonal, + IconLayoutColumns, + IconAdjustments, +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { + IBase, + IBaseRow, + IBaseView, + ViewSortConfig, + FilterCondition, + FilterGroup, +} from "@/ee/base/types/base.types"; +import { exportBaseToCsv } from "@/ee/base/services/base-service"; +import { getApiErrorMessage } from "@/lib/api-error"; +import { ViewTabs } from "@/ee/base/components/views/view-tabs"; +import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config"; +import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config"; +import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility"; +import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker"; +import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties"; +import { useTranslation } from "react-i18next"; +import classes from "@/ee/base/styles/grid.module.css"; +import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; + +type BaseToolbarProps = { + base: IBase; + activeView: IBaseView | undefined; + views: IBaseView[]; + table?: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + canAddView?: boolean; + onPersistViewConfig: () => void; + onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; + onDraftFiltersChange: (filter: FilterGroup | undefined) => void; + onExpand?: () => void; + getViewShareUrl?: (viewId: string) => string | null; +}; + +export function BaseToolbar({ + base, + activeView, + views, + table, + onViewChange, + onAddView, + canAddView, + onPersistViewConfig, + onDraftSortsChange, + onDraftFiltersChange, + onExpand, + getViewShareUrl, +}: BaseToolbarProps) { + const { t } = useTranslation(); + const [sortOpened, setSortOpened] = useState(false); + const [filterOpened, setFilterOpened] = useState(false); + const [propertiesOpened, setPropertiesOpened] = useState(false); + const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false); + const [exporting, setExporting] = useState(false); + + const isKanban = activeView?.type === "kanban"; + + const handleExport = useCallback(async () => { + if (exporting) return; + setExporting(true); + try { + await exportBaseToCsv(base.id); + } catch (err) { + notifications.show({ + color: "red", + message: getApiErrorMessage(err, t("Failed to export CSV")), + }); + } finally { + setExporting(false); + } + }, [base.id, exporting, t]); + + const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => { + setSortOpened(panel === "sort" ? (v) => !v : false); + setFilterOpened(panel === "filter" ? (v) => !v : false); + setPropertiesOpened(panel === "properties" ? (v) => !v : false); + }, []); + + const sorts = activeView?.config?.sorts ?? []; + const conditions = useMemo(() => { + const filter = activeView?.config?.filter; + if (!filter || filter.op !== "and") return []; + return filter.children.filter( + (c): c is FilterCondition => !("children" in c), + ); + }, [activeView?.config?.filter]); + + const hiddenPropertyCount = useMemo(() => { + if (!table) return 0; + const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); + return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length; + }, [table, table?.getState().columnVisibility]); + + const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); + }, + [onDraftSortsChange], + ); + + const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + onDraftFiltersChange(filter); + }, + [onDraftFiltersChange], + ); + + return ( +
+ + +
+ + + + + + + setFilterOpened(false)} + conditions={conditions} + properties={base.properties} + onChange={handleFiltersChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("filter")} + > + + {conditions.length > 0 && ( + + {conditions.length} + + )} + + + + + {isKanban && activeView && ( + <> + + + + + + + + + setCardPropertiesOpened(false)} + base={base} + view={activeView} + pageId={base.id} + > + + setCardPropertiesOpened((v) => !v)} + > + + + + + + )} + + {!isKanban && ( + <> + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + {table && ( + setPropertiesOpened(false)} + table={table} + properties={base.properties} + onPersist={onPersistViewConfig} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("properties")} + > + + {hiddenPropertyCount > 0 && ( + + {hiddenPropertyCount} + + )} + + + + )} + + )} + + {onExpand && ( + + + + + + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-view-draft-banner.tsx b/apps/client/src/ee/base/components/base-view-draft-banner.tsx new file mode 100644 index 000000000..cda3c9f15 --- /dev/null +++ b/apps/client/src/ee/base/components/base-view-draft-banner.tsx @@ -0,0 +1,45 @@ +import { Group, Button, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type BaseViewDraftBannerProps = { + isDirty: boolean; + canSave: boolean; + onReset: () => void; + onSave: () => void; + saving: boolean; +}; + +export function BaseViewDraftBanner({ + isDirty, + canSave, + onReset, + onSave, + saving, +}: BaseViewDraftBannerProps) { + const { t } = useTranslation(); + if (!isDirty) return null; + return ( + + + {canSave && ( + + + + )} + + ); +} diff --git a/apps/client/src/ee/base/components/base-view.tsx b/apps/client/src/ee/base/components/base-view.tsx new file mode 100644 index 000000000..22612f265 --- /dev/null +++ b/apps/client/src/ee/base/components/base-view.tsx @@ -0,0 +1,541 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Text, Stack } from "@mantine/core"; +import { useAtom } from "jotai"; +import { IconTable } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; +import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; +import { useBaseQuery } from "@/ee/base/queries/base-query"; +import { useBaseSocket } from "@/ee/base/hooks/use-base-socket"; +import { + FilterGroup, + ViewSortConfig, + EditingCell, + FocusedCell, + IBaseProperty, +} from "@/ee/base/types/base.types"; +import { + useBaseRowsQuery, + flattenRows, + useCreateRowMutation, + useUpdateRowMutation, + useReorderRowMutation, +} from "@/ee/base/queries/base-row-query"; +import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query"; +import { + activeViewIdAtomFamily, + editingCellAtomFamily, + focusedCellAtomFamily, +} from "@/ee/base/atoms/base-atoms"; +import { useBaseTable } from "@/ee/base/hooks/use-base-table"; +import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry"; +import { useRowSelection } from "@/ee/base/hooks/use-row-selection"; +import useCurrentUser from "@/features/user/hooks/use-current-user"; +import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store"; +import { useViewDraft } from "@/ee/base/hooks/use-view-draft"; +import { BaseToolbar } from "@/ee/base/components/base-toolbar"; +import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner"; +import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title"; +import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton"; +import { ViewRenderer } from "@/ee/base/components/views/view-renderer"; +import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal"; +import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal"; +import { BaseEditableProvider } from "@/ee/base/context/base-editable"; +import { RowExpandProvider } from "@/ee/base/context/row-expand"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { getAppUrl } from "@/lib/config.ts"; +import { useNavigate } from "react-router-dom"; +import classes from "@/ee/base/styles/grid.module.css"; +import viewClasses from "@/ee/base/styles/base-view.module.css"; +import kanbanClasses from "@/ee/base/styles/kanban.module.css"; + +type BaseViewProps = { + pageId: string; + embedded?: boolean; + /** False makes the view read-only. Standalone passes page.permissions.canEdit; + * embedded ANDs that with the host editor's editability. */ + editable?: boolean; + titleSlot?: React.ReactNode; +}; + +export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) { + const { t } = useTranslation(); + // Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache. + useBaseSocket(pageId); + const { data: base, isLoading: baseLoading, error: baseError } = + useBaseQuery(pageId); + + const navigate = useNavigate(); + const { data: page } = usePageQuery({ pageId }); + const handleExpand = useCallback(() => { + if (!page) return; + navigate(buildPageUrl(page.space?.slug, page.slugId, page.title)); + }, [navigate, page]); + + // Share URL for a specific view; always points at the standalone page where ?view= is honored. + const getViewShareUrl = useCallback( + (viewId: string) => + page + ? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}` + : null, + [page], + ); + + const [activeViewId, setActiveViewId] = useAtom( + activeViewIdAtomFamily(pageId), + ) as unknown as [string | null, (val: string | null) => void]; + + const [, setEditingCell] = useAtom( + editingCellAtomFamily(pageId), + ) as unknown as [EditingCell, (val: EditingCell) => void]; + + const [, setFocusedCell] = useAtom( + focusedCellAtomFamily(pageId), + ) as unknown as [FocusedCell, (val: FocusedCell) => void]; + + const views = useMemo( + () => + [...(base?.views ?? [])].sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ), + [base?.views], + ); + const activeView = useMemo(() => { + if (!views.length) return undefined; + return views.find((v) => v.id === activeViewId) ?? views[0]; + }, [views, activeViewId]); + + const { data: currentUser } = useCurrentUser(); + useHydrateCurrentUser(pageId); + const { + effectiveFilter, + effectiveSorts, + isDirty, + setFilter: setDraftFilter, + setSorts: setDraftSorts, + reset: resetDraft, + buildPromotedConfig, + } = useViewDraft({ + userId: currentUser?.user.id, + pageId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, + }); + + // Baseline merged with local draft. Used for table state and toolbar badge counts. + // The real activeView remains the auto-persist baseline so drafts can't leak into layout writes. + const effectiveView = useMemo( + () => + activeView + ? { + ...activeView, + config: { + ...activeView.config, + filter: effectiveFilter, + sorts: effectiveSorts, + }, + } + : undefined, + [activeView, effectiveFilter, effectiveSorts], + ); + + const activeFilter = effectiveFilter; + const activeSorts = effectiveSorts; + + const canSave = editable; + + // Gate on base to avoid a "bland" list request before the active view's + // config resolves, which would double network traffic for sorted/filtered views. + const isKanban = activeView?.type === "kanban"; + + const { + data: rowsData, + isLoading: rowsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts); + + const updateRowMutation = useUpdateRowMutation(); + const createRowMutation = useCreateRowMutation(); + const reorderRowMutation = useReorderRowMutation(); + const updateViewMutation = useUpdateViewMutation(); + + useEffect(() => { + if (activeView && activeViewId !== activeView.id) { + setActiveViewId(activeView.id); + } + }, [activeView, activeViewId, setActiveViewId]); + + // Deep link: apply ?view= once after views load; skip if the id is + // unrecognised so we fall back to the default without fighting a later tab switch. + const appliedViewParamRef = useRef(false); + useEffect(() => { + if (appliedViewParamRef.current || views.length === 0) return; + const viewParam = new URLSearchParams(window.location.search).get("view"); + if (viewParam && views.some((v) => v.id === viewParam)) { + setActiveViewId(viewParam); + } + appliedViewParamRef.current = true; + }, [views, setActiveViewId]); + + const { clear: clearSelection } = useRowSelection(pageId); + useEffect(() => { + clearSelection(); + }, [pageId, activeView?.id, clearSelection]); + + const scrollportRef = useRef(null); + + const rows = useMemo(() => { + const flat = flattenRows(rowsData); + // With an active sort the server returns rows in sort order via keyset + // pagination; re-sorting by position on the client would break it as more + // pages load. Position sort only applies when no view sort is active. + if (activeSorts && activeSorts.length > 0) { + return flat; + } + return flat.sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ); + }, [rowsData, activeSorts]); + const rowsRef = useRef(rows); + rowsRef.current = rows; + + const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView); + + const guardedPersistViewConfig = useCallback(() => { + if (!editable) return; + persistViewConfig(); + }, [editable, persistViewConfig]); + + // Mutation result objects change identity every render; only .mutate is + // stable. Rows are memoized on these callbacks' identities, so they must + // not churn with unrelated re-renders. + const updateRow = updateRowMutation.mutate; + const handleCellUpdate = useCallback( + (rowId: string, propertyId: string, value: unknown) => { + if (!editable) return; + updateRow({ + rowId, + pageId, + cells: { [propertyId]: value }, + }); + }, + [editable, pageId, updateRow], + ); + + const handleAddRow = useCallback( + (afterRowId?: string, focusPropertyId?: string) => { + if (!editable) return; + createRowMutation.mutate( + { pageId, ...(afterRowId ? { afterRowId } : {}) }, + { + onSuccess: (newRow) => { + let propertyId = focusPropertyId; + if (!propertyId) { + const firstEditable = table.getVisibleLeafColumns().find((col) => { + if (col.id === "__row_number") return false; + const prop = col.columnDef.meta?.property as + | IBaseProperty + | undefined; + return ( + !!prop && + prop.type !== "checkbox" && + !isSystemPropertyType(prop.type) + ); + }); + propertyId = ( + firstEditable?.columnDef.meta?.property as + | IBaseProperty + | undefined + )?.id; + } + if (propertyId) { + setEditingCell({ rowId: newRow.id, propertyId }); + setFocusedCell({ rowId: newRow.id, propertyId }); + } + }, + }, + ); + }, + [editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell], + ); + + const handleViewChange = useCallback( + (viewId: string) => { + setActiveViewId(viewId); + }, + [setActiveViewId], + ); + + const handleColumnReorder = useCallback( + (columnId: string, finishIndex: number) => { + const order = table.getState().columnOrder; + const startIndex = order.indexOf(columnId); + if (startIndex === -1 || startIndex === finishIndex) return; + table.setColumnOrder(reorder({ list: order, startIndex, finishIndex })); + guardedPersistViewConfig(); + }, + [table, guardedPersistViewConfig], + ); + + const handleResizeEnd = useCallback(() => { + guardedPersistViewConfig(); + }, [guardedPersistViewConfig]); + + const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[] | undefined) => { + setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined); + }, + [setDraftSorts], + ); + + const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => { + setDraftFilter(filter); + }, + [setDraftFilter], + ); + + const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + // Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts. + const config = buildPromotedConfig(activeView.config); + try { + await updateViewMutation.mutateAsync({ + viewId: activeView.id, + pageId: base.id, + config, + }); + resetDraft(); + notifications.show({ message: t("View updated for everyone") }); + } catch { + // useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry. + } + }, [ + activeView, + base, + buildPromotedConfig, + resetDraft, + t, + updateViewMutation, + ]); + + const { openRowId, openRow, closeRow } = useRowDetailModal(pageId); + // openRow's identity tracks searchParams; rows subscribe to the expand + // context, so hand them a stable wrapper instead. + const openRowRef = useRef(openRow); + openRowRef.current = openRow; + const handleExpandRow = useCallback((rowId: string) => { + openRowRef.current(rowId); + }, []); + const handleRowNavigate = useCallback((rowId: string) => { + openRowRef.current(rowId, { replace: true }); + }, []); + + const reorderRow = reorderRowMutation.mutate; + const handleRowReorder = useCallback( + (rowId: string, targetRowId: string, dropPosition: "above" | "below") => { + if (!editable) return; + const remainingRows = rowsRef.current.filter((r) => r.id !== rowId); + const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId); + if (targetIndex === -1) return; + + let lowerPos: string | null = null; + let upperPos: string | null = null; + if (dropPosition === "above") { + lowerPos = + targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null; + upperPos = remainingRows[targetIndex]?.position ?? null; + } else { + lowerPos = remainingRows[targetIndex]?.position ?? null; + upperPos = + targetIndex < remainingRows.length - 1 + ? remainingRows[targetIndex + 1]?.position + : null; + } + + try { + let newPosition: string; + if (lowerPos && upperPos && lowerPos === upperPos) { + newPosition = generateJitteredKeyBetween(lowerPos, null); + } else { + newPosition = generateJitteredKeyBetween(lowerPos, upperPos); + } + reorderRow({ rowId, pageId, position: newPosition }); + } catch { + // Position computation failed; skip silently. + } + }, + [editable, pageId, reorderRow], + ); + + if (baseLoading || (!isKanban && rowsLoading)) { + return ; + } + if (baseError) { + return ( + + + {t("Failed to load base")} + + ); + } + if (!base) return null; + + // Ghost rows are an "empty base" affordance, not a "filter matched nothing" state. + const isFiltered = (activeFilter?.children?.length ?? 0) > 0; + + const banner = ( + + ); + + const toolbar = ( + + ); + + const kanbanBand = ( +
+ {embedded ? null : titleSlot} + {banner} + {toolbar} + {embedded ? : null} +
+ ); + + const viewRenderer = (folded: React.ReactNode) => ( + + ); + + if (embedded) { + if (isKanban) { + return ( + + + {kanbanBand} + {viewRenderer(null)} + + + + ); + } + + // Banner and toolbar go into aboveBand so they scroll with the host document; + // only the column-header row stays pinned (via --sticky-band-top). + return ( + + + {viewRenderer( + <> + {banner} + {toolbar} + + , + )} + + + + ); + } + + if (isKanban) { + return ( + +
+ + {kanbanBand} + {viewRenderer(null)} + +
+ +
+ ); + } + + // Standalone: title, banner, and toolbar go in aboveBand inside the scroll + // container so they scroll away; only the column-header row stays pinned. + return ( + +
+
+ + {viewRenderer( + <> + {titleSlot} + {banner} + {toolbar} + , + )} + +
+
+ +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/badge-overflow.tsx b/apps/client/src/ee/base/components/cells/badge-overflow.tsx new file mode 100644 index 000000000..278b84472 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/badge-overflow.tsx @@ -0,0 +1,100 @@ +import { ReactElement, useLayoutEffect, useRef, useState } from "react"; +import { Tooltip } from "@mantine/core"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +export function computeVisibleBadgeCount( + itemWidths: number[], + gap: number, + available: number, + badgeWidth: number, +): number { + const count = itemWidths.length; + if (count === 0) return 0; + if (available <= 0) return count; + + let lineWidth = 0; + for (let i = 0; i < count; i++) { + lineWidth += itemWidths[i] + (i > 0 ? gap : 0); + } + if (lineWidth <= available) return count; + + let used = 0; + let fit = 0; + for (let i = 0; i < count; i++) { + const advance = itemWidths[i] + (i > 0 ? gap : 0); + if (used + advance + gap + badgeWidth <= available) { + used += advance; + fit = i + 1; + } else { + break; + } + } + return Math.max(fit, 1); +} + +const BADGE_GAP = 4; + +type BadgeOverflowListProps = { + chips: ReactElement[]; + measureKey: string; + tooltipLabel?: string; +}; + +export function BadgeOverflowList({ + chips, + measureKey, + tooltipLabel, +}: BadgeOverflowListProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(chips.length); + + useLayoutEffect(() => { + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure) return; + + const recompute = () => { + const nodes = Array.from(measure.children) as HTMLElement[]; + const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth); + const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0; + setVisibleCount( + computeVisibleBadgeCount( + chipWidths, + BADGE_GAP, + container.clientWidth, + badgeWidth, + ), + ); + }; + + recompute(); + const observer = new ResizeObserver(recompute); + observer.observe(container); + return () => observer.disconnect(); + }, [measureKey]); + + const visible = chips.slice(0, visibleCount); + const overflow = chips.length - visibleCount; + + return ( + +
+
+ {chips} + +{chips.length} +
+ {visible} + {overflow > 0 && ( + +{overflow} + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-checkbox.tsx b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx new file mode 100644 index 000000000..163359dbd --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { Checkbox } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCheckboxProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) { + const checked = value === true; + + const handleChange = useCallback(() => { + if (readOnly) return; + onCommit(!checked); + }, [readOnly, checked, onCommit]); + + return ( +
+ {}} + size="xs" + tabIndex={-1} + styles={{ + input: { + cursor: readOnly ? "default" : "pointer", + pointerEvents: "none", + }, + }} + /> +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-created-at.tsx b/apps/client/src/ee/base/components/cells/cell-created-at.tsx new file mode 100644 index 000000000..21286843c --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-created-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCreatedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCreatedAt({ value }: CellCreatedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-date.tsx b/apps/client/src/ee/base/components/cells/cell-date.tsx new file mode 100644 index 000000000..a4af25a15 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-date.tsx @@ -0,0 +1,146 @@ +import { useCallback } from "react"; +import { Popover } from "@mantine/core"; +import { DatePicker } from "@mantine/dates"; +import { + IBaseProperty, + DateTypeOptions, +} from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellDateProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function formatDateDisplay( + dateStr: string | null | undefined, + options: DateTypeOptions | undefined, +): string { + if (!dateStr) return ""; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ""; + + const months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const month = months[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + + let result = `${month} ${day}, ${year}`; + + if (options?.includeTime) { + if (options.timeFormat === "24h") { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + result += ` ${hours}:${minutes}`; + } else { + let hours = date.getHours(); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12 || 12; + const minutes = String(date.getMinutes()).padStart(2, "0"); + result += ` ${hours}:${minutes} ${ampm}`; + } + } + + return result; + } catch { + return ""; + } +} + +function toISODateString(dateStr: string | null): string | null { + if (!dateStr) return null; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return null; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } catch { + return null; + } +} + +export function CellDate({ + value, + property, + isEditing, + onCommit, + onCancel, +}: CellDateProps) { + const typeOptions = property.typeOptions as DateTypeOptions | undefined; + const dateStr = typeof value === "string" ? value : null; + const pickerValue = toISODateString(dateStr); + + const handleChange = useCallback( + (selected: string | null) => { + if (selected) { + const date = new Date(selected); + onCommit(date.toISOString()); + } else { + onCommit(null); + } + }, + [onCommit], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [onCancel], + ); + + if (isEditing) { + return ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width="auto" + trapFocus + closeOnClickOutside + closeOnEscape + > + +
+ + {formatDateDisplay(dateStr, typeOptions)} + +
+
+ + + +
+ ); + } + + if (!dateStr) { + return ; + } + + return ( + + {formatDateDisplay(dateStr, typeOptions)} + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-email.tsx b/apps/client/src/ee/base/components/cells/cell-email.tsx new file mode 100644 index 000000000..0d93f69fa --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-email.tsx @@ -0,0 +1,61 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { Tooltip } from "@mantine/core"; +import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellEmailProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); +const parse = (draft: string) => draft || null; + +export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) { + const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = + useEditableTextCell({ + value, + isEditing, + onCommit, + onCancel, + toDraft, + parse, + rowId, + propertyId: property.id, + }); + + if (isEditing) { + return ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + const displayValue = toDraft(value); + if (!displayValue) { + return ; + } + return ( + + e.stopPropagation()} + > + {displayValue} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-file.tsx b/apps/client/src/ee/base/components/cells/cell-file.tsx new file mode 100644 index 000000000..3d3bcd247 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-file.tsx @@ -0,0 +1,236 @@ +import { useState, useRef, useCallback } from "react"; +import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core"; +import { + IconPaperclip, + IconUpload, + IconFile, + IconX, +} from "@tabler/icons-react"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; +import { uploadFile } from "@/features/page/services/page-service"; +import { getFileUrl } from "@/lib/config"; + +export type FileValue = { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + url?: string; +}; + +function buildFileUrl(file: Pick): string { + return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`; +} + +type CellFileProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +function formatFileSize(bytes?: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function parseFiles(value: unknown): FileValue[] { + if (!Array.isArray(value)) return []; + return value.filter( + (f): f is FileValue => + f && typeof f === "object" && "id" in f && "fileName" in f, + ); +} + +export function CellFile({ + value, + property, + isEditing, + readOnly, + onCommit, + onCancel, +}: CellFileProps) { + const files = parseFiles(value); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleRemove = useCallback( + (fileId: string) => { + if (readOnly) return; + const updated = files.filter((f) => f.id !== fileId); + onCommit(updated.length > 0 ? updated : null); + }, + [readOnly, files, onCommit], + ); + + const handleUpload = useCallback( + async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + setUploading(true); + + const newFiles: FileValue[] = [...files]; + + // Reuse the page-attachment upload pipeline: the base's pageId is passed + // to the standard /files/upload endpoint, which enforces the same edit + // access check as any other page attachment. + for (const file of Array.from(fileList)) { + try { + const attachment = await uploadFile(file, property.pageId); + newFiles.push({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`, + }); + } catch (err) { + console.error("File upload failed:", err); + } + } + + setUploading(false); + onCommit(newFiles.length > 0 ? newFiles : null); + }, + [files, property.pageId, onCommit], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [onCancel], + ); + + const MAX_VISIBLE = 2; + + if (isEditing) { + return ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width={280} + trapFocus + closeOnClickOutside + closeOnEscape + hideDetached={false} + > + +
+ +
+
+ + {!readOnly && files.length === 0 && !uploading && ( + + No files attached + + )} + + {files.map((file) => ( + + ))} + + {!readOnly && ( + <> + { + handleUpload(e.target.files); + e.target.value = ""; + }} + /> + + fileInputRef.current?.click()} + disabled={uploading} + className={cellClasses.fileUploadBtn} + style={{ + color: uploading + ? "var(--mantine-color-gray-5)" + : "var(--mantine-color-blue-6)", + }} + > + + {uploading ? "Uploading..." : "Add file"} + + + )} + +
+ ); + } + + if (files.length === 0) { + return ; + } + + return ; +} + +function FileList({ + files, + maxVisible, +}: { + files: FileValue[]; + maxVisible: number; +}) { + const visible = files.slice(0, maxVisible); + const overflow = files.length - maxVisible; + + return ( +
+ {visible.map((file) => ( + + + {file.fileName} + + ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-formula.tsx b/apps/client/src/ee/base/components/cells/cell-formula.tsx new file mode 100644 index 000000000..bdaaf11cf --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-formula.tsx @@ -0,0 +1,38 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { + IBaseProperty, + isFormulaErrorCell, +} from "@/ee/base/types/base.types"; +import { CellText } from "./cell-text"; +import { CellNumber } from "./cell-number"; +import { CellCheckbox } from "./cell-checkbox"; +import { CellDate } from "./cell-date"; + +type Props = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellFormula(props: Props) { + const { value, property } = props; + if (isFormulaErrorCell(value)) { + return ( + + + #ERROR + + + ); + } + const opts = (property.typeOptions ?? {}) as { resultType?: string }; + const resultType = opts.resultType ?? "null"; + const readOnlyProps = { ...props, isEditing: false }; + if (resultType === "number") return ; + if (resultType === "boolean") return ; + if (resultType === "date") return ; + return ; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx new file mode 100644 index 000000000..efad990de --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLastEditedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellLastEditedAt({ value }: CellLastEditedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx new file mode 100644 index 000000000..b6dc90c52 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx @@ -0,0 +1,41 @@ +import { Group, Tooltip } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { useReferenceStore } from "@/ee/base/reference/reference-store"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLastEditedByProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellLastEditedBy({ value, property }: CellLastEditedByProps) { + const userId = typeof value === "string" ? value : null; + + const store = useReferenceStore(property.pageId); + const user = userId ? store.users[userId] ?? null : null; + + if (!userId) { + return ; + } + + const name = user?.name ?? userId.substring(0, 8); + + return ( + + + + {name} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx new file mode 100644 index 000000000..2c3900562 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "react"; +import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLongTextProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onValueChange: (value: unknown) => void; + onCancel: () => void; + onTabNavigate?: (shiftKey: boolean) => void; +}; + +const toText = (value: unknown) => (typeof value === "string" ? value : ""); +const normalize = (s: string) => { + const trimmed = s.trim(); + return trimmed.length ? trimmed : null; +}; + +export function CellLongText({ + value, + isEditing, + onCommit, + onValueChange, + onCancel, + onTabNavigate, +}: CellLongTextProps) { + const [draft, setDraft] = useState(() => toText(value)); + const cancelledRef = useRef(false); + const committedRef = useRef(false); + const wasEditingRef = useRef(false); + const textareaRef = useRef(null); + + // Seed draft and focus on the false->true editing transition only; ignore + // value changes mid-edit so the user's typing is not clobbered. + useEffect(() => { + if (isEditing && !wasEditingRef.current) { + cancelledRef.current = false; + committedRef.current = false; + setDraft(toText(value)); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }); + } + wasEditingRef.current = isEditing; + }, [isEditing, value]); + + // Autosave after a typing pause; commit/cancel clear the pending fire so + // a closed editor can never write a stale or discarded draft. + const debouncedAutosave = useDebouncedCallback(() => { + onValueChange(normalize(draft)); + }, 10_000); + + const commit = () => { + if (committedRef.current) return; + committedRef.current = true; + debouncedAutosave.cancel(); + onCommit(normalize(draft)); + }; + const cancel = () => { + cancelledRef.current = true; + debouncedAutosave.cancel(); + onCancel(); + }; + + const preview = formatLongTextPreview(toText(value)); + + return ( + { + if (opened) return; + // Programmatic close after cancel must not re-commit. + if (cancelledRef.current) { + cancelledRef.current = false; + return; + } + commit(); + }} + position="bottom-start" + width={320} + shadow="md" + withinPortal + closeOnClickOutside + closeOnEscape={false} + trapFocus + > + +
+ {preview ? ( + + {preview} + + ) : ( + + )} +
+
+ e.stopPropagation()} + className={cellClasses.longTextDropdown} + > + {isEditing && ( + <> + + + +