Compare commits

..

297 Commits

Author SHA1 Message Date
Philipinho 237bbe1f6c kanban - wip 2026-05-28 17:03:52 +01:00
Philipinho 6935a73e4f fix(bases): axis-aware kanban auto-scroll overflow matches reference example 2026-05-27 23:41:53 +01:00
Philipinho 48ff82800b fix(bases): make kanban board claim remaining flex height so columns scroll 2026-05-25 16:44:22 +01:00
Philipinho bd194c8bd5 fix(bases): widen kanban auto-scroll hitbox and allow cursor overflow 2026-05-25 16:27:55 +01:00
Philipinho f0b7bdef17 fix(bases): give kanban its own scroll container so column auto-scroll works 2026-05-25 15:55:56 +01:00
Philipinho ee3c5ce9d9 fix(bases): render filtered rows on first paint in standalone view
Track the scrollport element in state instead of reading
`scrollportRef.current` during render. The ref was always null on the
render that mounts the `.tableScrollport` div, so `useVirtualizer`'s
`_willUpdate` saw `scrollElement=null`, skipped observer attachment, and
`calculateRange` returned null — rendering zero rows even though the
`/rows` response was already in the React-Query cache.

The bug surfaced after a filter change (the `rowsLoading` skeleton path
remounts the scrollport, and no follow-on render is guaranteed once
`/rows` settles) but not on first base load (slower side queries forced
an extra render that coincidentally re-bound the virtualizer). Switching
views also masked it: the re-render triggered `_willUpdate` with a now-
populated ref.

Using a callback-ref-backed `useState` triggers a render the moment the
div attaches, so the virtualizer picks it up on the next pass — no
view-switch workaround needed.
2026-05-25 15:53:28 +01:00
Philipinho d1ebeffe19 feat(bases): clear kanban grouping when its property changes to a non-groupable type 2026-05-24 16:26:57 +01:00
Philipinho 2acd55c38d collab 2026-05-24 16:24:31 +01:00
Philipinho 4412009194 feat(bases): auto-fetch next rows when kanban scrolls to its end 2026-05-24 16:22:21 +01:00
Philipinho abf75ec90b feat(bases): auto-scroll the kanban board and columns during drag 2026-05-24 16:20:29 +01:00
Philipinho dba47956b1 feat(bases): block intra-column drops while sort is active + add hint 2026-05-24 16:17:36 +01:00
Philipinho c91f89a055 feat(bases): open detail modal for cards created via per-column '+ New' 2026-05-24 16:15:11 +01:00
Philipinho 6cdceede0b feat(bases): allow adding new properties from inside the detail modal 2026-05-24 16:13:54 +01:00
Philipinho f5602e9bcf feat(bases): render editable property rows inside detail modal 2026-05-24 16:10:31 +01:00
Philipinho 9237e94769 feat(bases): row detail modal shell with editable title 2026-05-24 16:04:29 +01:00
Philipinho e071de9248 feat(bases): add URL-driven row detail modal hook 2026-05-24 16:01:50 +01:00
Philipinho aebd54f13d refactor(bases): guard kanban column drag from menu clicks; polish hidden chips strip 2026-05-24 15:59:53 +01:00
Philipinho a7d390932d feat(bases): hide and show kanban columns from header menu 2026-05-24 15:53:49 +01:00
Philipinho beb7120b00 feat(bases): reorder kanban columns via drag, persisting choiceOrder 2026-05-24 15:48:51 +01:00
Philipinho 83d52fc324 fix(bases): suppress kanban column-body drop when a card target also matched 2026-05-24 15:45:24 +01:00
Philipinho d97d8108d2 feat(bases): drop cards onto empty kanban columns / below last card 2026-05-24 15:38:34 +01:00
Philipinho bdfd0413b4 feat(bases): card-to-card kanban drag with edge slotting 2026-05-24 15:34:40 +01:00
Philipinho a9c6051d12 feat(bases): add per-column '+ New' button to kanban 2026-05-24 13:51:20 +01:00
Philipinho 76ca68bec6 feat(bases): surface group-by picker in toolbar for kanban views 2026-05-24 13:46:41 +01:00
Philipinho c350308c03 feat(bases): add kanban empty state with group-by picker 2026-05-24 13:42:35 +01:00
Philipinho f36df26d75 feat(bases): dispatch kanban view through ViewRenderer 2026-05-24 13:38:11 +01:00
Philipinho ee4bf73c92 refactor(bases): rename KanbanColumn helper type to KanbanColumnData; clsx + font vars 2026-05-24 13:36:12 +01:00
Philipinho 68cdcd970c feat(bases): scaffold kanban renderer (no DnD yet) 2026-05-24 13:27:25 +01:00
Philipinho dfd6d3aee0 test(bases): pin resolveCardDrop catch-fallback behavior + fix comment 2026-05-24 13:24:36 +01:00
Philipinho ad0e65371b feat(bases): add resolveCardDrop helper for kanban drag mutations 2026-05-24 13:20:31 +01:00
Philipinho fd6f6a9341 fix(bases): always render kanban NO_VALUE column unless explicitly hidden 2026-05-24 13:13:12 +01:00
Philipinho a60de83e57 feat(bases): add useKanbanGroups partitioning hook 2026-05-24 13:09:26 +01:00
Philipinho f75779951e refactor(bases): split BaseTable into BaseView shell + ViewRenderer 2026-05-24 13:00:20 +01:00
Philipinho 6b3babb3de fix(bases): strip all property-id refs from view config on delete 2026-05-24 12:52:05 +01:00
Philipinho b8192e69d1 feat(bases): cascade-clean view configs when a property is deleted 2026-05-24 12:41:14 +01:00
Philipinho 46c5960e99 feat(base): clearer warning copy for url/email type conversions 2026-05-24 12:37:56 +01:00
Philipinho 38cdf1267a fix(base): always rewrite cells on type change so renderers see correct shape 2026-05-24 12:37:52 +01:00
Philipinho 9cec9b64c6 feat(bases): allow updateRow to set position atomically 2026-05-24 12:31:36 +01:00
Philipinho a793e65560 fix(base): suppress prosemirror dropcursor inside base-embed atom 2026-05-24 12:31:08 +01:00
Philipinho b9ab95af4e Revert "fix(base): isolate inline-embed drags from prosemirror dropcursor"
This reverts commit 3c62331826.
2026-05-24 12:28:16 +01:00
Philipinho a60febc92f feat(bases): add kanban view config fields to schema 2026-05-24 12:26:36 +01:00
Philipinho 3c62331826 fix(base): isolate inline-embed drags from prosemirror dropcursor 2026-05-24 02:50:49 +01:00
Philipinho b83b92bea6 fix(base): invalidate row cache on base:schema:bumped 2026-05-24 02:43:16 +01:00
Philipinho 651f799e3a chore(client): drop unused @dnd-kit packages 2026-05-24 02:41:43 +01:00
Philipinho 5f845ab4c3 feat(base): add i18n keys for property type-change UI 2026-05-24 02:37:28 +01:00
Philipinho d607e858d5 refactor(base): migrate choice editor reorder from dnd-kit to pragmatic-drag-and-drop 2026-05-24 02:35:34 +01:00
Philipinho 909b6a8f9c feat(base): show Converting badge in column header during type change 2026-05-24 02:35:22 +01:00
Philipinho 23ea060e54 feat(base): restore property type change with confirmation panel 2026-05-24 02:32:43 +01:00
Philipinho 73e626e3bc refactor(base): stabilize column-reorder dnd effect against WS-driven refetches 2026-05-24 02:31:52 +01:00
Philipinho c2dac69e70 feat(base): add conversion-warning lookup with unit tests 2026-05-24 02:29:52 +01:00
Philipinho e1f862967a feat(base): wire type field through UpdatePropertyInput + restore row invalidation 2026-05-24 02:27:37 +01:00
Philipinho a66d31178a fix(base): sync client SYSTEM_PROPERTY_TYPES with server (add formula) 2026-05-24 02:26:13 +01:00
Philipinho eeb84e97c9 refactor(base): migrate column reorder from dnd-kit to pragmatic-drag-and-drop 2026-05-24 02:24:54 +01:00
Philipinho 9c124f8851 feat(base): guard system-source/primary type changes; allow rename mid-conversion 2026-05-24 02:24:37 +01:00
Philipinho c8ce98347e feat(base): allow type on /properties/update, restricted to user types 2026-05-24 02:21:47 +01:00
Philipinho 64a1e22cb4 feat(base): add USER_PROPERTY_TYPES subset for type-change DTO 2026-05-24 02:19:42 +01:00
Philipinho 218b5755a9 feat(base): add drop-edge indicator component for pragmatic-dnd 2026-05-24 02:18:31 +01:00
Philipinho 911bf53924 Merge branch 'main' into base-formula 2026-05-24 01:47:59 +01:00
Philipinho 01ba1add5d fix(base): restore position: relative on .headerCell
The sticky-headers refactor (cd8d1e0e) moved the header row's sticky
behavior up to .stickyBand and dropped the position rule from
.headerCell entirely — but the cell still has two absolutely-positioned
children that need it as their containing block:

  - .resizeHandle (right: 0) — without a per-cell containing block, all
    handles resolve up to .stickyBand and stack at the band's right
    edge, so only one is visible.
  - Popover.Target (inset: 0) — every header's popover anchor collapses
    to the same band-relative box, so the property menu opens at one
    fixed spot regardless of which header was clicked.

.headerCellPinned still establishes a containing block via position:
sticky, which is why the primary Title column kept working. Re-adding
position: relative on the base .headerCell fixes both the resize handle
and the property-menu popover for non-pinned columns; pinned cells are
unaffected because position: sticky later in the cascade still wins.
2026-04-29 13:47:25 +01:00
Philipinho d6575a1cf8 chore(base): drop redundant 'private' section divider comment in base-ws 2026-04-28 20:31:07 +01:00
Philipinho 5069573e8a refactor(base): fold /bases/inline-embed into /bases/create with parentPageId
The inline-embed endpoint was a thin wrapper around baseService.create:
its only differences from /bases/create were that it derived the
spaceId/workspaceId from a parent page, gated permission via
pageAccessService.validateCanEdit on that parent, and seeded inline
defaults (Title + Text 1 + Text 2 + one row). All of that fits
naturally on /bases/create as a parentPageId branch.

  - CreateBaseDto: spaceId is now optional. Either spaceId or
    parentPageId must be supplied; the controller validates the
    cross-field requirement and 400s otherwise.
  - /bases/create: with parentPageId, derive workspaceId/spaceId from
    the parent, validateCanEdit on the parent, apply inline defaults.
    Without parentPageId, gate on space-level Create, Page (the
    standalone path).
  - InlineEmbedBaseDto + createInlineEmbed deleted.
  - Slash command in the editor now POSTs /bases/create with
    { parentPageId } — the server picks up the inline branch.
2026-04-28 20:29:07 +01:00
Philipinho 0615bcf222 refactor(base): route WS subscribe through pageAccessService
BaseWsService.subscribe was the last surface that didn't go through
the page-permission system. It checked authorization with a bespoke
canReadBaseSpace(userId, spaceId) — which queried space membership
directly and accepted ANY space role — so a user with a per-base
restriction (revoked access via pagePermissionRepo) could still
stream live updates and presence for a base they couldn't otherwise
read.

Replace it with pageAccessService.validateCanView(base, user) — the
same gate the HTTP endpoints (info, list, rows query, etc.) and the
page collab WS already use. Bases are pages structurally (isBase=true),
so reusing the page validator keeps them on a single permission code
path.

Drops the now-unused SpaceMemberRepo / findHighestUserSpaceRole
imports; injects UserRepo + PageAccessService instead (both are
globally provided modules, no DI changes needed).
2026-04-28 19:28:52 +01:00
Philipinho 936c0de7fe refactor(base): drop SpaceCaslSubject.Base, route base permissions through Page
Bases are pages (isBase=true) and the casl rules already granted the
exact same Manage/Read level on the Base subject as on Page for every
space role (admin, writer, reader). The `Base` subject was therefore
pure duplication: any caller that needed to check base access either
went through pageAccessService (which uses Page internally) or did a
direct Page-equivalent ability.cannot(..., Base) check that produced
the same outcome as Page would have.

Drop SpaceCaslSubject.Base entirely — server enum, server union,
server factory rules, client enum, client union — and switch the two
remaining direct callers to Page:

  - base.controller.ts `create` and list checks now use Page (matching
    page.controller.ts's create/list).
  - base-table.tsx's `canSave` now reads Page edit ability.

Net effect: one source of truth for "can this user view/edit/manage
content in this space," whether the content is a regular page or a
base. Existing role assignments behave identically; no migration
needed because permissions are computed per-request from the role,
not stored.
2026-04-28 19:21:46 +01:00
Philipinho 0ec30ba804 fix(base): keep view-tab context menu right-click-only, close on outside / Esc
Switching to <Menu> in the previous fix made left-click on a tab
also open the menu — Mantine Menu auto-toggles via its Target's
click handler in controlled mode, which we don't want (left-click
should switch view, only right-click should open the context menu).

Switch back to <Popover> (no auto-toggle on Target click) and wire
outside-click / Escape close paths manually with a useEffect that's
active only while the menu is open. Capture-phase mousedown so we
run before grid-container's outside-click logic. Left-click on the
tab now calls onClick (switch view) and dismisses the menu in the
same gesture if it happens to be open.
2026-04-28 19:05:39 +01:00
Philipinho 38f7ffefe0 fix(base): replace Popover with Menu for view-tab context menu
The Rename / Delete-view popover (right-click on a view tab) wasn't
closing on outside click or Escape. The container was a <Popover>
with hand-rolled <UnstyledButton> menu items — closeOnClickOutside
and closeOnEscape on Mantine Popover only fire onClose when focus is
inside the dropdown, which never happens here because the popover
opens via a context-menu (focus stays on body) and there's no
trapFocus.

Switch to Mantine <Menu>, which is purpose-built for this pattern:
closeOnClickOutside / closeOnEscape work without focus being inside,
closeOnItemClick removes the manual setMenuOpened(false) wiring on
each item, and the keyboard arrow-key navigation is free.
2026-04-28 18:41:59 +01:00
Philipinho 4b75383460 fix(base): store file-cell URLs in the editor's /api/files/{id}/{name} shape
The file-cell value was storing the raw S3 storage path returned by
attachmentService (e.g. "01944.../files/019dd.../AlgoExpert_Receipt.pdf"),
which the browser can't fetch as-is — and the cell never built it
into a clickable link anyway. Match the editor's attachment node-view
pattern: store url as "/api/files/{id}/{fileName}" on upload and
resolve it through getFileUrl when rendering. The dropdown's file
rows are now <a target="_blank"> links so the user can actually open
the attached file.

`buildFileUrl` falls back to constructing the path from id+fileName
for any pre-existing values that still carry the old shape, so
already-uploaded attachments don't break.
2026-04-28 18:34:12 +01:00
Philipinho 7dfb172d45 style(base): slim the body-grid horizontal scrollbar
The browser-default-thick scrollbar at the bottom of an inline-embed's
bodyGrid was visually competing with the table content. Drop it to an
8px thumb with a transparent track in WebKit and `scrollbar-width:
thin` in Firefox. Tokens picked from the Mantine theme so dark mode
inverts cleanly.
2026-04-28 18:30:06 +01:00
Philipinho 79394f93f5 refactor(base): drop dedicated /bases/files/upload endpoint, reuse /files/upload
Base file-cell uploads were hitting a parallel POST /bases/files/upload
endpoint that re-implemented the multipart parse, the size limit
handling, and the spaceId resolution that the standard page-attachment
endpoint (POST /files/upload) already does. The two diverged on minor
points (no audit log, no attachmentId support, slightly different
permission check) without good reason — bases are pages (isBase=true),
so the existing endpoint already handles them correctly.

Server: delete uploadBaseFile and the now-unused BaseRepo injection.

Client: route the file-cell uploader through the existing uploadFile
helper in page-service. The base's pageId is a valid page id, so the
server's pageRepo.findById succeeds and pageAccessService.validateCanEdit
runs — which lines up with the Base edit ability at the space-role
level (Manage Page and Manage Base track together for admins/writers,
Read for readers).
2026-04-28 15:51:06 +01:00
Philipinho 0532253034 fix(base): commit cell edit on click-outside, not just on Enter
CellNumber/CellText/CellEmail/CellUrl all commit their draft via
onBlur. The grid-container's document mousedown handler was clearing
editingCell synchronously when the user clicked outside, which made
React unmount the input before the native blur event reached its
onBlur listener — so the edit was silently dropped, and pressing
Enter was the only way to save.

Trigger blur() on the active element first; the cell's onBlur runs,
commits, and clears editingCell as part of its normal flow. The
trailing setEditingCell(null) is now a safety net for the case
where the active element wasn't a cell editor (no double-commit
risk because each cell guards with committedRef).
2026-04-28 15:44:22 +01:00
Philipinho 7e35936544 fix(base): align inline-embed AddRowButton to page-content edge like standalone
In embed mode the "+ New row" button rendered to the right of the
data columns instead of at the page-content edge (where it lives in
standalone). Two compounding causes:

  - position: sticky with inset-inline-start:
    var(--embed-grid-pad-left, 0). Sticky offsets are measured from
    the scroll-port, not the bodyGrid's outer edge — and the scroll-
    port already starts at the page-content edge (bodyGrid's negative
    margins and equal padding cancel out). With the variable set to
    ~200px in embed, sticky shifted the button 200px *into* the
    scroll-port. inset-inline-start: 0 keeps it at the scroll-port's
    start in both modes.
  - max-content grid item with default justify-self: stretch on a
    grid-column: 1 / -1 area has surprising placement; `justify-self:
    start` makes the inline-start anchoring explicit.

Standalone behavior is unchanged (the variable was 0 there anyway).
2026-04-28 11:51:06 +01:00
Philipinho edc7143f77 fix(base): match inline-embed placeholder skeleton to seeded 3 col / 1 row shape
The placeholder rendered a default 10×6 BaseTableSkeleton while
waiting on the create-base API, then swapped to the real table once
the response landed. Because the inline-embed flow now seeds
Title + Text 1 + Text 2 with one default row, the real table is 3×1
— the swap visibly collapsed a large fake table down to a small
empty one. The scroll didn't jump (initialOffset takes care of that)
but the flicker was jarring.

Re-introduce rows + columns props on BaseTableSkeleton (default still
10 / 6 so other call sites are unaffected) and pass rows=1 columns=3
from the inline-embed placeholder so the swap is visually stable.
2026-04-28 11:34:08 +01:00
Philipinho dac65914ed chore(base): drop pre-virtualizer-fix scroll-jump workarounds
Two pieces of code added under the wrong height-mismatch theory of
the inline-embed scroll jump are no longer load-bearing now that the
real cause (virtual-core's _willUpdate calling _scrollToOffset(NaN))
is fixed by the initialOffset seed in grid-container.tsx:

  - BaseTableSkeleton's `rows` prop, whose only consumer was the
    "creating database" placeholder passing rows=0 to "match" the
    eventual empty-base height. Reverted to a fixed 10-row skeleton.
  - The Database slash command's React Query cache prefill of
    ["bases", id] and ["base-rows", id, …] (~30 lines), which
    existed to skip BaseTable's own loading skeleton on swap. The
    create endpoint return type is back to { id }.

The placeholder approach (pendingKey + setNodeMarkup patch) stays —
that's what gives the user visible state during the create request,
unrelated to the scroll jump.
2026-04-28 11:29:28 +01:00
Philipinho e9926d5cef feat(base): seed inline-embed bases with two extra text columns and one row
A freshly-created inline-embed used to render with only the primary
"Title" column and zero rows — visually it looks more like a broken
widget than a database, so users always had to do at least three
manual setup steps before the embed conveyed its purpose.

BaseService.create now accepts an optional `defaults` arg for
`extraTextProperties` and `defaultRows`; both extras are inserted in
the same transaction as the page/property/view so a half-built base
can never slip out. The inline-embed controller passes
{ extraTextProperties: 2, defaultRows: 1 } so the embed lands as
"Title + Text 1 + Text 2", with one empty row ready to type into.

Standalone base creation goes through the same code path with no
defaults, so its existing single-Title-column shape is unchanged.
2026-04-28 11:25:11 +01:00
Philipinho 4a25e787fa prevent create property popover flash 2026-04-28 10:46:27 +01:00
Philipinho 86809fc0dc fix scroll bug 2026-04-28 10:39:20 +01:00
Philipinho b411f52c18 fix(base): keep editor scroll stable when inline-embed creation completes
The placeholder rendered the full 10-row BaseTableSkeleton (~440px)
while waiting for the create response, then BaseTable mounted, ran its
own queries, and rendered the same 10-row skeleton again until rows
loaded. The actual content for a freshly-created empty base is ~112px
— so the swap shrank the doc by ~330px and on a short page the
browser clamped scrollY past the new doc bottom, manifesting as a
"jump to top of editor."

Two changes to keep the height constant end-to-end:

1. BaseTableSkeleton now accepts a `rows` prop (default 10). The
   placeholder in BaseEmbedView passes `rows={0}` so the skeleton
   matches the height of the eventual empty base shell — header row +
   AddRow button, no fake body rows.

2. The Database slash command now seeds `["bases", id]` and the
   `["base-rows", id, undefined, undefined, undefined]` infinite-query
   cache from the create response (the endpoint already returns the
   full base with properties + views; the typed return was just too
   narrow). BaseTable mounts with baseLoading/rowsLoading already
   false and skips its own skeleton — no transient grow-then-shrink
   between placeholder and final content.

End state: placeholder height ≈ rendered-empty-base height, and no
intermediate skeleton appears while BaseTable is "loading." The
scrollY clamp can't fire because the doc never shrinks.
2026-04-27 22:33:08 +01:00
Philipinho c8086b33e4 fix(base): show skeleton while inline embed is being created, anchor at slash position
The Database slash command deleted the trigger text and then awaited
the create-base API before inserting the embed. During the wait the
editor sat empty (no visible state), and by the time the embed was
inserted the editor's selection had often drifted — so the new node
landed in the wrong place and the page appeared to "jump up" when the
response arrived.

Insert a placeholder baseEmbed node synchronously at the slash position
with pageId: null and a unique pendingKey. BaseEmbedView renders
BaseTableSkeleton while pendingKey is set — same skeleton BaseTable
shows on its own initial load, so the swap to the real table is a
visual no-op. Once the API responds, look up the placeholder by its
pendingKey and patch in the real pageId via setNodeMarkup. On API
failure, remove the placeholder and surface a toast. The pendingKey
attribute is non-serializing (parseHTML returns null, renderHTML
returns {}) so a placeholder can't survive a page reload as orphan.
2026-04-27 22:02:42 +01:00
Philipinho bfa85b9835 fix(base): scope per-base UI atoms by pageId to prevent embed render loops
Two BaseTable instances on the same page (e.g. multiple base embeds in
one document) shared the same global jotai atoms for activeViewId,
editingCell, property menu state, and row selection. Each instance's
useEffect that synced activeViewId would clobber the other's value
every render, pinning React into a "Maximum update depth exceeded"
loop.

Convert every UI atom in base-atoms.ts to an atomFamily keyed by pageId
so each base owns its own scope, and thread pageId through the grid
component tree (GridRow, GridCell, GridHeaderCell, RowNumberCell,
RowNumberHeaderCell, PropertyMenuContent) plus useRowSelection so each
consumer reaches the per-base atom. use-base-socket already had pageId
in scope; its store.get/store.set calls now resolve through
selectedRowIdsAtomFamily(pageId) too.
2026-04-27 21:35:39 +01:00
Philipinho cd8d1e0ed8 feat(base): make column headers sticky in standalone and inline contexts
Split the unified .gridWrapper into a sticky band (containing column
headers, plus banner + toolbar in inline) and a body grid that owns
horizontal scroll. The band's vertical sticky anchor is automatic CSS:
the table's scrollport in standalone, the page in inline. A small
useHorizontalScrollSync hook mirrors body scrollLeft onto the header
and turns wheel-on-header into pan-on-body.
2026-04-27 20:25:13 +01:00
Philipinho 1cfd0fb2c4 style(base): add tableScrollport / stickyBand / headerGrid / bodyGrid classes 2026-04-27 14:07:20 +01:00
Philipinho fc13520322 feat(base): add useHorizontalScrollSync hook 2026-04-27 13:59:07 +01:00
Philipinho 3ad666adad refactor(base): use --page-header-height var in standalone layout 2026-04-27 13:56:24 +01:00
Philipinho a645ab947b style: introduce --page-header-height variable 2026-04-27 13:52:49 +01:00
Philipinho 6a89212e32 fix(base): strip global ProseMirror 3rem horizontal padding from full-page base title
core.css globally pads .ProseMirror with 3rem on each side, so the
TitleEditor's content sat 48px further in than the table below. Add
a .base-page-title scope that resets the padding to 0 (only on the
full-page base title wrapper), so the outer 24px paddingInline is
the single source of horizontal positioning. Doc pages and the
embedded BaseTable are unaffected — neither uses the new class.
2026-04-27 11:29:17 +01:00
Philipinho 701ae5c78d fix(base): align title and table at the same left edge with shared horizontal padding
Wrap both the title editor and the BaseTable in one outer flex
container that owns the horizontal padding (24px on each side).
Each child only sets vertical spacing — they no longer fight over
their own left positions, so the title's left edge now lines up
with the toolbar / first column header below.
2026-04-27 05:10:01 +01:00
Philipinho 1de982c95b fix(base): add horizontal breathing space around full-page base title and table 2026-04-27 05:06:31 +01:00
Philipinho 9eee7bc12c fix(base): left-align full-page base title with the table below 2026-04-27 05:04:38 +01:00
Philipinho d476d0b8ba feat(base): add editable page title above the full-page base view
Mirror the doc-page layout — the base name is editable via the same
TitleEditor used on document pages, sitting in a centered Container
above the table. The base table fills the remaining space below via
flex: 1, so the toolbar and grid stay anchored to the bottom of the
viewport instead of fighting the page header.
2026-04-27 05:02:13 +01:00
Philipinho 361afc2426 feat(editor-ext): block typing/paste from replacing a selected base embed
Add a ProseMirror plugin with handleTextInput and handlePaste that
return true (handled, no-op) when the current selection is a
NodeSelection of the base embed. Pairs with the existing Backspace/
Delete keyboard guard — between them the two accidental-deletion
paths (focus + delete key, focus + type a character) are blocked.

Other deletion routes still work: range selections covering the
node, programmatic deletes, and so on. Pressing an arrow key
deselects the node so the user can type elsewhere.
2026-04-27 04:53:36 +01:00
Philipinho 144838aa89 feat(editor-ext): block accidental delete of selected base embed
Add Backspace/Delete keyboard shortcuts on the BaseEmbed node that
return true (handled, no-op) when the current selection is a
NodeSelection of this node — the 'click the embed and hit delete'
accidental path. Other deletion paths (range selections covering
the node, programmatic transactions, sync) still go through normally.
2026-04-27 04:51:18 +01:00
Philipinho 00e9d1d724 feat(base): add right-side scroll headroom on inline embed grid
Mirror the leftward padding with --embed-grid-pad-right = extendRight,
applied as padding-right on .grid. The user can now pan past the last
column into empty space — same shape as Notion's behavior. Standalone
full-page bases are unaffected (var unset → 0).
2026-04-27 04:44:47 +01:00
Philipinho 583d2d37c4 fix(base): pull AddRowButton out of grid so sticky-left actually sticks
As a child of .grid with grid-column: 1/-1, the button took the full
row width and the sticky-left offset got swallowed by the grid layout
— it scrolled along with the cells instead of anchoring to the left.

Move it to a sibling of .grid inside .gridWrapper, restyle as a
small inline-flex chip with width: max-content. Sticky-left now
keeps it pinned to page-content-left while the user scrolls
horizontally, matching the toolbar.

Add padding-bottom: 6px on .gridWrapper and a small vertical margin
on the button so the horizontal scrollbar no longer overlaps it.
2026-04-27 04:39:29 +01:00
Philipinho a992fa8a63 feat(base): keep New row button sticky during horizontal scroll
Add position: sticky + inset-inline-start to .addRowButton so the
'New row' affordance stays at the page-content edge while the user
scrolls horizontally — same shape as the toolbar staying put. The
sticky offset uses the existing --embed-grid-pad-left var: in embed
mode that's the leftward extension distance (so it sticks at
page-content-left); in standalone mode the var is 0 (sticks at the
grid's natural left edge, no visible effect when there's no scroll).

Add a body-color background and z-index: 4 so cells don't bleed
through during scroll.
2026-04-27 04:30:04 +01:00
Philipinho 7a119010c7 fix(base): suppress ProseMirror atom-node selection outline on inline embed 2026-04-27 04:23:16 +01:00
Philipinho 678c6f5f3f fix(base): drop outer panel border on inline embed grid
The .grid had a 1px outer border + small radius drawing a rounded
rectangle around the whole table. In standalone full-page mode that
reads as a panel and is fine. In an inline embed it looked like a
floating box, especially with the leftward padded area inside the
border.

Make the outer border and radius CSS vars; the embed wrapper sets
them to none/0 so inline databases get only cell-level gridlines,
matching Notion's document-style rendering. Standalone bases get
the default 1px panel frame as before.
2026-04-27 04:20:58 +01:00
Philipinho 030d3e878a feat(base): extend inline embed scroll viewport leftward, anchor first cell with grid padding
Bring back the leftward extension via negative margin-left so the
scroll viewport reaches AppShell.Main's left edge, then offset the
.grid with padding-left = extendLeft so the first cell still lines
up with page-content on load. The extended area becomes scrollable
empty space the user can pan into — same shape as Notion's inline
databases.

Done with one new CSS var (--embed-grid-pad-left) consumed by the
.grid in grid.module.css. Standalone full-page bases never set the
var, so it's a no-op there.
2026-04-27 04:13:34 +01:00
Philipinho a7105267ed fix(base): keep inline embed left edge at page-content, extend right only
Removing the leftward extension. With negative margin-left, the
grid grew leftward past page-content and the first column appeared
near the sidebar edge on load — wrong; Notion keeps the first column
aligned with page text. Drop margin-left and the extendLeft
calculation; only extend rightward toward AppShell.Main's right edge.

Leftward viewport extension (so frozen columns can lock at the
sidebar edge during scroll) becomes meaningful once we add frozen
columns. Deferred until that feature lands.
2026-04-27 04:05:18 +01:00
Philipinho 67f45ee61b fix(base): anchor inline embed extension to AppShell main, not first wider parent
The findWiderAncestor walk often stopped at a slightly-larger
intermediate (NodeViewWrapper, drag-handle wrapper, etc.) whose
left edge was almost the same as the wrapper's, so the leftward
extension came out as ~0 and the grid only grew to the right.

Use closest('main') instead — that's AppShell.Main, the layout
container whose bounds already reflect the navbar width and
sidebar collapse state. Both sides now extend to its edges.
2026-04-27 03:57:45 +01:00
Philipinho d73ac010af feat(base): use negative margin to extend inline grid past parent
The previous approach (position: relative + explicit width +
inset-inline-start) didn't physically grow the box in some flex
contexts, so the grid stayed at the parent's width. Switch to
negative margin-left / margin-right on the grid wrapper only —
with width: auto, the rendered width becomes parent + |margin|,
extending the box past the parent without any positioning hacks.

The toolbar keeps its natural parent-constrained width (no extension)
so it stays aligned with the page text above. Two CSS vars,
--embed-extend-l / --embed-extend-r, are computed on mount + on
ResizeObserver from the wrapper and the closest wider ancestor.
2026-04-27 03:51:21 +01:00
Philipinho 8132b171f4 fix(base): apply embed width-extension after base loads, not just on mount
useEffect ran once on mount with empty deps, but the wrapper div was
inside a conditional branch that only renders after the base query
resolves. Result: the ref was null when the effect ran, so nothing
extended. Move the wrapper outside the conditional so the ref is
always set, and re-run the effect when isLoading / isError / pageId
change so the extension applies once the table mounts.
2026-04-27 03:43:38 +01:00
Philipinho e0e87329f4 feat(base): inline embed extends grid + toolbar beyond page width
When a base is embedded inline in a doc page, measure the parent
container's available area and extend toolbar + grid sections to fill
it via CSS variables (--embed-width / --embed-shift / --embed-pad).
Inner content is re-padded so toolbar buttons and the first column
visually align with the page text, while the box itself reaches the
viewport edges for horizontal scroll headroom on wide databases.
Sticky inset-inline-start keeps the toolbar pinned to the page-content
edge during horizontal scroll. Standalone full-page bases are
unaffected (the embedded prop defaults to false).
2026-04-27 03:39:03 +01:00
Philipinho 8aabf86abb fix(base): make property type-picker popover scrollable with responsive width 2026-04-27 02:20:42 +01:00
Philipinho adae526627 fix type 2026-04-27 02:12:34 +01:00
Philipinho e66e18cd60 fix(base): rename remaining baseId references in attachment upload and ws utility 2026-04-27 01:57:18 +01:00
Philipinho 7f8ed733a3 feat(base): inline base embed — node registration, slash command, and renderer view 2026-04-27 01:54:59 +01:00
Philipinho 3826e5a50d feat(base): add /bases/inline-embed endpoint that creates a child base page 2026-04-27 01:54:00 +01:00
Philipinho 4a15c805f1 feat(editor-ext): add BaseEmbed TipTap node with pageId attribute 2026-04-27 01:52:58 +01:00
Philipinho 69e7bd73f2 feat(base): render table icon in sidebar for is_base=true pages
- Add isBase to SpaceTreeNode type
- Forward isBase from IPage in buildTree utility
- In the sidebar Node renderer, show IconTable when node.data.isBase
  is true and no custom emoji icon is set
2026-04-27 01:50:05 +01:00
Philipinho 19821d3aeb feat(base): page renderer dispatches to base view when page.isBase
- Add isBase to page repo baseFields so it is always selected
- Add isBase to sidebar pages query select list
- Add isBase to client IPage type
- In PageContent, render <BaseTable pageId={page.id} /> when page.isBase
  is true instead of the TipTap editor path
2026-04-27 01:49:54 +01:00
Philipinho 060bd1048f refactor(base): rename /base/:baseId param to :pageId 2026-04-27 01:44:37 +01:00
Philipinho f56af5e6b4 refactor(base): rename baseId to pageId in client components 2026-04-27 01:44:11 +01:00
Philipinho 3e9f26a8dd refactor(base): rename baseId to pageId in client atoms, hooks, types 2026-04-27 01:42:54 +01:00
Philipinho 4510543510 refactor(base): rename baseId to pageId in client services and queries 2026-04-27 01:41:17 +01:00
Philipinho 25dfb44774 refactor(base): rename baseId to pageId across WS, processors, tasks, formula, events
Renames baseId → pageId in:
- Domain event types (BaseEventBase) and all derived event types
- WS Zod schemas (wire-protocol field names now emit pageId)
- BaseWsService, BaseWsConsumers, BasePresenceService
- FormulaLockService, FormulaService
- BullMQ job interfaces (IBaseTypeConversionJob, IBaseCellGcJob, IBaseFormulaRecomputeJob)
- BaseQueueProcessor and all task functions
- Service emit-payload keys in base.service, base-property.service,
  base-row.service, base-view.service, base-csv-export.service
- Formula spec test fixtures
2026-04-27 01:38:11 +01:00
Philipinho 78d450a238 refactor(base): use PageAccessService for BaseViewController 2026-04-27 01:29:23 +01:00
Philipinho 2e7fe5bbb4 refactor(base): use PageAccessService for BaseRowController 2026-04-27 01:28:59 +01:00
Philipinho 43cf1665f5 refactor(base): use PageAccessService for BasePropertyController 2026-04-27 01:28:28 +01:00
Philipinho fd6a208235 refactor(base): use PageAccessService for BaseController permissions 2026-04-27 01:28:03 +01:00
Philipinho a53dabae70 refactor(base): rename baseId to pageId in DTOs, schemas, and services 2026-04-27 01:27:10 +01:00
Philipinho 54de3a7791 refactor(base): update task files to use renamed repo methods 2026-04-27 01:22:09 +01:00
Philipinho 16161d793b refactor(base): rename baseId to pageId in csv-export and page-resolver services 2026-04-27 01:21:07 +01:00
Philipinho 42f950e11d refactor(base): rename baseId to pageId in BaseViewService 2026-04-27 01:20:34 +01:00
Philipinho fdb250bbe8 refactor(base): rename baseId to pageId in BaseRowService 2026-04-27 01:19:49 +01:00
Philipinho 2b4a9b8a00 refactor(base): rename baseId methods to pageId in repos and BasePropertyService 2026-04-27 01:18:32 +01:00
Philipinho a2917bad6d feat(base): create() now allocates an is_base=true page via PageService 2026-04-27 01:16:48 +01:00
Philipinho ccdf2343f2 refactor(base): rename baseId to pageId in BaseViewRepo 2026-04-27 01:10:56 +01:00
Philipinho cbed118c11 refactor(base): rename baseId to pageId in BaseRowRepo 2026-04-27 01:10:29 +01:00
Philipinho 0f9dee4b28 refactor(base): rename baseId to pageId in BasePropertyRepo 2026-04-27 01:08:59 +01:00
Philipinho 0257f03003 refactor(base): rewrite BaseRepo over pages (is_base + base_schema_version) 2026-04-27 01:06:19 +01:00
Philipinho 731fa45672 chore(db): drop Base entity type aliases (table no longer exists) 2026-04-27 01:04:05 +01:00
Philipinho 2b05d1520b chore(db): regenerate Kysely types after bases-as-pages migration 2026-04-27 01:01:41 +01:00
Philipinho 24a70e48da fix(db): add pending_type columns to recreated base_properties table 2026-04-27 00:59:36 +01:00
Philipinho a847c2121c feat(db): add is_base flag to pages, drop bases table, recreate base_* with page_id 2026-04-27 00:53:44 +01:00
Philipinho 137c02a10f feat(base): sticky table header so columns stay visible while scrolling
Pins the header row to the top of the gridWrapper scroll viewport, with
the pinned-left corner cell stacking above pinned body cells at the
top-left intersection. The trailing add-column button gets the same
sticky treatment so it doesn't drift away from the row.

Subtle bit: `position: sticky` confines an element to its containing
block. The previous subgrid `.headerRow` wrapper was 34px tall, so
sticky cells could only travel 34px before scrolling out with the
wrapper. Switching the wrapper to `display: contents` lets the cells
become direct grid children of `.grid` (full table height) so sticky
travel matches the scroll range. role="row" survives display:contents
in modern browsers.
2026-04-25 01:05:20 +01:00
Philipinho be79b7159c feat(base): warm row count query on base load, gated on user hydrate
Mounts useBaseRowsCountQuery alongside the rows query so the count is
fetched eagerly and the cache is warm by the time the toolbar consumes
it. Gating on `currentUser` (not just `base`) ensures the persisted
view-draft has hydrated from localStorage before the first count fires
— otherwise a post-refresh count races ahead of the user's saved
filter and ships without it.
2026-04-25 01:05:11 +01:00
Philipinho 18222d1142 fix(base): escalate fuzzy filter and search counts to capped-exact
Postgres has no per-expression stats for `cells->>'uuid' ILIKE '%…%'`,
`search_text ILIKE`, or `search_tsv @@`, so EXPLAIN Plan Rows falls
back to a default selectivity and is off by orders of magnitude — a
contains filter on a 10k-row base was reporting ~150 against thousands
of real matches. Auto-route any request whose filter tree contains a
contains/ncontains/startsWith/endsWith op or a search term to the
capped-exact path, even when the caller asked for an estimate.
2026-04-25 01:05:02 +01:00
Philipinho 72fc93dcc1 perf(base): batch page-cell resolution via microtask loader
Per-cell useResolvedPages([id]) calls each mounted with a unique
React Query key, so a grid with 20 page-typed cells fired 20 requests on
first paint. A shared loader now accumulates incoming ids within a
microtask, fires a single POST for the union, and fans the subset each
caller asked for back to them. Cells keep their own cache entry + null
handling; they just share the underlying network call.

Also renames /bases/pages/resolve → /bases/pages/expand — the old name
collided with other "resolve" semantics in the codebase.
2026-04-24 12:11:41 +01:00
Philipinho 89ee3714ac feat(base): add /bases/rows/count with estimate and capped-exact modes
Row-count display on a filtered view shouldn't force a full COUNT(*) on
every list fetch. New endpoint returns either an EXPLAIN-plan estimate
(default, ~1ms, no execution) or a LIMIT-capped exact count that short-
circuits to `{ capped: true }` once the match set passes EXACT_COUNT_CAP.
Clients call it in parallel with the rows query so the grid still paints
at its own pace.

- DTO + repo.countEstimate/countExact reusing the list predicate shape
- service picks the mode; controller mirrors the list Read ability check
- client hook keyed by filter/search/exact so a "show exact" toggle
  doesn't clobber the estimate cache
2026-04-24 12:11:29 +01:00
Philipinho b9d8bf948c fix(base): strip empty filter groups at the query boundary
The view-draft layer stores `{op: 'and', children: []}` as an explicit
"override baseline with no predicates" marker. That payload was leaking
into /bases/rows requests once local filter/sort drafts were enabled —
harmless server-side (buildWhere maps an empty group to TRUE) but it
destabilised the React Query key and cluttered request payloads. Normalise
empty groups to undefined at the hook level.
2026-04-24 12:11:02 +01:00
Philipinho f7ea6e8fd3 fix(base): hide soft-deleted properties from base info payload
The withProperties subquery hydrating /bases/info was missing a
`deleted_at IS NULL` filter, so after a delete the socket-echo
invalidation refetched and the just-deleted column rehydrated on the
originating client and never dropped on others.
2026-04-24 12:10:22 +01:00
Philipinho dcff8d3c53 fix(base): mirror filter-clear fix for sorts so deleting the last sort sticks 2026-04-24 03:04:51 +01:00
Philipinho 2db43788d5 feat(base): auto-suffix fallback property names to avoid collisions 2026-04-24 02:54:03 +01:00
Philipinho 3bfdae7990 feat(base): insert palette items at cursor and refocus editor 2026-04-24 02:47:12 +01:00
Philipinho 464bd701ba feat(base): enforce unique property names per base 2026-04-24 02:34:38 +01:00
Philipinho 8c0071ee23 fix(base-formula): point client exports at source so Vite can detect ESM named exports 2026-04-24 02:28:23 +01:00
Philipinho 3962ccdc29 feat(base): tighten formula editor spacing and pill styling 2026-04-24 02:25:33 +01:00
Philipinho 7a254a9412 feat(base): redesign formula editor popover with polished header, pills, and accordion 2026-04-24 02:15:08 +01:00
Philipinho 9808791db4 feat(base-formula): add add/subtract/multiply/divide/pow/sqrt/sum/mean/median functions 2026-04-24 02:10:23 +01:00
Philipinho f99450f04a fix formula 2026-04-24 01:33:14 +01:00
Philipinho 82705ce3bd fix(base-formula): resolve package via dist to keep server build output layout 2026-04-24 00:51:40 +01:00
Philipinho 3eb7c9b1d4 feat(base): render formula cells with error badge support 2026-04-24 00:35:57 +01:00
Philipinho 28fed815ba feat(base): add formula editor popover with live parse and palette 2026-04-24 00:35:26 +01:00
Philipinho 48d77a2b53 feat(base): add Formula entry to the property type picker 2026-04-24 00:33:38 +01:00
Philipinho 46f9292c05 feat(base): subscribe to formula WS events on client 2026-04-24 00:29:23 +01:00
Philipinho 230c4e35f0 feat(base): emit formula-related WS events 2026-04-24 00:27:52 +01:00
Philipinho e729e77bda chore(base): note formula bulk-write threshold hook for future bulk endpoint 2026-04-24 00:27:06 +01:00
Philipinho 89e2d0d62f feat(base): compile and cycle-check formulas on property save, enqueue recompute on dep changes 2026-04-24 00:25:50 +01:00
Philipinho fbee344e96 feat(base): wire formula recompute job into queue processor 2026-04-24 00:21:20 +01:00
Philipinho 46386bf4e1 feat(base): add formula recompute task 2026-04-24 00:20:25 +01:00
Philipinho 5b5c98daa8 feat(base): wire inline formula evaluation into row service 2026-04-24 00:16:32 +01:00
Philipinho 2da8779b34 feat(base): add FormulaService, FormulaLockService, recompute job type 2026-04-24 00:13:20 +01:00
Philipinho 493613e634 feat(base): add client formula type literals 2026-04-24 00:07:17 +01:00
Philipinho 5a82d660da feat(base): register formula property type in schema layer 2026-04-24 00:06:42 +01:00
Philipinho c67ae19c39 feat(base): add migration marker for formula property type 2026-04-24 00:06:13 +01:00
Philipinho ea0dc2b56b feat(base-formula): add date and coercion functions, wire registry 2026-04-24 00:03:40 +01:00
Philipinho 0174fada5f feat(base-formula): add string and coercion functions 2026-04-24 00:02:40 +01:00
Philipinho 6669832e24 feat(base-formula): add math functions 2026-04-24 00:02:26 +01:00
Philipinho 22716f3df9 feat(base-formula): add logic functions 2026-04-24 00:02:15 +01:00
Philipinho e9e903abe9 feat(base-formula): add tree-walking evaluator 2026-04-24 00:00:22 +01:00
Philipinho 1b30de32b5 feat(base-formula): add function registry register helper 2026-04-23 23:56:48 +01:00
Philipinho ded855e44e feat(base-formula): add dependency graph with topo and cycle detection 2026-04-23 23:55:55 +01:00
Philipinho e445fc4fa9 feat(base-formula): add pretty-printer 2026-04-23 23:53:41 +01:00
Philipinho 77897733de feat(base-formula): add type checker 2026-04-23 23:52:11 +01:00
Philipinho 216a4a99e1 feat(base-formula): add name-to-id resolver with dependency extraction 2026-04-23 23:46:52 +01:00
Philipinho d8c96089b1 feat(base-formula): add Pratt parser 2026-04-23 23:44:09 +01:00
Philipinho dc825b0f62 feat(base-formula): add tokenizer 2026-04-23 23:40:14 +01:00
Philipinho 7202e65a07 feat(base-formula): add parse-error and cell-error sentinels 2026-04-23 23:34:05 +01:00
Philipinho 4c2d6772f1 feat(base-formula): add AST and value types 2026-04-23 23:26:49 +01:00
Philipinho a65aec0925 chore(base-formula): gitignore dist and tsbuildinfo to match editor-ext convention 2026-04-23 23:25:03 +01:00
Philipinho 99fd4a9503 feat(base-formula): scaffold shared package 2026-04-23 23:24:13 +01:00
Philipinho bf75cc9c74 label 2026-04-23 01:41:04 +01:00
Philipinho 5c8ce178e5 fix(base): stabilize choices reference so Add option row does not flicker 2026-04-21 12:04:36 +01:00
Philipinho cfb02766e2 refactor(base): simplify draft banner to inline Reset/Save controls 2026-04-20 23:12:51 +01:00
Philipinho ae595c51ed feat(base): mount draft banner and wire save-for-everyone flow 2026-04-20 23:00:32 +01:00
Philipinho 6c44354403 feat(base): add view draft banner component 2026-04-20 22:56:54 +01:00
Philipinho 184fa25d3e feat(base): route toolbar sort/filter changes through local draft 2026-04-20 22:54:25 +01:00
Philipinho 6740912adf feat(base): render table from effective (draft-or-baseline) view config 2026-04-20 22:50:28 +01:00
Philipinho f524243da1 refactor(base): accept baselineConfig option in useBaseTable 2026-04-20 22:47:00 +01:00
Philipinho d5093da863 feat(base): add useViewDraft hook for local filter/sort drafts 2026-04-20 22:44:05 +01:00
Philipinho 196afc21d4 feat(base): add BaseViewDraft type and view-draft atom family 2026-04-20 22:40:37 +01:00
Philipinho 9b5e3783dd feat(base): add Base subject to client-side space CASL enum 2026-04-20 22:38:15 +01:00
Philipinho 4ae941c5c4 docs(base): implementation plan for local-first view filter/sort 2026-04-20 22:31:19 +01:00
Philipinho 8bfa0aaf7e docs(base): refactor view-draft spec to atomFamily + atomWithStorage 2026-04-20 22:16:35 +01:00
Philipinho 58a47893a6 docs(base): address spec review notes for view-draft feature 2026-04-20 22:10:50 +01:00
Philipinho 2d91817602 docs(base): draft spec for local-first view filter/sort 2026-04-20 22:00:16 +01:00
Philipinho 9ecf88511b page property 2026-04-20 21:27:29 +01:00
Philipinho 30988c1959 docs(base): draft spec for page property type 2026-04-20 20:01:44 +01:00
Philipinho eb0f37bfe5 update packages 2026-04-19 02:05:48 +01:00
Philipinho 4c0348e46a docs(base): add working plans for recent base feature work 2026-04-19 02:05:34 +01:00
Philipinho cac4774641 fix(base): stop runaway pagination loop caused by browser scroll anchoring
Browser overflow-anchor silently bumped scrollTop by one page's worth
of pixels every time a new page of rows committed — anchoring on the
AddRowButton that sits below paddingBottom. This kept the near-bottom
threshold satisfied and re-fired onFetchNextPage indefinitely, even
after the user released the scrollbar. Disabling scroll anchoring on
the grid scroll container stops the browser from adjusting scrollTop
in response to content growth.
2026-04-19 02:05:30 +01:00
Philipinho c4d8b6c300 fix(base): stop infinite fetch loop when sorted list scrolled to bottom 2026-04-19 00:27:52 +01:00
Philipinho 95d0457a7e refactor(base): drop /list suffix from base endpoints to match codebase convention 2026-04-18 23:36:52 +01:00
Philipinho 83d28a8505 perf(base): defer rows query until base info loads to avoid bland first request 2026-04-18 23:34:02 +01:00
Philipinho f9bbbc7ebf fix(base): ignore nested listbox and portal clicks so select doesnt close toolbar popover 2026-04-18 23:31:53 +01:00
Philipinho d9e2d7ba3d chore(server): one-shot script to clean poisoned base view configs 2026-04-18 23:27:03 +01:00
Philipinho 44ec2dbe88 fix(base): stop jsonb char-key corruption in seed and guard view config spread 2026-04-18 23:26:03 +01:00
Philipinho a6e9e66bbd fix(base): don't override server sort with client-side position sort 2026-04-18 22:55:15 +01:00
Philipinho a9ea2a99b4 chore(server): let seed-base-rows script take row count via env var 2026-04-18 22:44:52 +01:00
Philipinho 2f6bad141c feat(base): draft flow with save and cancel for new view filters 2026-04-18 22:39:30 +01:00
Philipinho fd1257f61c feat(base): draft flow with save and cancel for new view sorts 2026-04-18 22:38:28 +01:00
Philipinho 321184394d feat(base): show table skeleton instead of centered loader on load 2026-04-18 22:22:49 +01:00
Philipinho b01f6e9af9 feat(base): add layout-matching skeleton loading component 2026-04-18 22:22:11 +01:00
Philipinho 93b1fc534b fix(base): adopt server view state when no local edit is pending 2026-04-18 22:03:25 +01:00
Philipinho 1aa92b1bb5 fix(base): stop synthesized switch input click from re-firing hide toggle 2026-04-18 21:57:28 +01:00
Philipinho d385099eb1 fix(base): fire hide toggle once per click instead of twice 2026-04-18 21:51:43 +01:00
Philipinho d4fe0e0a69 fix(base): re-render grid header and rows when column visibility changes 2026-04-18 21:41:32 +01:00
Philipinho ab9b00f91c fix(base): include new properties in local column state so the grid can scroll to them 2026-04-18 21:11:09 +01:00
Philipinho 64dafe5ac0 fix(base): prompt unsaved changes when discarding dirty rename 2026-04-18 20:58:59 +01:00
Philipinho 097b1c76d4 feat(base): add save and cancel buttons to property rename panel 2026-04-18 20:52:26 +01:00
Philipinho 2c1f66b603 fix(base): refresh hide-fields popover when a property is renamed 2026-04-18 20:52:24 +01:00
Philipinho f812162a26 fix(base): refresh grid headers when a property is renamed 2026-04-18 20:51:14 +01:00
Philipinho b88c060df8 fix(base): escape on dirty property options triggers discard prompt 2026-04-18 20:39:02 +01:00
Philipinho 97cd88405d fix(base): close property menu on escape from main and options panels 2026-04-18 20:35:30 +01:00
Philipinho 5de9a69130 fix(base): close toolbar popovers on escape via document keydown 2026-04-18 20:31:54 +01:00
Philipinho 83d55d9bd3 fix(base): close toolbar popovers on outside click via custom listener 2026-04-18 20:26:57 +01:00
Philipinho 9c71a90637 fix(base): dismiss hide-fields popover on escape and outside click 2026-04-18 19:49:13 +01:00
Philipinho c6f993b610 fix(base): only re-seed column state when view identity changes 2026-04-18 19:23:56 +01:00
Philipinho c331e0ffd3 fix(base): merge live table state into sort and filter mutations 2026-04-18 19:22:41 +01:00
Philipinho 53ee685874 refactor(base): extract buildViewConfigFromTable helper 2026-04-18 19:21:17 +01:00
Philipinho 082a32faa0 fix(client): exempt base csv export from response interceptor unwrap 2026-04-18 18:51:49 +01:00
Philipinho 5c11e59128 fix(base): stabilize properties identity to break render loop 2026-04-18 18:48:41 +01:00
Philipinho 5a4d10081d feat(base): add csv export button to base toolbar 2026-04-18 18:24:24 +01:00
Philipinho 18668c7bcf feat(base): add client csv export service call 2026-04-18 18:23:43 +01:00
Philipinho f119d728a8 fix(base): handle csv export client abort and mid-stream errors 2026-04-18 18:18:34 +01:00
Philipinho 66f9194e96 feat(base): add csv export http endpoint 2026-04-18 18:14:41 +01:00
Philipinho 19b3f26cbb feat(base): register csv export service in module 2026-04-18 18:14:01 +01:00
Philipinho 56c57afff3 feat(base): add streaming csv export service 2026-04-18 18:13:20 +01:00
Philipinho d84aadadbb feat(base): add export base csv dto 2026-04-18 18:11:34 +01:00
Philipinho da0321b468 feat(base): add csv cell serializer with per-type rules 2026-04-18 18:10:47 +01:00
Philipinho db6f82ff7a chore(server): add csv-stringify dependency 2026-04-18 18:08:09 +01:00
Philipinho 207c74427d style(base): unify hover state across selected row cells 2026-04-18 17:15:57 +01:00
Philipinho c53d70b64e style(base): darken select option hover for better visibility 2026-04-18 17:15:23 +01:00
Philipinho 9a1cbc8ea9 style(base): nudge row drag grip past left table border 2026-04-18 17:14:49 +01:00
Philipinho 8b343d25f0 style(base): push row drag grip flush to left table border 2026-04-18 17:13:03 +01:00
Philipinho 2d47ffb25a style(base): align row drag grip flush with cell left edge 2026-04-18 17:12:42 +01:00
Philipinho b6882d774b fix(base): widen row-number column so drag grip sits left of checkbox 2026-04-18 17:09:00 +01:00
Philipinho 4dc6d32e49 fix(base): absolutely position row-number content to eliminate layout shift 2026-04-18 17:03:06 +01:00
Philipinho 8994575437 feat(base): confirm before bulk deleting selected rows 2026-04-18 17:00:43 +01:00
Philipinho 3f52e54207 fix(base): pin selection bar to viewport with Confluence-style dark pill 2026-04-18 16:54:49 +01:00
Philipinho b6b6e1809a feat(base): reconcile bulk delete over socket + prune selection 2026-04-18 16:47:05 +01:00
Philipinho d8adcd44c2 feat(base): clear row selection on view or base change 2026-04-18 16:46:07 +01:00
Philipinho 6a230b14ca feat(base): keyboard delete and esc to clear selection 2026-04-18 16:45:38 +01:00
Philipinho 05406640f0 feat(base): floating selection action bar with bulk delete 2026-04-18 16:44:07 +01:00
Philipinho 4c4bbe9b15 feat(base): header select-all with tri-state checkbox 2026-04-18 16:42:06 +01:00
Philipinho 3fca962c9f feat(base): row-number cell renders checkbox + drag handle on hover 2026-04-18 16:40:21 +01:00
Philipinho fda163311a feat(base): add use-row-selection hook 2026-04-18 16:37:47 +01:00
Philipinho 0d824dcd24 feat(base): add row selection atoms 2026-04-18 16:35:07 +01:00
Philipinho 8d793ec26b feat(base): add useDeleteRowsMutation with optimistic update 2026-04-18 16:35:00 +01:00
Philipinho 0bbcc7ee30 feat(base): add deleteRows client service + type 2026-04-18 16:34:19 +01:00
Philipinho e017209d76 feat(base): emit base:rows:deleted websocket event 2026-04-18 16:32:27 +01:00
Philipinho fc734475df feat(base): add POST /bases/rows/delete-many endpoint 2026-04-18 16:31:44 +01:00
Philipinho a7f9d66778 feat(base): add deleteMany service method for batch row delete 2026-04-18 16:31:11 +01:00
Philipinho 4a9e891582 feat(base): add BASE_ROWS_DELETED event type 2026-04-18 16:29:26 +01:00
Philipinho 65c5bb11b8 feat(base): add DeleteRowsDto for batch row delete 2026-04-18 16:29:02 +01:00
Philipinho 1466d95078 feat(base): add findByIds and softDeleteMany to base-row repo 2026-04-18 16:28:39 +01:00
Philipinho 901445305d docs: drop unused selectionCount in row-number-header-cell sample 2026-04-18 16:20:40 +01:00
Philipinho 5985238b4b docs: tighten row selection plan per review (consolidate tasks 13-14, fix deps) 2026-04-18 16:20:02 +01:00
Philipinho 10ee8d0c85 docs: add base row selection and bulk delete implementation plan 2026-04-18 16:16:58 +01:00
Philipinho d2f19b2aa0 docs: clarify base row selection spec edge cases per review 2026-04-18 16:09:02 +01:00
Philipinho 493915a0c3 docs: add base row selection and bulk delete design spec 2026-04-18 16:07:58 +01:00
Philipinho da49ffc332 fix orderBy 2026-04-18 15:17:20 +01:00
Philipinho b95f3033d1 style(base): add focus-preservation comment to status cell mousedown 2026-04-18 15:07:05 +01:00
Philipinho 88c906cdcd feat(base): keyboard navigation for status cell dropdown 2026-04-18 15:05:30 +01:00
Philipinho 836a25cdbf feat(base): keyboard navigation for multi-select cell dropdown 2026-04-18 15:03:04 +01:00
Philipinho b02b2cd5d8 refactor(base): hoist NavItem type and drop IIFE in select cell 2026-04-18 15:01:12 +01:00
Philipinho bb398bb7d6 feat(base): keyboard navigation for single-select cell dropdown 2026-04-18 14:58:19 +01:00
Philipinho 4cefa40f5b refactor(base): destructure useListKeyboardNav and use clsx in person cell 2026-04-18 14:55:59 +01:00
Philipinho 2ca27f16a1 feat(base): keyboard navigation for person cell dropdown 2026-04-18 14:52:09 +01:00
Philipinho f8edb587e4 feat(base): add useListKeyboardNav hook for dropdown keyboard nav 2026-04-18 14:48:30 +01:00
Philipinho 0f4a819ec5 style(base): add keyboard-active option style for cell dropdowns 2026-04-18 14:47:59 +01:00
Philipinho ede1a799f2 feat(base): disable type-conversion API for v1, preserve engine for v2 2026-04-18 14:13:08 +01:00
Philipinho 845b49968e feat(base): replace property type picker with read-only display 2026-04-18 13:34:33 +01:00
Philipinho b244f831da refactor(base): drop type-change invalidation branch from update-property mutation 2026-04-18 13:29:32 +01:00
Philipinho 2ececc8203 fix(base): remove maxPages cap that caused infinite scroll loop past row 500 2026-04-18 13:26:25 +01:00
Philipinho 6d9107b727 refactor(base): drop unused schema:bumped socket handler 2026-04-18 13:23:36 +01:00
Philipinho 5ae49cab49 refactor(base): prune deleted property cells locally instead of invalidating rows 2026-04-18 13:18:51 +01:00
Philipinho 89638fb11d refactor(base): append remote row creates to cache instead of invalidating 2026-04-18 13:15:49 +01:00
Philipinho f5b19316af Base WIP 2026-04-18 13:13:53 +01:00
Philipinho 081bb67239 Merge branch 'main' into base 2026-04-17 13:48:49 +01:00
Philipinho eb0538b856 fix 2026-04-17 13:41:24 +01:00
Philipinho 084746e65a WIP 2026-03-09 01:08:15 +00:00
Philipinho 4ff13cef62 sort cursor pagination 2026-03-08 04:00:44 +00:00
Philipinho 2a6e604bf8 person cell 2026-03-08 03:36:57 +00:00
Philipinho 674b0ec64a filter/sort, file, person 2026-03-08 03:15:49 +00:00
Philipinho ac03a54ae6 make recent 2026-03-08 02:36:00 +00:00
Philipinho 2cf7958dac Merge branch 'main' into base 2026-03-08 01:57:17 +00:00
Philipinho 94ee1e80fb feat: bases - WIP 2026-03-08 00:56:24 +00:00
403 changed files with 24491 additions and 14317 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -0,0 +1,479 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
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 (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 510MB per-origin cap, but practically negligible. No cleanup logic needed.
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
1. "Save for everyone" (current behavior)
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
## Future extension
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
-2
View File
@@ -28,8 +28,6 @@ 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
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.1",
"version": "0.90.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -34,7 +34,7 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.2",
"@tanstack/react-virtual": "3.13.24",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
@@ -45,7 +45,7 @@
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"js-cookie": "3.0.5",
"jwt-decode": "4.0.0",
"katex": "0.16.40",
"lowlight": "3.3.0",
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"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."
}
@@ -41,8 +41,6 @@
"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",
@@ -78,24 +76,6 @@
"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",
@@ -444,7 +424,6 @@
"Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}",
"now": "now",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
@@ -617,8 +596,6 @@
"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",
@@ -1125,23 +1102,5 @@
"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"
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
"Page menu for {{name}}": "Menú de página para {{name}}",
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
"Create subpage of {{name}}": "Crear subpágina de {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
"Page menu for {{name}}": "Menu de la page pour {{name}}",
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
"Create subpage of {{name}}": "Créer une sous-page de {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
"Page menu for {{name}}": "Menu della pagina per {{name}}",
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成"
"Create subpage of {{name}}": "{{name}} のサブページを作成",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
"Page menu for {{name}}": "Menu da página de {{name}}",
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} додано до обраного",
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
"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."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
"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."
}
+1 -1
View File
@@ -38,7 +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 BasePage from "@/pages/base/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";
@@ -1,18 +0,0 @@
import { ThemeIcon } from "@mantine/core";
import { IconFileDescription, IconTable } from "@tabler/icons-react";
type Props = {
icon?: string | null;
isBase?: boolean;
};
export function PageListIcon({ icon, isBase }: Props) {
if (icon) {
return <>{icon}</>;
}
return (
<ThemeIcon variant="transparent" color="gray" size={18}>
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
</ThemeIcon>
);
}
@@ -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, getPageTitle } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { IconFileDescription, 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,10 +50,14 @@ export default function RecentChanges({ spaceId }: Props) {
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
<PageListIcon icon={page.icon} isBase={page.isBase} />
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{getPageTitle(page.title, page.isBase, t)}
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Modal, Button, Group, Divider } from "@mantine/core";
import { Modal, Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DestinationPicker } from "./destination-picker";
import {
@@ -52,9 +52,7 @@ export function DestinationPickerModal({
searchSpacesOnly={searchSpacesOnly}
/>
<Divider my="md" />
<Group justify="flex-end">
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Close")}
</Button>
@@ -89,6 +89,14 @@
}
}
.selectedIndicator {
padding: 8px 12px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: var(--mantine-spacing-xs);
}
.emptyState {
padding: 12px;
text-align: center;
@@ -221,6 +221,14 @@ export function DestinationPicker({
))
)}
</ScrollArea>
{selection && (
<div className={classes.selectedIndicator}>
{selection.type === "space"
? selection.space.name
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
</div>
)}
</>
);
}
@@ -3,7 +3,6 @@ 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";
@@ -96,7 +95,7 @@ export function PageRow({
</div>
<div className={classes.pageTitle}>
{getPageTitle(page.title, page.isBase, t)}
{page.title || t("Untitled")}
</div>
</div>
-38
View File
@@ -1,39 +1 @@
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.
@@ -1,11 +1,11 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
@@ -23,11 +23,10 @@ export function ApiKeyTable({
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString(i18n.language, {
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
@@ -1,20 +0,0 @@
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<RowReferences>({ 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 },
};
}
@@ -1,91 +0,0 @@
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 (
<input
className={classes.embedTitleInput}
value={value}
placeholder={t("Untitled base")}
aria-label={t("Base name")}
onChange={(e) => {
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();
}
}}
/>
);
}
@@ -1,299 +0,0 @@
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 { useBaseEditable } from "@/ee/base/context/base-editable";
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<IBaseRow>;
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 editable = useBaseEditable();
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<FilterCondition[]>(() => {
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 (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
pageId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
base={base}
canAddView={canAddView}
getViewShareUrl={getViewShareUrl}
/>
<div className={classes.toolbarRight}>
{editable && (
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
)}
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
{isKanban && activeView && (
<>
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
<Tooltip label={t("Group by")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<IconLayoutColumns size={16} />
</ActionIcon>
</Tooltip>
</KanbanGroupByPicker>
<KanbanCardProperties
opened={cardPropertiesOpened}
onClose={() => setCardPropertiesOpened(false)}
base={base}
view={activeView}
pageId={base.id}
>
<Tooltip label={t("Card properties")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() => setCardPropertiesOpened((v) => !v)}
>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
</KanbanCardProperties>
</>
)}
{!isKanban && (
<>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
{table && (
<ViewPropertyVisibility
opened={propertiesOpened}
onClose={() => setPropertiesOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
>
<IconEye size={16} />
{hiddenPropertyCount > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{hiddenPropertyCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewPropertyVisibility>
)}
</>
)}
{onExpand && (
<Tooltip label={t("Open as page")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onExpand}
>
<IconArrowsDiagonal size={16} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
);
}
@@ -1,526 +0,0 @@
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,
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,
} 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 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=<id> 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<HTMLDivElement>(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(() => {
if (!editable) return;
createRowMutation.mutate(
{ pageId },
{
onSuccess: (newRow) => {
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)
);
});
const propertyId = (
firstEditable?.columnDef.meta?.property as IBaseProperty | undefined
)?.id;
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
}
},
},
);
}, [editable, pageId, createRowMutation, table, setEditingCell]);
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 <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconTable size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
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 = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
canAddView={editable}
onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
onExpand={embedded ? handleExpand : undefined}
getViewShareUrl={getViewShareUrl}
/>
);
const kanbanBand = (
<div className={kanbanClasses.bandWrap}>
{embedded ? null : titleSlot}
{banner}
{toolbar}
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
</div>
);
const viewRenderer = (folded: React.ReactNode) => (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
editable={editable}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
kanbanFilter={activeFilter}
aboveBand={folded}
/>
);
if (embedded) {
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// 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 (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>,
)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<div className={kanbanClasses.standalone}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned.
return (
<BaseEditableProvider editable={editable}>
<div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{titleSlot}
{banner}
{toolbar}
</>,
)}
</RowExpandProvider>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
@@ -1,100 +0,0 @@
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<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(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 (
<Tooltip
label={tooltipLabel}
multiline
withinPortal
openDelay={400}
disabled={!tooltipLabel || overflow <= 0}
>
<div className={cellClasses.badgeGroup} ref={containerRef}>
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
{chips}
<span className={cellClasses.overflowCount}>+{chips.length}</span>
</div>
{visible}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
</Tooltip>
);
}
@@ -1,44 +0,0 @@
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 (
<div
className={cellClasses.checkboxCell}
onClick={handleChange}
style={readOnly ? { cursor: "default" } : undefined}
>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{
input: {
cursor: readOnly ? "default" : "pointer",
pointerEvents: "none",
},
}}
/>
</div>
);
}
@@ -1,22 +0,0 @@
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 <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -1,61 +0,0 @@
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 (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -1,22 +0,0 @@
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 <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -1,41 +0,0 @@
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 <span className={cellClasses.emptyValue} />;
}
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<span className={cellClasses.lastEditedByName}>{name}</span>
</Tooltip>
</Group>
);
}
@@ -1,151 +0,0 @@
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<HTMLTextAreaElement>(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 (
<Popover
opened={isEditing}
onChange={(opened) => {
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
>
<Popover.Target>
<div className={cellClasses.popoverTargetFlex}>
{preview ? (
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
<span className={cellClasses.longTextPreview}>{preview}</span>
</Tooltip>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown
p={4}
onClick={(e) => e.stopPropagation()}
className={cellClasses.longTextDropdown}
>
{isEditing && (
<>
<Group justify="flex-end" mb={2}>
<CloseButton size="sm" onClick={commit} aria-label="Close" />
</Group>
<Textarea
ref={textareaRef}
data-autofocus
autosize
minRows={3}
maxRows={12}
maxLength={25000}
variant="unstyled"
value={draft}
onChange={(e) => {
setDraft(e.currentTarget.value);
debouncedAutosave();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
commit();
onTabNavigate?.(e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();
}
}}
styles={{ input: { padding: 4 } }}
/>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,158 +0,0 @@
import {
IBaseProperty,
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { snapNumber } from "@docmost/base-formula/client";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
comma_period: { group: ",", decimal: "." },
period_comma: { group: ".", decimal: "," },
space_comma: { group: " ", decimal: "," },
space_period: { group: " ", decimal: "." },
};
function separatorChars(style: string): { group: string; decimal: string } {
if (style === "local") {
const parts = new Intl.NumberFormat().formatToParts(11111.1);
return {
group: parts.find((p) => p.type === "group")?.value ?? ",",
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
};
}
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
}
function formatPlain(
value: number,
precision: number | undefined,
style: string,
): string {
const fixed = precision == null ? String(value) : value.toFixed(precision);
if (style === "none") return fixed;
const { group, decimal } = separatorChars(style);
const neg = fixed[0] === "-";
const abs = neg ? fixed.slice(1) : fixed;
const dot = abs.indexOf(".");
const intPart = dot === -1 ? abs : abs.slice(0, dot);
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
return neg ? `-${out}` : out;
}
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "currency":
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function sanitizeNumberInput(text: string): string {
return text.replace(/[^0-9.-]/g, "");
}
export function parseNumberDraft(draft: string): number | null {
const cleaned = sanitizeNumberInput(draft);
if (cleaned === "" || cleaned === "-") return null;
const parsed = Number(cleaned);
return isNaN(parsed) ? null : parsed;
}
export function CellNumber({
value,
property,
rowId,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const numValue = typeof value === "number" ? value : null;
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={cellClasses.numberValue}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{formatNumber(numValue, typeOptions)}
</AutoTooltipText>
);
}
@@ -1,60 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
import gridClasses from "@/ee/base/styles/grid.module.css";
type CellTextProps = {
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;
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
maxLength={1000}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={gridClasses.cellContent}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{displayValue}
</AutoTooltipText>
);
}
@@ -1,70 +0,0 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { sanitizeUrl } from "@docmost/editor-ext";
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 CellUrlProps = {
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 CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return <span>{displayValue}</span>;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -1,30 +0,0 @@
function isEmpty(v: unknown): boolean {
return v === null || v === undefined || v === "";
}
/**
* Deep equality for cell values. Treats null/undefined/"" as equivalent
* "empty", compares arrays by ordered content, objects by serialized form.
* Note: an empty array [] is a real value, distinct from "empty".
*/
export function cellValuesEqual(a: unknown, b: unknown): boolean {
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (!aArr && !bArr) {
if (isEmpty(a) && isEmpty(b)) return true;
if (isEmpty(a) !== isEmpty(b)) return false;
}
if (aArr || bArr) {
if (!aArr || !bArr) return false;
if (a.length !== b.length) return false;
return a.every((x, i) => cellValuesEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object" && a && b) {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
@@ -1,29 +0,0 @@
import { CSSProperties, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
type ChoiceBadgeProps = {
name: string;
style: CSSProperties;
};
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
return (
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
<span
ref={ref}
className={cellClasses.badge}
style={style}
onMouseEnter={() => {
const el = ref.current;
if (el) setTruncated(el.scrollWidth > el.clientWidth);
}}
>
{name}
</span>
</Tooltip>
);
}
@@ -1,258 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORY_LABELS: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type ChoiceGroup = { label: string | null; choices: Choice[] };
type ChoicePickerProps = {
property: IBaseProperty;
selectedIds: string[];
/** Multi keeps the picker open, hides picked options from the list and
* shows them as removable tags instead. */
multiple?: boolean;
/** Group options under status category headings. */
grouped?: boolean;
/** Offer "Add option: <search>" when the search has no exact match. */
allowCreate?: boolean;
onToggle: (choice: Choice) => void;
onEscape: () => void;
};
/** Searchable choice list shared by select-like editors (modal fields; the
* grid cells render the same UI and can migrate here). */
export function ChoicePicker({
property,
selectedIds,
multiple = false,
grouped = false,
allowCreate = false,
onToggle,
onEscape,
}: ChoicePickerProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const groups = useMemo<ChoiceGroup[]>(() => {
const filtered = (
search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices
).filter((c) => !multiple || !selectedSet.has(c.id));
if (!grouped) return [{ label: null, choices: filtered }];
const byCategory: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
(byCategory[cat] ??= []).push(choice);
}
return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map(
(key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }),
);
}, [choices, search, grouped, multiple, selectedSet]);
const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[flatChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onToggle(newChoice);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") onToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption],
);
const addOptionIdx = flatChoices.length;
return (
<>
{multiple && selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
onToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label ?? "all"}>
{group.label && (
<div className={cellClasses.selectCategoryLabel}>{group.label}</div>
)}
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = !multiple && selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => onToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span className={cellClasses.badge} style={choiceColor(addOptionColor)}>
{trimmedSearch}
</span>
</div>
)}
</div>
</>
);
}
@@ -1,40 +0,0 @@
import clsx from "clsx";
import { UserRef } from "@/ee/base/types/base.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
type PersonReadListProps = {
personIds: string[];
users: Record<string, UserRef>;
};
export function PersonReadList({ personIds, users }: PersonReadListProps) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -1,48 +0,0 @@
import { FormulaEditor } from "./formula-editor";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import {
IBaseProperty,
FormulaTypeOptions,
TypeOptions,
} from "@/ee/base/types/base.types";
type Props = {
property: IBaseProperty;
pageId: string;
onClose: () => void;
};
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
const { data: base } = useBaseQuery(pageId);
const updatePropertyMutation = useUpdatePropertyMutation();
const opts = property.typeOptions as FormulaTypeOptions | undefined;
return (
<FormulaEditor
properties={base?.properties ?? []}
editingPropertyId={property.id}
initialSource={opts?.source ?? ""}
name={property.name}
onCancel={onClose}
onSave={(source, ast, resultType, dependencies) => {
if (source === (opts?.source ?? "")) {
onClose();
return;
}
updatePropertyMutation.mutate({
propertyId: property.id,
pageId,
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
});
onClose();
}}
/>
);
}
@@ -1,290 +0,0 @@
import { memo, useCallback, useMemo } from "react";
import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom, useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { IBaseRow, EditingCell, FocusedCell } from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import {
isSystemPropertyType,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/ee/base/styles/grid.module.css";
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
colIndex?: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
pageId: string;
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
colIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
const property = cell.column.columnDef.meta?.property;
const isRowNumber = cell.column.id === "__row_number";
const isPinned = cell.column.getIsPinned();
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setFocusedCell = useSetAtom(focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === cell.row.id && fc?.propertyId === property?.id,
),
[pageId, cell.row.id, property?.id],
),
);
const { t } = useTranslation();
const editable = useBaseEditable();
const readOnly = !editable;
const onExpandRow = useRowExpand();
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable.
if (property.type === "file") {
setEditingCell({ rowId, propertyId: property.id });
}
return;
}
if (property.type === "formula") {
setActiveFormulaEditor({ propertyId: property.id, rowId });
return;
}
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property) return;
setFocusedCell({ rowId, propertyId: property.id });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[property, rowId, setFocusedCell],
);
const cellReadOnly = property
? readOnly || isSystemPropertyType(property.type)
: false;
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const handleValueChange = useCallback(
(value: unknown) => {
if (!property) return;
if (!cellValuesEqual(value, cell.getValue())) {
onCellUpdate(rowId, property.id, value);
}
},
[property, rowId, cell, onCellUpdate],
);
const handleCommit = useCallback(
(value: unknown) => {
handleValueChange(value);
setEditingCell(null);
},
[handleValueChange, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
const handleTabNavigate = useCallback(
(shiftKey: boolean) => {
if (!property) return;
const tableInstance = cell.getContext().table;
const colIds = tableInstance
.getVisibleLeafColumns()
.filter((c) => c.id !== "__row_number")
.map((c) => c.id);
const rowIds = tableInstance.getRowModel().rows.map((r) => r.id);
const next = computeNextCell(
rowIds,
colIds,
{ rowId, propertyId: property.id },
0,
shiftKey ? -1 : 1,
true,
);
if (next) {
setEditingCell(next);
setFocusedCell(next);
}
},
[cell, rowId, property, setEditingCell, setFocusedCell],
);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
pageId={pageId}
/>
);
}
if (!property) return null;
const CellComponent = getDescriptor(property.type)?.cellComponent;
if (!CellComponent) return null;
const value = cell.getValue();
const cellInner = (
<div
id={`base-cell-${rowId}-${property.id}`}
role="gridcell"
aria-colindex={colIndex != null ? colIndex + 1 : undefined}
aria-readonly={cellReadOnly || undefined}
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${isFocused && !isEditing ? classes.cellFocused : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
onTabNavigate={handleTabNavigate}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
tabIndex={-1}
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
>
<IconArrowsDiagonal size={13} />
</button>
</Tooltip>
</span>
)}
</div>
);
if (property.type !== "formula") return cellInner;
const formulaEditorOpen =
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === rowId;
return (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>{cellInner}</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
);
},
gridCellPropsEqual);
// Cell instances are re-created whenever the table data identity changes;
// compare by coordinates + value so unchanged cells skip re-rendering.
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.colIndex !== next.colIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
return false;
}
if (prev.cell === next.cell) return true;
return (
prev.cell.row.id === next.cell.row.id &&
prev.cell.column.id === next.cell.column.id &&
prev.cell.column.columnDef.meta?.property ===
next.cell.column.columnDef.meta?.property &&
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
);
}
@@ -1,551 +0,0 @@
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
observeWindowOffset,
observeWindowRect,
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom, useSetAtom, type PrimitiveAtom } from "jotai";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
pendingTypeInsertAtom,
type FormulaEditorTarget,
type PendingTypeInsert,
} from "@/ee/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { GridGhostRows } from "./grid-ghost-rows";
import { SelectionActionBar } from "./selection-action-bar";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
// var(--base-row-height) from this constant so the rendered height can
// never drift from the virtualizer estimate.
const ROW_HEIGHT = 37;
const OVERSCAN = 25;
const GRID_ROOT_STYLE = {
"--base-row-height": `${ROW_HEIGHT}px`,
} as React.CSSProperties;
const ADD_COLUMN_TRACK_WIDTH = 40;
// Hoisted to module scope to avoid allocating a fresh options object on
// every GridContainer render. The function refs from virtual-core are
// stable; only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/** true when a view filter with at least one condition is active; suppresses ghost rows */
isFiltered?: boolean;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
* with the content while only the column-header row stays pinned. In
* inline mode BaseTable injects banner + toolbar here; standalone passes
* null (they render outside the scrollport instead).
*/
aboveBand?: React.ReactNode;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
pageId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
isFiltered,
scrollElement,
aboveBand,
}: GridContainerProps) {
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const rowsContainerRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
useGridAutoScroll(bodyRef, pageId);
useRowAutoScroll(scrollElement, pageId);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const rowIdsRef = useRef(rowIds);
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
const onExpandRow = useRowExpand();
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const editingCellRef = useRef(editingCell);
editingCellRef.current = editingCell;
const { selectionCount, clear: clearSelection, toggle: toggleRow } = useRowSelection(pageId);
const { deleteSelected } = useDeleteSelectedRows(pageId);
const { t } = useTranslation();
const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void];
const focusedCellRef = useRef(focusedCell);
focusedCellRef.current = focusedCell;
const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setPendingTypeInsert = useSetAtom(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
// This handler's sole job is to commit an inline input editor
// (text/number/url/email) when the user clicks elsewhere, since clicking
// a non-focusable cell does not natively blur the input. Gating on
// editingCell also stops it from stealing focus from unrelated inputs.
if (!editingCellRef.current) return;
const target = e.target as HTMLElement;
if (target.closest(`.${classes.headerCell}`)) return;
if (target.closest("[role=\"dialog\"]")) return;
if (target.closest("[role=\"listbox\"]")) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
if (target.closest(`.${classes.cellEditing}`)) return;
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
// which also clears editingCell. No setEditingCell(null) needed here.
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body && typeof active.blur === "function") {
active.blur();
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, []);
useColumnResize(table, onResizeEnd ?? (() => {}));
// When the scroll container is the window (inline embed mode), the default
// Element-mode observers read scrollTop/scrollLeft, which Window does not
// have. Swap in the Window-mode observers so the virtualizer reads
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
// upcast on getScrollElement; virtual-core's runtime accepts Window when
// the observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Rows are positioned inside .rowsContainer, which sits below the sticky
// band (and aboveBand content) within the scroll content. scrollMargin =
// the container's offset from the scroll content top, in both modes, so
// virtual indexing lines up with what is actually on screen.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const el = rowsContainerRef.current;
if (!el || !scrollElement) return;
const update = () => {
const rect = el.getBoundingClientRect();
if (isWindowScroll) {
setScrollMargin(rect.top + window.scrollY);
} else {
const scrollport = scrollElement as HTMLElement;
setScrollMargin(
rect.top -
scrollport.getBoundingClientRect().top +
scrollport.scrollTop,
);
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) can move the
// grid without resizing it, so listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll, scrollElement]);
// Stable row-id keys: the direct-update element cache and measurement
// cache are keyed by item key, so index keys would go stale whenever rows
// are inserted or reordered above the viewport.
const getItemKey = useCallback(
(index: number) => rowIds[index] ?? index,
[rowIds],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
getItemKey,
directDomUpdates: true,
// 'position' (writes `top`), not 'transform': a transform on the row
// creates a containing block that breaks the position:sticky pinned
// cells inside it.
directDomUpdatesMode: "position",
...windowScrollOptions,
// virtual-core bug: on first attach _willUpdate calls
// _scrollToOffset(getScrollOffset()), which returns undefined when no
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
// mid-page. Seeding initialOffset to the current scroll position makes
// the first _scrollToOffset a no-op.
initialOffset: isWindowScroll
? () => window.scrollY
: () =>
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
});
const virtualItems = virtualizer.getVirtualItems();
const pinnedLeftWidth = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((c) => c.getIsPinned() === "left")
.reduce((sum, c) => sum + c.getSize(), 0),
[table],
);
const scrollCellIntoView = useCallback(
(coord: CellCoord, rowIndex: number) => {
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
requestAnimationFrame(() => {
const scroller = bodyRef.current;
const el = document.getElementById(
`base-cell-${coord.rowId}-${coord.propertyId}`,
);
if (!scroller || !el) return;
const cellRect = el.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const pinned = pinnedLeftWidth();
if (cellRect.left < scRect.left + pinned) {
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
} else if (cellRect.right > scRect.right) {
scroller.scrollLeft += cellRect.right - scRect.right;
}
});
},
[virtualizer, pinnedLeftWidth],
);
const openEditor = useCallback(
(coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop) return;
if (prop.type === "checkbox") {
if (!editable) return;
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
onCellUpdate(coord.rowId, coord.propertyId, !current);
return;
}
if (!editable) {
if (prop.type === "file") setEditingCell(coord);
return;
}
if (prop.type === "formula") {
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
return;
}
if (isSystemPropertyType(prop.type)) return;
setEditingCell(coord);
},
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
);
const clearCell = useCallback(
(coord: CellCoord) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type)) return;
onCellUpdate(coord.rowId, coord.propertyId, null);
},
[editable, properties, onCellUpdate],
);
const beginTypeToEdit = useCallback(
(coord: CellCoord, char: string) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
if (["text", "number", "url", "email"].includes(prop.type)) {
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
setEditingCell(coord);
} else {
openEditor(coord);
}
},
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
);
const toggleRowSelection = useCallback(
(rowId: string) => {
toggleRow(rowId, {
shiftKey: false,
rowIndex: rowIdsRef.current.indexOf(rowId),
orderedRowIds: rowIdsRef.current,
});
},
[toggleRow],
);
const expandRow = useCallback(
(rowId: string) => {
onExpandRow?.(rowId);
},
[onExpandRow],
);
const prevEditingRef = useRef(editingCell);
useEffect(() => {
const prev = prevEditingRef.current;
prevEditingRef.current = editingCell;
if (prev && !editingCell) {
if (!focusedCellRef.current) setFocusedCell(prev);
const grid = bodyRef.current;
const active = document.activeElement;
if (grid && active && !grid.contains(active)) {
grid.focus({ preventScroll: true });
}
}
}, [editingCell, setFocusedCell]);
useEffect(() => {
const fc = focusedCellRef.current;
if (!fc) return;
const rowOk = rowIds.includes(fc.rowId);
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
if (!rowOk || !colOk) setFocusedCell(null);
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
const handleGridFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (editingCellRef.current || focusedCellRef.current) return;
const firstRow = rowIdsRef.current[0];
const firstCol = table
.getVisibleLeafColumns()
.find((c) => c.id !== "__row_number")?.id;
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
},
[table, setFocusedCell],
);
useGridKeyboardNav({
table,
properties,
containerRef: bodyRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
});
const activeCell = editingCell ?? focusedCell;
const activeDescendantId = activeCell
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
: undefined;
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
useEffect(() => {
// When the row set shrinks (filter/sort/view change) or resets to zero,
// un-gate the trigger so the first page can trigger the next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return (
columnWidths.join(" ") +
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
);
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
const totalColumnsWidth = useMemo(
() =>
table
.getVisibleLeafColumns()
.reduce((sum, col) => sum + col.getSize(), 0) +
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
);
const showGhostRows = rows.length === 0 && !isFiltered;
// Append a flexible trailing track so every row spans the full width.
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
// fills remaining width otherwise. The header grid keeps the plain template.
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const getColumnOrder = useCallback(
() => table.getState().columnOrder,
[table],
);
return (
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<GridRowOrderProvider value={getOrderedRowIds}>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
role="grid"
aria-label={t("Base table")}
aria-rowcount={rows.length}
aria-colcount={table.getVisibleLeafColumns().length}
aria-multiselectable
aria-activedescendant={activeDescendantId}
onFocus={handleGridFocus}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
} as React.CSSProperties
}
>
<div
className={classes.rowsContainer}
ref={(node) => {
rowsContainerRef.current = node;
virtualizer.containerRef(node);
}}
role="rowgroup"
style={{ width: totalColumnsWidth, minWidth: "100%" }}
>
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
measureRef={virtualizer.measureElement}
onCellUpdate={onCellUpdate}
properties={properties}
columnVisibility={table.getState().columnVisibility}
columnOrder={table.getState().columnOrder}
pageId={pageId}
onRowReorder={onRowReorder}
/>
);
})}
</div>
{showGhostRows && (
<GridGhostRows
count={3}
columnCount={table.getVisibleLeafColumns().length}
onCreate={editable ? handleAddRow : undefined}
/>
)}
{editable && <AddRowButton onClick={handleAddRow} />}
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</GridRowOrderProvider>
</div>
);
}
@@ -1,32 +0,0 @@
import classes from "@/ee/base/styles/grid.module.css";
type GridGhostRowsProps = {
/** how many placeholder rows to render */
count: number;
/** number of visible leaf columns (incl. the row-number column) */
columnCount: number;
/** create the first real row (clicking any ghost cell); omit when read-only */
onCreate?: () => void;
};
// Empty-state ghost rows shown when no data rows exist and no filter is active.
// Clicking any ghost row creates the first real row; cells align via subgrid.
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
return (
<>
{Array.from({ length: count }).map((_, rowIdx) => (
<div
key={rowIdx}
className={`${classes.row} ${classes.ghostRow}`}
role={onCreate ? "button" : undefined}
aria-label={onCreate ? "Create first row" : undefined}
onClick={onCreate}
>
{Array.from({ length: columnCount }).map((_, colIdx) => (
<div key={colIdx} className={classes.cell} aria-hidden="true" />
))}
</div>
))}
</>
);
}
@@ -1,198 +0,0 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Row, VisibilityState } from "@tanstack/react-table";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { GridCell } from "./grid-cell";
import classes from "@/ee/base/styles/grid.module.css";
export const ROW_DRAG_TYPE = "base-row";
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
measureRef: (node: Element | null) => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onRowReorder?: (
rowId: string,
targetRowId: string,
position: "above" | "below",
) => void;
properties: IBaseProperty[];
columnVisibility: VisibilityState;
columnOrder: string[];
pageId: string;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
measureRef,
onCellUpdate,
onRowReorder,
pageId,
}: GridRowProps) {
const rowId = row.id;
const isSelected = useRowSelection(pageId).isSelected(rowId);
const rowRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const setRowEl = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
measureRef(node);
},
[measureRef],
);
// onRowReorder ultimately depends on React Query result objects (activeView,
// base) via persistViewConfig, and its identity changes on every WS-driven
// cache invalidation. Holding it in a ref keeps it out of the DnD effect's
// dep array so we don't tear down and re-register every row's pragmatic-dnd
// adapter each time another user edits the base. Same pattern as the column
// header's onColumnReorderRef.
const onRowReorderRef = useRef(onRowReorder);
useLayoutEffect(() => {
onRowReorderRef.current = onRowReorder;
});
useEffect(() => {
const rowEl = rowRef.current;
if (!rowEl || !onRowReorder) return;
// The whole row is the draggable element (full-row native preview).
// dragHandle limits initiation to the grip, leaving cell clicks and
// inline editing untouched.
const handle = rowEl.querySelector<HTMLElement>(
`.${classes.rowNumberDragHandle}`,
);
if (!handle) return;
return combine(
draggable({
element: rowEl,
dragHandle: handle,
getInitialData: () => ({ type: ROW_DRAG_TYPE, rowId, pageId }),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// Native preview of the full-width sticky subgrid row rasterizes
// garbled (it pulls in surrounding page paint, e.g. the sidebar).
// Render a compact card that clones just the title cell instead.
const titleCell =
rowEl.querySelector<HTMLElement>(`.${classes.primaryCell}`) ??
rowEl.querySelector<HTMLElement>(`.${classes.cell}`);
if (!titleCell) return;
const width = titleCell.getBoundingClientRect().width;
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
render: ({ container }) => {
const card = document.createElement("div");
card.className = classes.rowDragPreview;
card.style.width = `${width}px`;
const clone = titleCell.cloneNode(true) as HTMLElement;
clone.style.position = "static";
clone.style.left = "auto";
clone.style.width = "100%";
clone.style.opacity = "1";
clone.style.borderRight = "none";
card.appendChild(clone);
container.appendChild(card);
},
});
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: rowEl,
canDrop: ({ source }) =>
source.data.type === ROW_DRAG_TYPE &&
source.data.pageId === pageId &&
source.data.rowId !== rowId,
getData: ({ input, element }) =>
attachClosestEdge(
{ rowId },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onRowReorderRef.current?.(
source.data.rowId as string,
rowId,
edge === "top" ? "above" : "below",
);
triggerPostMoveFlash(rowEl);
liveRegion.announce("Moved row");
},
}),
);
// onRowReorder is read through onRowReorderRef; only its presence gates
// registration, and that does not change across a row's mounted life.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowId, pageId]);
const dropIndicatorClass = closestEdge
? closestEdge === "top"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
ref={setRowEl}
data-index={rowIndex}
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
aria-rowindex={rowIndex + 1}
aria-selected={isSelected}
>
{row.getVisibleCells().map((cell, colIndex) => (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
colIndex={colIndex}
onCellUpdate={onCellUpdate}
pageId={pageId}
/>
))}
</div>
);
},
gridRowPropsEqual);
// row compares by row.original: React Query structural sharing keeps
// unchanged rows reference-stable, while TanStack re-instantiates Row/Cell
// wrappers on every data change. properties/columnVisibility/columnOrder are
// layout busters — schema or column-state changes must re-render rows.
function gridRowPropsEqual(prev: GridRowProps, next: GridRowProps) {
return (
prev.row.id === next.row.id &&
prev.row.original === next.row.original &&
prev.rowIndex === next.rowIndex &&
prev.pageId === next.pageId &&
prev.onCellUpdate === next.onCellUpdate &&
prev.onRowReorder === next.onRowReorder &&
prev.measureRef === next.measureRef &&
prev.properties === next.properties &&
prev.columnVisibility === next.columnVisibility &&
prev.columnOrder === next.columnOrder
);
}
@@ -1,102 +0,0 @@
import { memo, useCallback, useMemo } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { FocusedCell } from "@/ee/base/types/base.types";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberCellProps = {
rowId: string;
rowIndex: number;
isPinned: boolean;
pinOffset?: number;
pageId: string;
};
export const RowNumberCell = memo(function RowNumberCell({
rowId,
rowIndex,
isPinned,
pinOffset,
pageId,
}: RowNumberCellProps) {
const { isSelected, toggle } = useRowSelection(pageId);
const selected = isSelected(rowId);
const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder();
const setFocusedCell = useSetAtom(
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
),
[pageId, rowId],
),
);
const handleCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setFocusedCell({ rowId, propertyId: "__row_number" });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[rowId, setFocusedCell],
);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds: getOrderedRowIds(),
});
},
[rowId, rowIndex, getOrderedRowIds, toggle],
);
return (
<div
id={`base-cell-${rowId}-__row_number`}
role="gridcell"
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
onClick={handleCellClick}
>
<div className={classes.rowNumberCellInner}>
{editable && (
<span className={classes.rowNumberDragHandle} aria-label="Drag row">
<IconGripVertical size={12} />
</span>
)}
{editable && (
<span className={classes.rowNumberCheckbox}>
<Checkbox
size="xs"
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
tabIndex={-1}
/>
</span>
)}
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
</div>
</div>
);
});
@@ -1,213 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanMoveCardMutation } from "@/ee/base/queries/base-row-query";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
import classes from "@/ee/base/styles/kanban.module.css";
type BaseKanbanProps = {
base: IBase;
view: IBaseView;
pageId: string;
embedded?: boolean;
editable: boolean;
viewFilter: FilterGroup | undefined;
};
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId);
const openRowRef = useRef(openRow);
useLayoutEffect(() => { openRowRef.current = openRow; });
const handleOpenRow = useCallback((id: string) => openRowRef.current(id), []);
const boardRef = useRef<HTMLDivElement>(null);
useKanbanBoardAutoScroll(boardRef, pageId);
const cardRefs = useRef<Map<string, { columnKey: string; el: HTMLDivElement }>>(new Map());
const registerCardRef = useCallback((rowId: string, columnKey: string, el: HTMLDivElement | null) => {
if (el) {
cardRefs.current.set(rowId, { columnKey, el });
} else {
cardRefs.current.delete(rowId);
}
}, []);
const columnRows = useRef<Map<string, IBaseRow[]>>(new Map());
const registerColumnRows = useCallback((key: string, rows: IBaseRow[]) => {
columnRows.current.set(key, rows);
}, []);
const hideColumn = useCallback(
(key: string) => {
const next = Array.from(new Set([...(view.config?.hiddenChoiceIds ?? []), key]));
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
},
[updateView, view.id, view.config?.hiddenChoiceIds, pageId],
);
const onCardDropRef = useRef<(args: {
draggedRowId: string;
sourceColumnKey: string;
targetColumnKey: string;
targetRowId: string | null;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onCardDropRef.current = ({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge }) => {
if (!groupByPropertyId) return;
const targetColumnRows = columnRows.current.get(targetColumnKey) ?? [];
const result = resolveCardDrop({
draggedRowId,
targetRowId,
edge: edge === "left" || edge === "right" ? null : edge,
targetColumnKey,
sourceColumnKey,
targetColumnRows,
});
if (!result) return;
const sourceFilter = buildColumnFilter(viewFilter, groupByPropertyId, sourceColumnKey);
const destFilter = buildColumnFilter(viewFilter, groupByPropertyId, targetColumnKey);
moveCard.mutate({
pageId,
rowId: draggedRowId,
sourceColumnFilter: sourceFilter,
destColumnFilter: destFilter,
columnChanged: result.columnChanged,
groupByPropertyId,
destChoiceValue: result.destChoiceValue,
position: result.position,
});
const el = cardRefs.current.get(draggedRowId)?.el;
if (el) triggerPostMoveFlash(el);
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved card to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const draggedRowId = source.data.rowId as string;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const isColumnBody = target.data.isColumnBody === true;
const targetRowId = isColumnBody ? null : (target.data.rowId as string);
const edge = isColumnBody ? null : extractClosestEdge(target.data);
onCardDropRef.current({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge });
},
});
}, [pageId]);
const onColumnDropRef = useRef<(args: {
sourceColumnKey: string;
targetColumnKey: string;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onColumnDropRef.current = ({ sourceColumnKey, targetColumnKey, edge }) => {
const fullOrder: string[] = view.config?.choiceOrder?.length
? view.config.choiceOrder
: columns.map((c) => c.key);
const startIndex = fullOrder.indexOf(sourceColumnKey);
const indexOfTarget = fullOrder.indexOf(targetColumnKey);
if (startIndex === -1 || indexOfTarget === -1) {
const visibleKeys = columns.map((c) => c.key);
const visStart = visibleKeys.indexOf(sourceColumnKey);
const visTarget = visibleKeys.indexOf(targetColumnKey);
if (visStart === -1 || visTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex: visStart,
indexOfTarget: visTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === visStart) return;
const reorderedVisible = reorder({ list: visibleKeys, startIndex: visStart, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: [...reorderedVisible, ...(view.config?.hiddenChoiceIds ?? [])] } });
} else {
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
const newChoiceOrder = reorder({ list: fullOrder, startIndex, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: newChoiceOrder } });
}
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved column to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const edge = extractClosestEdge(target.data);
onColumnDropRef.current({ sourceColumnKey, targetColumnKey, edge });
},
});
}, [pageId]);
if (!hasValidGroupBy) {
return <KanbanEmptyState base={base} view={view} pageId={pageId} editable={editable} />;
}
return (
<div
ref={boardRef}
className={clsx(classes.board, embedded ? classes.boardEmbed : classes.boardFullPage)}
>
{columns.map((column) => (
<KanbanColumn
key={column.key}
base={base}
view={view}
pageId={pageId}
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
registerCardRef={registerCardRef}
registerColumnRows={registerColumnRows}
/>
))}
</div>
);
}
@@ -1,339 +0,0 @@
import { Text, Badge, Tooltip, Group } from "@mantine/core";
import { IconCheck, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { sanitizeUrl } from "@docmost/editor-ext";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
import {
formatNumber,
formatDateDisplay,
formatTimestamp,
formatLongTextPreview,
} from "@/ee/base/formatters/cell-formatters";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { FileValue } from "@/ee/base/components/cells/cell-file";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CardFieldProps = {
property: IBaseProperty;
value: unknown;
pageId: string;
};
export function CardField({ property, value, pageId }: CardFieldProps) {
if (value === null || value === undefined || value === "") return null;
if (Array.isArray(value) && value.length === 0) return null;
switch (property.type) {
case "text":
return <TextField value={value} />;
case "longText":
return <LongTextField value={value} />;
case "number":
return <NumberField value={value} property={property} />;
case "select":
case "status":
return <SelectField value={value} property={property} />;
case "multiSelect":
return <MultiSelectField value={value} property={property} />;
case "date":
return <DateField value={value} property={property} />;
case "createdAt":
case "lastEditedAt":
return <TimestampField value={value} />;
case "person":
return <PersonField value={value} pageId={pageId} />;
case "lastEditedBy":
return <LastEditedByField value={value} pageId={pageId} />;
case "file":
return <FileField value={value} />;
case "page":
return <PageField value={value} basePageId={pageId} propertyPageId={property.pageId} />;
case "checkbox":
return <CheckboxField value={value} />;
case "url":
return <UrlField value={value} />;
case "email":
return <EmailField value={value} />;
case "formula":
return <FormulaField value={value} property={property} />;
default:
return (
<Text size="xs" lineClamp={1}>
{String(value)}
</Text>
);
}
}
function TextField({ value }: { value: unknown }) {
const text = typeof value === "string" ? value : String(value);
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
function LongTextField({ value }: { value: unknown }) {
const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
if (!preview) return null;
return (
<Text size="xs" c="dimmed" lineClamp={2}>
{preview}
</Text>
);
}
function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
const num = typeof value === "number" ? value : null;
if (num === null) return null;
const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
if (!formatted) return null;
return <Text size="sm">{formatted}</Text>;
}
function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const choice = choices.find((c) => c.id === selectedId);
if (!choice) return null;
return (
<ChoiceBadge
name={choice.name}
style={{ ...choiceColor(choice.color), alignSelf: "flex-start" }}
/>
);
}
function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
if (selectedChoices.length === 0) return null;
const chips = selectedChoices.map((choice) => (
<span key={choice.id} className={cellClasses.badge} style={choiceColor(choice.color)}>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={selectedChoices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
const dateStr = typeof value === "string" ? value : null;
const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function TimestampField({ value }: { value: unknown }) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
const store = useReferenceStore(pageId);
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
if (personIds.length === 0) return null;
return <PersonReadList personIds={personIds} users={store.users} />;
}
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(pageId);
if (!userId) return null;
const user = store.users[userId] ?? null;
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar avatarUrl={user?.avatarUrl ?? ""} name={name} size={20} radius="xl" />
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<Text size="xs" truncate>
{name}
</Text>
</Tooltip>
</Group>
);
}
function FileField({ value }: { value: unknown }) {
const files = Array.isArray(value)
? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
: [];
if (files.length === 0) return null;
const maxVisible = 2;
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
{file.fileName}
</span>
))}
{overflow > 0 && <span className={cellClasses.overflowCount}>+{overflow}</span>}
</div>
);
}
function PageField({
value,
basePageId,
propertyPageId,
}: {
value: unknown;
basePageId: string;
propertyPageId: string;
}) {
const { t } = useTranslation();
const pageId = typeof value === "string" && value.length > 0 ? value : null;
const resolvedPage = useResolvePage(propertyPageId, pageId);
if (!pageId) return null;
if (resolvedPage === undefined) return null;
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
const title = getPageTitle(resolvedPage.title, undefined, t);
const spaceSlug = resolvedPage.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{resolvedPage.icon ? (
<span className={cellClasses.pagePillIcon}>{resolvedPage.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
function CheckboxField({ value }: { value: unknown }) {
if (value !== true) return null;
return <IconCheck size={14} />;
}
function UrlField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return (
<Text size="xs" lineClamp={1}>
{displayValue}
</Text>
);
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function EmailField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`} withinPortal>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
if (resultType === "number") {
return <NumberField value={value} property={property} />;
}
if (resultType === "boolean") {
return <CheckboxField value={value} />;
}
if (resultType === "date") {
return <DateField value={value} property={property} />;
}
const text = typeof value === "string" ? value : value != null ? String(value) : null;
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
@@ -1,28 +0,0 @@
import { useTranslation } from "react-i18next";
import { IconPlus } from "@tabler/icons-react";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanAddCardButtonProps = {
onAddCard: () => void;
};
export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addCard}
role="button"
tabIndex={0}
onClick={onAddCard}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAddCard();
}
}}
>
<IconPlus size={16} />
{t("New row")}
</div>
);
}
@@ -1,251 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import cellClasses from "@/ee/base/styles/cells.module.css";
import propClasses from "@/ee/base/styles/property.module.css";
const DRAG_TYPE = "base-card-property";
type KanbanCardPropertiesProps = {
opened: boolean;
onClose: () => void;
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanCardProperties({
opened,
onClose,
base,
view,
pageId,
children,
}: KanbanCardPropertiesProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
const visibleIds = view.config?.visiblePropertyIds ?? [];
const savedOrder = view.config?.propertyOrder ?? [];
const orderedProperties = [
...savedOrder
.map((id) => nonPrimaryProperties.find((p) => p.id === id))
.filter((p): p is IBaseProperty => p !== undefined),
...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
];
const primaryProperty = base.properties.find((p) => p.isPrimary);
const PrimaryIcon = primaryProperty
? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
: undefined;
const handleToggle = useCallback(
(propertyId: string, checked: boolean) => {
const next = checked
? [...visibleIds, propertyId]
: visibleIds.filter((id) => id !== propertyId);
updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
},
[updateView, view.id, visibleIds, pageId],
);
const handleReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return;
const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
updateView.mutate({
viewId: view.id,
pageId,
config: { propertyOrder: reordered.map((p) => p.id) },
});
},
[orderedProperties, updateView, view.id, pageId],
);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={4}>
<Group justify="space-between" px={4} py={2}>
<Text size="xs" fw={600} c="dimmed">
{t("Card properties")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(60vh, 420px)" scrollbarSize={6} offsetScrollbars>
<Stack gap={0}>
{primaryProperty && (
<div className={cellClasses.menuItem} style={{ paddingLeft: 4, cursor: "default" }}>
<div className={propClasses.dragHandle} style={{ visibility: "hidden" }}>
<IconGripVertical size={14} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{PrimaryIcon && <PrimaryIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{primaryProperty.name}
</Text>
</Group>
<Switch
size="xs"
checked
disabled
onChange={() => {}}
styles={{ track: { cursor: "default" } }}
/>
</div>
)}
{orderedProperties.map((p) => {
const isVisible = visibleIds.includes(p.id);
const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
const TypeIcon = typeConfig?.icon;
return (
<SortablePropertyRow
key={p.id}
property={p}
isVisible={isVisible}
TypeIcon={TypeIcon}
onToggle={handleToggle}
onReorder={handleReorder}
/>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
type SortablePropertyRowProps = {
property: IBaseProperty;
isVisible: boolean;
TypeIcon: typeof IconLetterT | undefined;
onToggle: (propertyId: string, checked: boolean) => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
};
function SortablePropertyRow({
property,
isVisible,
TypeIcon,
onToggle,
onReorder,
}: SortablePropertyRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
const row = rowRef.current;
const handle = handleRef.current;
if (!row || !handle) return;
return combine(
draggable({
element: row,
dragHandle: handle,
getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: row,
canDrop: ({ source }) =>
source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ propertyId: property.id },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(source.data.propertyId as string, property.id, edge);
},
}),
);
}, [property.id]);
return (
<div
ref={rowRef}
style={{ position: "relative", opacity: isDragging ? 0.4 : 1 }}
>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => onToggle(property.id, !isVisible)}
style={{ paddingLeft: 4 }}
>
<div ref={handleRef} className={propClasses.dragHandle} onClick={(e) => e.stopPropagation()}>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -1,85 +0,0 @@
import { forwardRef, useCallback, useRef } from "react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanCardProps = {
base: IBase;
view: IBaseView;
row: IBaseRow;
columnKey: string;
onOpen: (rowId: string) => void;
};
export const KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(
function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
const { t } = useTranslation();
const primary = base.properties.find((p) => p.isPrimary);
const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
const visibleIds = view.config?.visiblePropertyIds ?? [];
const propertyOrder = view.config?.propertyOrder;
const cardProps = base.properties.filter(
(p) => visibleIds.includes(p.id) && !p.isPrimary,
);
if (propertyOrder) {
cardProps.sort(
(a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
);
}
const cardRef = useRef<HTMLDivElement>(null);
const setCardEl = useCallback(
(node: HTMLDivElement | null) => {
cardRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const { closestEdge, isDragging } = useKanbanCardDnd({
cardRef,
rowId: row.id,
columnKey,
pageId: base.id,
});
return (
<div
ref={setCardEl}
className={clsx(classes.card, isDragging && classes.cardDragging)}
role="button"
tabIndex={0}
onClick={() => onOpen(row.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(row.id);
}
}}
>
{closestEdge === "top" && <BaseDropEdgeIndicator edge="top" />}
<div className={clsx(classes.cardTitle, !title && classes.cardUntitled)}>
{title || t("Untitled")}
</div>
{cardProps.map((property) => (
<CardField
key={property.id}
property={property}
value={row.cells[property.id]}
pageId={base.id}
/>
))}
{closestEdge === "bottom" && <BaseDropEdgeIndicator edge="bottom" />}
</div>
);
},
);
@@ -1,76 +0,0 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu, Text } from "@mantine/core";
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
count?: string;
canEdit: boolean;
onHide: () => void;
onAddCard: () => void;
};
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation();
const dotColor = column.color
? choiceColor(column.color).color as string
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
const headerRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const { closestEdge, isDragging } = useKanbanColumnDnd({
headerRef,
handleRef,
columnKey: column.key,
pageId,
});
return (
<div ref={headerRef} className={clsx(classes.columnHeader, isDragging && classes.columnHeaderDragging)}>
{canEdit && (
<div ref={handleRef} className={classes.columnDragHandle} aria-hidden>
<IconGripVertical size={14} />
</div>
)}
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
{count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && (
<>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} />
</ActionIcon>
</>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -1,163 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useBaseRowsQuery } from "@/ee/base/queries/base-row-query";
import { useKanbanCreateCardMutation } from "@/ee/base/queries/base-row-query";
import { KanbanColumnHeader } from "@/ee/base/components/kanban/kanban-column-header";
import { KanbanAddCardButton } from "@/ee/base/components/kanban/kanban-add-card-button";
import { KanbanCard } from "@/ee/base/components/kanban/kanban-card";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnProps = {
base: IBase;
view: IBaseView;
pageId: string;
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
registerCardRef: (rowId: string, columnKey: string, el: HTMLDivElement | null) => void;
registerColumnRows: (columnKey: string, rows: IBaseRow[]) => void;
};
export function KanbanColumn({
base,
view,
pageId,
column,
viewFilter,
groupByPropertyId,
canEdit,
onOpenRow,
onHide,
registerCardRef,
registerColumnRows,
}: KanbanColumnProps) {
const filter = useMemo(
() => buildColumnFilter(viewFilter, groupByPropertyId, column.key),
[viewFilter, groupByPropertyId, column.key],
);
const rowsQuery = useBaseRowsQuery(pageId, filter, undefined);
const createCard = useKanbanCreateCardMutation();
const rows = useMemo(() => {
const pages = rowsQuery.data?.pages ?? [];
const seen = new Set<string>();
const flat: IBaseRow[] = [];
for (const page of pages) {
for (const row of page.items) {
if (!seen.has(row.id)) {
seen.add(row.id);
flat.push(row);
}
}
}
return flat.slice().sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsQuery.data]);
const count = rowsQuery.isSuccess
? formatKanbanCount(rows.length, rowsQuery.hasNextPage ?? false)
: undefined;
useEffect(() => {
registerColumnRows(column.key, rows);
}, [column.key, rows, registerColumnRows]);
const listRef = useRef<HTMLDivElement>(null);
useKanbanColumnAutoScroll(listRef, pageId);
const pendingScrollRef = useRef<"top" | "bottom" | null>(null);
useEffect(() => {
const placement = pendingScrollRef.current;
if (!placement) return;
pendingScrollRef.current = null;
const el = listRef.current;
if (!el) return;
el.scrollTop = placement === "top" ? 0 : el.scrollHeight;
}, [rows]);
useEffect(() => {
const listEl = listRef.current;
if (!listEl) return;
return dropTargetForElements({
element: listEl,
canDrop: ({ source }) =>
source.data.type === KANBAN_CARD_DRAG_TYPE && source.data.pageId === pageId,
getData: () => ({ columnKey: column.key, isColumnBody: true }),
});
}, [column.key, pageId]);
const onScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const { scrollHeight, scrollTop, clientHeight } = el;
if (
scrollHeight - scrollTop - clientHeight < 200 &&
rowsQuery.hasNextPage &&
!rowsQuery.isFetchingNextPage
) {
rowsQuery.fetchNextPage();
}
}, [rowsQuery.hasNextPage, rowsQuery.isFetchingNextPage, rowsQuery.fetchNextPage]);
const addCard = useCallback(
(placement: "top" | "bottom") => {
let position: string | undefined;
try {
position =
placement === "top"
? generateJitteredKeyBetween(null, rows[0]?.position ?? null)
: generateJitteredKeyBetween(rows[rows.length - 1]?.position ?? null, null);
} catch {
position = undefined;
}
createCard.mutate(
{ pageId, destColumnFilter: filter, groupByPropertyId, columnKey: column.key, position },
{
onSuccess: (newRow) => {
pendingScrollRef.current = placement;
onOpenRow(newRow.id);
},
},
);
},
[createCard, pageId, filter, groupByPropertyId, column.key, onOpenRow, rows],
);
return (
<div className={classes.column} data-column-key={column.key}>
<KanbanColumnHeader
column={column}
pageId={pageId}
count={count}
canEdit={canEdit}
onHide={() => onHide(column.key)}
onAddCard={() => addCard("top")}
/>
<div className={classes.cardList} ref={listRef} onScroll={onScroll}>
{rows.map((row) => (
<KanbanCard
key={row.id}
base={base}
view={view}
row={row}
columnKey={column.key}
onOpen={onOpenRow}
ref={(el) => registerCardRef(row.id, column.key, el)}
/>
))}
{canEdit && <KanbanAddCardButton onAddCard={() => addCard("bottom")} />}
</div>
</div>
);
}
@@ -1,99 +0,0 @@
import { useCallback } from "react";
import { Stack, Text, Select, Button } from "@mantine/core";
import { v7 as uuid7 } from "uuid";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
type KanbanEmptyStateProps = {
base: IBase;
view: IBaseView;
pageId: string;
editable: boolean;
};
export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptyStateProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const createProperty = useCreatePropertyMutation();
const groupableProperties = base.properties.filter(
(p) => p.type === "select" || p.type === "status",
);
const selectData = groupableProperties.map((p) => ({
value: p.id,
label: p.name,
}));
const handleSelect = useCallback(
(value: string | null) => {
if (!value) return;
updateView.mutate({ viewId: view.id, pageId, config: { groupByPropertyId: value } });
},
[updateView, view.id, pageId],
);
const handleCreateStatus = useCallback(() => {
const todoId = uuid7();
const inProgressId = uuid7();
const completeId = uuid7();
createProperty.mutate(
{
pageId,
name: t("Status"),
type: "status",
typeOptions: {
choices: [
{ id: todoId, name: t("Not started"), color: "gray", category: "todo" },
{ id: inProgressId, name: t("In progress"), color: "blue", category: "inProgress" },
{ id: completeId, name: t("Done"), color: "green", category: "complete" },
],
choiceOrder: [todoId, inProgressId, completeId],
},
},
{
onSuccess: (newProperty) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: newProperty.id },
});
},
},
);
}, [createProperty, updateView, view.id, pageId, t]);
if (!editable) {
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
</Stack>
);
}
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
{groupableProperties.length > 0 ? (
<Select
placeholder={t("Choose a property")}
data={selectData}
value={view.config?.groupByPropertyId ?? null}
onChange={handleSelect}
w={240}
/>
) : (
<Button
variant="light"
size="sm"
onClick={handleCreateStatus}
loading={createProperty.isPending}
>
{t("Create a status property")}
</Button>
)}
</Stack>
);
}
@@ -1,115 +0,0 @@
import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import cellClasses from "@/ee/base/styles/cells.module.css";
type KanbanGroupByPickerProps = {
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
const data = base.properties
.filter((p) => p.type === "select" || p.type === "status")
.map((p) => ({ value: p.id, label: p.name }));
const handleChange = (value: string | null) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: value ?? null },
});
};
const toggleGroup = (key: string, currentlyHidden: boolean) => {
const current = view.config?.hiddenChoiceIds ?? [];
const next = currentlyHidden
? current.filter((k) => k !== key)
: [...current, key];
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
};
return (
<Popover
position="bottom-end"
shadow="md"
width={300}
withinPortal
trapFocus
closeOnEscape
closeOnClickOutside
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed">
{t("Group by")}
</Text>
<Select
size="xs"
placeholder={t("Select a property")}
data={data}
value={view.config?.groupByPropertyId ?? null}
onChange={handleChange}
clearable
/>
{hasValidGroupBy && allGroups.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t("Groups")}
</Text>
<Stack gap={0}>
{allGroups.map((g) => {
const dotColor = g.color
? (choiceColor(g.color).color as string)
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
return (
<UnstyledButton
key={g.key}
className={cellClasses.menuItem}
onClick={() => toggleGroup(g.key, g.hidden)}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<Text
size="sm"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{g.isNoValue ? t("No value") : g.name}
</Text>
</Group>
<Switch
size="xs"
checked={!g.hidden}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,127 +0,0 @@
import type { BasePropertyType } from "@/ee/base/types/base.types";
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
"createdAt",
"lastEditedAt",
"lastEditedBy",
"formula",
]);
type ConversionInfo = {
// i18n source key (translation files key them by their exact text).
message: string;
// True when cells can be cleared, discarded, truncated, or have their
// structured value flattened, i.e. the change is not safely reversible.
// Drives the destructive (red) "Apply" button in the confirm panel.
lossy: boolean;
};
// Buckets ordered most-specific first; default covers safe reinterpretations.
function describeConversion(
from: BasePropertyType,
to: BasePropertyType,
): ConversionInfo {
if (to === "text" || to === "longText") {
if (from === "longText" && to === "text") {
return {
message:
"Cells longer than the Text limit will be truncated and the extra content permanently lost.",
lossy: true,
};
}
if (from === "select" || from === "status") {
return { message: "Cells will be replaced with the option name.", lossy: true };
}
if (from === "multiSelect") {
return {
message:
"Cells will be replaced with a comma-separated list of option names.",
lossy: true,
};
}
if (from === "person") {
return { message: "Cells will be replaced with the person's name.", lossy: true };
}
if (from === "file") {
return {
message:
"Cells will be replaced with a comma-separated list of file names.",
lossy: true,
};
}
if (from === "page") {
return { message: "Cells will be replaced with the page title.", lossy: true };
}
}
if (to === "select" && from === "multiSelect") {
return {
message:
"Only the first selected item per row will be kept; the rest will be discarded.",
lossy: true,
};
}
if (to === "multiSelect" && from === "select") {
return {
message: "Existing values become single-item lists. No data is lost.",
lossy: false,
};
}
if (to === "page") {
return {
message: "Cells that aren't already a page reference will be cleared.",
lossy: true,
};
}
if (to === "number" && from !== "number") {
return {
message: "Cells that can't be parsed as a number will be cleared.",
lossy: true,
};
}
if (to === "date" && from !== "date") {
return {
message: "Cells that can't be parsed as a date will be cleared.",
lossy: true,
};
}
if (to === "checkbox" && from !== "checkbox") {
return {
message:
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
lossy: true,
};
}
if ((to === "url" || to === "email") && from !== to) {
return {
message:
to === "url"
? "Cells that aren't a valid URL will be cleared."
: "Cells that aren't a valid email address will be cleared.",
lossy: true,
};
}
return { message: "Cells will be reinterpreted under the new type.", lossy: false };
}
export function conversionWarning(
from: BasePropertyType,
to: BasePropertyType,
): string {
return describeConversion(from, to).message;
}
// Whether the type change can lose data, used to make "Apply" destructive.
export function isLossyConversion(
from: BasePropertyType,
to: BasePropertyType,
): boolean {
return describeConversion(from, to).lossy;
}
@@ -1,98 +0,0 @@
import { Group, MultiSelect, Select, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import type { ComboboxItem } from "@mantine/core";
type DefaultValuePickerProps = {
choices: Choice[];
value: string | string[] | null;
multiple?: boolean;
onChange: (value: string | string[] | null) => void;
dropdownPortalTarget?: HTMLElement | null;
};
export function DefaultValuePicker({
choices,
value,
multiple,
onChange,
dropdownPortalTarget,
}: DefaultValuePickerProps) {
const { t } = useTranslation();
const data = choices.map((c) => ({ value: c.id, label: c.name }));
const comboboxProps = {
portalProps: { target: dropdownPortalTarget ?? undefined },
};
const renderOption = ({
option,
checked,
}: {
option: ComboboxItem;
checked?: boolean;
}) => {
const choice = choices.find((c) => c.id === option.value);
const colors = choice ? choiceColor(choice.color) : undefined;
return (
<Group gap={6} wrap="nowrap" justify="space-between" style={{ flex: 1 }}>
<Group gap={6} wrap="nowrap">
{colors && (
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
)}
<Text size="xs">{option.label}</Text>
</Group>
{checked && (
<IconCheck size={14} color="var(--mantine-color-dimmed)" />
)}
</Group>
);
};
if (multiple) {
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
label={t("Default value")}
placeholder={selected.length ? undefined : t("None")}
data={data}
value={selected}
onChange={(vals) => onChange(vals.length ? vals : null)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
const single =
typeof value === "string" && choices.some((c) => c.id === value)
? value
: null;
return (
<Select
size="xs"
label={t("Default value")}
placeholder={t("None")}
data={data}
value={single}
onChange={(val) => onChange(val)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
@@ -1,622 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Stack,
NumberInput,
Select,
Switch,
Text,
Button,
Group,
Divider,
TextInput,
Textarea,
} from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
PersonTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
import {
CURRENCIES,
DEFAULT_CURRENCY_CODE,
} from "@/ee/base/constants/currencies";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
// Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
dropdownPortalTarget?: HTMLElement | null;
};
export function PropertyOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "person":
return (
<PersonOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "text":
case "longText":
case "url":
case "email":
return (
<TextDefaultOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "checkbox":
return (
<CheckboxOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
type OptionEditorProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
dropdownPortalTarget?: HTMLElement | null;
};
const EMPTY_OPTIONS: Record<string, unknown> = {};
function optionsEqual(
a: Record<string, unknown>,
b: Record<string, unknown>,
): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
const av = a[key];
const bv = b[key];
if (Array.isArray(av) && Array.isArray(bv)) {
if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
return false;
}
} else if (av !== bv) {
return false;
}
}
return true;
}
// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
function useEditableTypeOptions(
initialRaw: Record<string, unknown> | undefined,
{
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
onUpdate: (opts: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
},
) {
const initial = initialRaw ?? EMPTY_OPTIONS;
const [draft, setDraft] = useState<Record<string, unknown>>(initial);
useEffect(() => {
if (!hideButtons) setDraft(initial);
}, [initial, hideButtons]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
useEffect(() => {
if (hideButtons) onUpdateRef.current(draft);
}, [hideButtons, draft]);
const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const update = useCallback(
(patch: Record<string, unknown>) =>
setDraft((prev) => ({ ...prev, ...patch })),
[],
);
const save = useCallback(() => {
onUpdate(draft);
onClose();
}, [draft, onUpdate, onClose]);
const cancel = useCallback(() => {
setDraft(initial);
onDirtyChange?.(false);
onClose();
}, [initial, onClose, onDirtyChange]);
return { draft, update, isDirty, save, cancel };
}
function OptionsFooter({
isDirty,
onCancel,
onSave,
}: {
isDirty: boolean;
onCancel: () => void;
onSave: () => void;
}) {
const { t } = useTranslation();
return (
<>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={onSave} disabled={!isDirty}>
{t("Save")}
</Button>
</Group>
</>
);
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
multiDefault={property.type === "multiSelect"}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function NumberOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as NumberTypeOptions;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
]}
value={options.format ?? "plain"}
onChange={(val) => update({ format: val ?? "plain" })}
/>
{options.format === "currency" && (
<Select
size="xs"
label={t("Currency")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={CURRENCIES.map((c) => ({
value: c.code,
label: `${c.name} (${c.code})`,
}))}
value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
onChange={(val) =>
update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
}
/>
)}
<Select
size="xs"
label={t("Thousands and decimal separators")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "none", label: t("None") },
{ value: "local", label: t("Local") },
{ value: "comma_period", label: t("Comma, period") },
{ value: "period_comma", label: t("Period, comma") },
{ value: "space_comma", label: t("Space, comma") },
{ value: "space_period", label: t("Space, period") },
]}
value={options.separators ?? "none"}
onChange={(val) => update({ separators: val ?? "none" })}
/>
<Select
size="xs"
label={t("Decimal places")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "default", label: t("Default") },
...Array.from({ length: 9 }, (_, i) => ({
value: String(i),
label: String(i),
})),
]}
value={options.precision == null ? "default" : String(options.precision)}
onChange={(val) =>
update({ precision: val == null || val === "default" ? undefined : Number(val) })
}
/>
<NumberInput
size="xs"
label={t("Default value")}
placeholder={t("None")}
value={typeof options.defaultValue === "number" ? options.defaultValue : ""}
onChange={(val) =>
update({ defaultValue: typeof val === "number" ? val : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function DateOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as DateTypeOptions;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options.includeTime ?? false}
onChange={(e) => update({ includeTime: e.currentTarget.checked })}
/>
{options.includeTime && (
<Select
size="xs"
label={t("Time format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options.timeFormat ?? "12h"}
onChange={(val) => update({ timeFormat: val ?? "12h" })}
/>
)}
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function PersonOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as PersonTypeOptions;
const allowMultiple = options.allowMultiple === true;
const handleAllowMultipleChange = (toMulti: boolean) => {
const dv = options.defaultValue;
const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
update({
allowMultiple: toMulti,
defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
});
};
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Allow multiple people")}
checked={allowMultiple}
onChange={(e) => handleAllowMultipleChange(e.currentTarget.checked)}
/>
<FilterPersonInput
pageId={property.pageId}
multiple={allowMultiple}
value={options.defaultValue ?? null}
onChange={(value) =>
update({ defaultValue: value as string | string[] | undefined })
}
placeholder={t("None")}
label={t("Default value")}
w="100%"
portalTarget={dropdownPortalTarget}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function TextDefaultOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const defaultValue =
typeof draft.defaultValue === "string" ? draft.defaultValue : "";
const defaultValueError =
defaultValue && property.type === "url" && !URL.canParse(defaultValue)
? t("Please enter a valid url")
: defaultValue &&
property.type === "email" &&
!EMAIL_FORMAT.test(defaultValue)
? t("Please enter a valid email")
: null;
return (
<Stack gap="xs">
{property.type === "longText" ? (
<Textarea
size="xs"
label={t("Default value")}
placeholder={t("None")}
autosize
minRows={2}
maxRows={6}
value={defaultValue}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
) : (
<TextInput
size="xs"
label={t("Default value")}
placeholder={
property.type === "url"
? "https://example.com"
: property.type === "email"
? "name@example.com"
: t("None")
}
value={defaultValue}
error={defaultValueError}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
)}
{!hideButtons && (
<OptionsFooter
isDirty={isDirty && !defaultValueError}
onCancel={cancel}
onSave={save}
/>
)}
</Stack>
);
}
function CheckboxOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Checked by default")}
checked={draft.defaultValue === true}
onChange={(e) =>
update({ defaultValue: e.currentTarget.checked ? true : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
@@ -1,142 +0,0 @@
import { forwardRef } from "react";
import { Checkbox } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import clsx from "clsx";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldText } from "./field-text";
import { FieldLongText } from "./field-long-text";
import { FieldNumber } from "./field-number";
import { FieldDate } from "./field-date";
import { FieldChoice } from "./field-choice";
import { FieldCellAdapter } from "./field-cell-adapter";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
export type FieldProps = {
property: IBaseProperty;
value: unknown;
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
};
type FieldShellProps = {
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
cursor?: "text" | "pointer" | "default";
/** Popover open — keeps the focus ring while focus is in the portal. */
active?: boolean;
locked?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
// ref injected into this element; without it the picker renders at (0,0).
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
function FieldShell(
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
ref,
) {
return (
<div
ref={ref}
className={clsx(
classes.fieldShell,
cursor === "text" && classes.fieldShellText,
cursor === "pointer" && classes.fieldShellPointer,
active && classes.fieldShellActive,
locked && classes.fieldShellLocked,
alignTop && classes.fieldShellTop,
className,
)}
{...rest}
>
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
{children}
</div>
);
},
);
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
const checked = value === true;
return (
<FieldShell>
<Checkbox
size="sm"
checked={checked}
disabled={readOnly}
onChange={() => onChange(!checked)}
/>
</FieldShell>
);
}
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
const CellComponent = getDescriptor(property.type)?.cellComponent;
return (
<FieldShell locked>
<div className={classes.fieldCellDisplay}>
{CellComponent && (
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={false}
readOnly
onCommit={() => {}}
onValueChange={() => {}}
onCancel={() => {}}
/>
)}
</div>
</FieldShell>
);
}
type DetailFieldProps = {
property: IBaseProperty;
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
: (row.cells ?? {})[property.id];
const fieldProps: FieldProps = {
property,
value,
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
};
switch (property.type) {
case "text":
case "url":
case "email":
return <FieldText {...fieldProps} />;
case "longText":
return <FieldLongText {...fieldProps} />;
case "number":
return <FieldNumber {...fieldProps} />;
case "checkbox":
return <FieldCheckbox {...fieldProps} />;
case "date":
return <FieldDate {...fieldProps} />;
case "select":
case "status":
case "multiSelect":
return <FieldChoice {...fieldProps} />;
case "person":
case "file":
case "page":
return <FieldCellAdapter {...fieldProps} />;
default:
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
return <FieldReadonlyCell {...fieldProps} />;
}
}
@@ -1,80 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
/** Person, file and page editors are popover pickers owned by their cell
* components; the shell supplies modal styling and click-anywhere
* activation while the cell keeps its picker behavior. */
export function FieldCellAdapter({
property,
value,
rowId,
readOnly,
onChange,
}: FieldProps) {
const [editing, setEditing] = useState(false);
// Whether the picker was open when the current gesture's mousedown fired.
const editingAtMouseDownRef = useRef(false);
const CellComponent = getDescriptor(property.type)?.cellComponent;
// Files stay openable read-only (download-only popover), matching the grid.
const canActivate = !readOnly || property.type === "file";
// Activate on click, not mousedown: opening on mousedown mounts the cell's
// picker mid-dispatch, and its document-level outside-mousedown listener
// then catches the same still-bubbling event and instantly closes it. By
// click time the mousedown has fully finished. The ref keeps toggle-close
// working: when the gesture started with the picker open, the picker's own
// outside-close already handled it and we must not reopen.
const handleMouseDown = useCallback(() => {
editingAtMouseDownRef.current = editing;
}, [editing]);
const handleClick = useCallback(() => {
if (!canActivate || editingAtMouseDownRef.current || editing) return;
setEditing(true);
}, [canActivate, editing]);
const handleCommit = useCallback(
(next: unknown) => {
setEditing(false);
onChange(next);
},
[onChange],
);
const handleCancel = useCallback(() => setEditing(false), []);
if (!CellComponent) return <FieldShell />;
return (
<FieldShell
cursor={canActivate ? "pointer" : "default"}
active={editing}
onMouseDown={handleMouseDown}
onClick={handleClick}
role={canActivate ? "button" : undefined}
tabIndex={canActivate ? 0 : undefined}
aria-label={property.name}
onKeyDown={(e) => {
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setEditing(true);
}
}}
>
<div className={classes.fieldCellDisplay}>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={editing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={onChange}
onCancel={handleCancel}
/>
</div>
</FieldShell>
);
}
@@ -1,103 +0,0 @@
import { useCallback, useState } from "react";
import { Popover } from "@mantine/core";
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const multiple = property.type === "multiSelect";
const choices =
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = multiple
? Array.isArray(value)
? (value as string[])
: []
: typeof value === "string"
? [value]
: [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
if (multiple) {
const next = selectedIds.includes(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onChange(next.length > 0 ? next : null);
} else {
onChange(choice.id === selectedIds[0] ? null : choice.id);
setOpened(false);
}
},
[multiple, selectedIds, onChange],
);
const chips = selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
if (readOnly) {
return (
<FieldShell>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="target"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p={4}>
{opened && (
<ChoicePicker
property={property}
selectedIds={selectedIds}
multiple={multiple}
grouped={property.type === "status"}
allowCreate={property.type !== "status"}
onToggle={handleToggle}
onEscape={() => setOpened(false)}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,75 +0,0 @@
import { useState } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { DateTypeOptions } from "@/ee/base/types/base.types";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
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}`;
}
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const display = formatDateDisplay(dateStr, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="auto"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p="xs">
<DatePicker
value={toISODateString(dateStr)}
onChange={(selected) => {
onChange(selected ? new Date(selected).toISOString() : null);
setOpened(false);
}}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,69 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Textarea } from "@mantine/core";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
};
if (readOnly) {
return (
<FieldShell alignTop>
<span className={classes.fieldValueTextMultiline}>{text}</span>
</FieldShell>
);
}
return (
<FieldShell cursor="text" alignTop>
<Textarea
autosize
minRows={3}
maxRows={16}
maxLength={25000}
variant="unstyled"
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -1,89 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import {
formatNumber,
parseNumberDraft,
sanitizeNumberInput,
} from "@/ee/base/components/cells/cell-number";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(toDraft(value));
}, [value, focused]);
const formatted = formatNumber(numValue, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{formatted}</span>
</FieldShell>
);
}
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
return;
}
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
return (
<FieldShell cursor="text">
<input
type="text"
inputMode="decimal"
className={classes.fieldInput}
value={focused ? draft : formatted}
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
}}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -1,89 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { IconExternalLink, IconMail } from "@tabler/icons-react";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
// Track remote/navigation updates while not typing.
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (draft !== text) onChange(draft);
};
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{text}</span>
</FieldShell>
);
}
const linkHref =
!focused && text
? property.type === "email"
? text.includes("@")
? `mailto:${text}`
: null
: property.type === "url" && /^https?:\/\//i.test(text)
? text
: null
: null;
return (
<FieldShell cursor="text">
<input
type="text"
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
{linkHref && (
<a
href={linkHref}
target={property.type === "url" ? "_blank" : undefined}
rel="noopener noreferrer"
className={classes.fieldTrailing}
onMouseDown={(e) => e.stopPropagation()}
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
>
{property.type === "email" ? (
<IconMail size={14} />
) : (
<IconExternalLink size={14} />
)}
</a>
)}
</FieldShell>
);
}
@@ -1,117 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import clsx from "clsx";
import { Popover } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { DetailField } from "./fields/detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type PropertyRowProps = {
property: IBaseProperty;
row: IBaseRow;
pageId: string;
menuOpened: boolean;
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
export function PropertyRow({
property,
row,
pageId,
menuOpened,
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
const canEdit = useBaseEditable();
const rowRef = useRef<HTMLDivElement>(null);
const focusedRef = useRef(false);
useEffect(() => {
if (!autoFocusValue || focusedRef.current) return;
focusedRef.current = true;
const el = rowRef.current;
if (el) {
el.scrollIntoView({ block: "nearest" });
el.querySelector<HTMLElement>("input, textarea")?.focus();
}
onAutoFocused?.();
}, [autoFocusValue, onAutoFocused]);
const handleLabelClick = useCallback(() => {
onMenuOpenChange(!menuOpened);
}, [menuOpened, onMenuOpenChange]);
const handleMenuClose = useCallback(() => {
onMenuOpenChange(false);
}, [onMenuOpenChange]);
const Icon = getDescriptor(property.type)?.icon;
const label = (
<>
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
<span className={classes.propertyLabelText}>{property.name}</span>
</>
);
return (
<div className={classes.propertyRow} ref={rowRef}>
{canEdit ? (
<Popover
opened={menuOpened}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
closeOnEscape={false}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.propertyLabel, classes.propertyLabelButton, {
[classes.propertyLabelActive]: menuOpened,
})}
onClick={handleLabelClick}
data-property-menu-target
>
{label}
<IconChevronDown size={13} className={classes.propertyLabelChevron} />
</button>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={onMenuDirtyChange}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
) : (
<div className={classes.propertyLabel}>{label}</div>
)}
<DetailField
property={property}
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
/>
</div>
);
}
@@ -1,439 +0,0 @@
import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import {
IconChevronDown,
IconChevronUp,
IconDotsVertical,
IconLink,
IconLock,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IBase, IBaseRow } from "@/ee/base/types/base.types";
import {
useBaseRowQuery,
useDeleteRowMutation,
useUpdateRowMutation,
} from "@/ee/base/queries/base-row-query";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useClipboard } from "@/hooks/use-clipboard";
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
import { RowDetailTitle } from "./row-detail-title";
import { PropertyRow } from "./property-row";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailModalProps = {
base: IBase;
rows: IBaseRow[];
openRowId: string | null;
onClose: () => void;
onNavigate: (rowId: string) => void;
};
export function RowDetailModal({
base,
rows,
openRowId,
onClose,
onNavigate,
}: RowDetailModalProps) {
const { t } = useTranslation();
const canEdit = useBaseEditable();
const updateRowMutation = useUpdateRowMutation();
const deleteRowMutation = useDeleteRowMutation();
const clipboard = useClipboard({ timeout: 500 });
const rowIndex = useMemo(
() => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
[openRowId, rows],
);
const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
// Deep links (?row=) can target rows outside the loaded pages or filtered
// out of the active view — fetch by id instead of closing. Close only
// when the server confirms the row is gone.
const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
enabled: !!openRowId && !rowFromList,
});
const row = rowFromList ?? rowQuery.data;
const primaryProperty = useMemo(
() => base.properties.find((p) => p.isPrimary),
[base.properties],
);
const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
useEffect(() => {
if (rowMissing) onClose();
}, [rowMissing, onClose]);
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
// run its discard-confirm flow instead of being torn down mid-edit.
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
const menuDirtyRef = useRef(false);
const [closeRequest, setCloseRequest] = useAtom(
propertyMenuCloseRequestAtomFamily(base.id),
) as unknown as [number, (val: number) => void];
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
menuDirtyRef.current = dirty;
}, []);
const requestMenuClose = useCallback(() => {
if (menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
} else {
setOpenMenuId(null);
}
}, [closeRequest, setCloseRequest]);
const handleMenuOpenChange = useCallback(
(propertyId: string, nextOpened: boolean) => {
if (!nextOpened) {
setOpenMenuId(null);
menuDirtyRef.current = false;
return;
}
if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
return;
}
setOpenMenuId(propertyId);
},
[openMenuId, closeRequest, setCloseRequest],
);
useEffect(() => {
if (!openMenuId) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-position]")) return;
if (target.closest("[data-property-menu-target]")) return;
requestMenuClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenuId, requestMenuClose]);
const hasPrev = rowIndex > 0;
const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
const navigate = useCallback(
(delta: number) => {
if (rowIndex === -1) return;
const next = rows[rowIndex + delta];
if (next) onNavigate(next.id);
},
[rows, rowIndex, onNavigate],
);
const handleCopyLink = useCallback(() => {
clipboard.copy(window.location.href);
notifications.show({ message: t("Link copied") });
}, [clipboard, t]);
const handleDeleteRecord = useCallback(() => {
if (!row) return;
const rowId = row.id;
modals.openConfirmModal({
title: t("Delete record?"),
centered: true,
children: <Text size="sm">{t("This action cannot be undone.")}</Text>,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteRowMutation.mutate({ rowId, pageId: base.id });
onClose();
},
});
}, [row, base.id, deleteRowMutation, onClose, t]);
// Mantine's closeOnEscape runs a capture-phase window listener that fires
// before inner popovers and inputs see the key, so we manage Esc ourselves
// and yield to: nested dialogs (delete confirm), open popovers
// ([data-position]) and editable elements. Arrows step records under the
// same yield rules. Mantine puts role="dialog" and our content class on
// the same element, which distinguishes this modal from nested ones.
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isEscape = event.key === "Escape";
const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
const target = event.target as HTMLElement | null;
if (target) {
const dialog = target.closest('[role="dialog"]');
if (dialog && !dialog.classList.contains(classes.modalContent)) {
return;
}
if (
target.closest("[data-position]") ||
target.matches("input, textarea, select, [contenteditable='true']")
) {
return;
}
}
if (isEscape) {
if (openMenuId) {
requestMenuClose();
return;
}
onClose();
return;
}
if (openMenuId) return;
event.preventDefault();
navigate(event.key === "ArrowUp" ? -1 : 1);
},
[opened, openMenuId, requestMenuClose, onClose, navigate],
);
useWindowEvent("keydown", handleKeyDown, { capture: true });
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
withCloseButton={false}
closeOnEscape={false}
closeOnClickOutside={!openMenuId}
padding={0}
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
>
{row ? (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Tooltip label={t("Previous record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(-1)}
disabled={!hasPrev}
aria-label={t("Previous record")}
>
<IconChevronUp size={16} />
</button>
</Tooltip>
<Tooltip label={t("Next record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(1)}
disabled={!hasNext}
aria-label={t("Next record")}
>
<IconChevronDown size={16} />
</button>
</Tooltip>
</div>
<div className={classes.topBarGroup}>
<Menu position="bottom-end" shadow="md" withinPortal>
<Menu.Target>
<button
type="button"
className={classes.iconButton}
aria-label={t("Record actions")}
>
<IconDotsVertical size={16} />
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={14} />}
onClick={handleCopyLink}
>
{t("Copy link")}
</Menu.Item>
{canEdit && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={handleDeleteRecord}
>
{t("Delete record")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<button
type="button"
className={classes.iconButton}
onClick={onClose}
aria-label={t("Close")}
>
<IconX size={16} />
</button>
</div>
</div>
<RowDetailTitle
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [primaryProperty.id]: value },
});
}}
/>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => (
<PropertyRow
key={property.id}
property={property}
row={row}
pageId={base.id}
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
onMenuDirtyChange={handleMenuDirtyChange}
onUpdate={(propertyId, value) => {
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [propertyId]: value },
});
}}
/>
))}
</div>
{canEdit && (
<CreatePropertyPopover
pageId={base.id}
properties={base.properties}
onPropertyCreated={(p) => setNewPropertyId(p.id)}
renderTarget={(open) => (
<button
type="button"
className={classes.addPropertyRow}
onClick={open}
>
<span className={classes.addPropertyLabel}>
<IconPlus size={15} />
{t("Add property")}
</span>
</button>
)}
/>
)}
</div>
<footer className={classes.footer}>
<div className={classes.footerStatus}>
{!canEdit ? (
<span className={classes.lockedHint}>
<IconLock size={12} />
{t("Read-only")}
</span>
) : isSaving ? (
<>
<span className={classes.savingDot} />
<span>{t("Saving…")}</span>
</>
) : null}
</div>
<div className={classes.kbdHint}>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
) : (
<RowDetailSkeleton base={base} />
)}
</Modal>
);
}
/** Hydration state for deep-linked rows: the schema is already loaded, so
* render the real labels and shimmer only the unknown values. Matching the
* final layout avoids a size jump when the row arrives. */
function RowDetailSkeleton({ base }: { base: IBase }) {
return (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
</div>
<header className={classes.header}>
<Skeleton height={30} width="45%" radius={8} />
<div className={classes.metaRow}>
<Skeleton height={12} width={150} radius={4} />
</div>
</header>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => {
const Icon = getDescriptor(property.type)?.icon;
return (
<div key={property.id} className={classes.propertyRow}>
<div className={classes.propertyLabel}>
{Icon && (
<Icon size={15} className={classes.propertyLabelIcon} />
)}
<span className={classes.propertyLabelText}>
{property.name}
</span>
</div>
<Skeleton
height={property.type === "longText" ? 82 : 34}
radius={7}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</div>
</>
);
}
@@ -1,201 +0,0 @@
import { useState } from "react";
import {
Popover,
InputBase,
Input,
SegmentedControl,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import type {
DateFilterValue,
FilterOperator,
} from "@/ee/base/types/base.types";
import {
DATE_ANCHOR_PRESETS,
DATE_RANGE_PRESETS,
ANCHOR_VALUES,
RANGE_VALUES,
} from "./relative-date-presets";
import cellClasses from "@/ee/base/styles/cells.module.css";
type FilterDateInputProps = {
op: FilterOperator;
value: unknown;
onChange: (value: unknown) => void;
};
type Mode = "exact" | "relative";
const ANCHOR_LABEL: Record<string, string> = Object.fromEntries(
DATE_ANCHOR_PRESETS.map((p) => [p.value, p.labelKey]),
);
const RANGE_LABEL: Record<string, string> = Object.fromEntries(
DATE_RANGE_PRESETS.map((p) => [p.value, p.labelKey]),
);
function asDateValue(value: unknown): DateFilterValue | null {
if (!value || typeof value !== "object") return null;
return value as DateFilterValue;
}
function toISODate(d: string | null): string | null {
if (!d) return null;
// Already a date-only ISO string (Mantine v8 emits these) — pass through to
// avoid a UTC-parse + local-getter round-trip that shifts the day west of UTC.
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
const date = new Date(d);
if (isNaN(date.getTime())) return null;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function FilterDateInput({ op, value, onChange }: FilterDateInputProps) {
const { t } = useTranslation();
const current = asDateValue(value);
const [opened, setOpened] = useState(false);
const [localMode, setLocalMode] = useState<Mode>("exact");
const exactDate = current?.mode === "exact" ? toISODate(current.date) : null;
const anchor =
current?.mode === "relative" && ANCHOR_VALUES.has(current.preset)
? current.preset
: null;
const range =
current?.mode === "range" && RANGE_VALUES.has(current.preset)
? current.preset
: null;
const valueMode: Mode | null =
current?.mode === "relative"
? "relative"
: current?.mode === "exact"
? "exact"
: null;
const mode: Mode = valueMode ?? localMode;
let triggerLabel: string | null = null;
if (op === "isWithin") triggerLabel = range ? t(RANGE_LABEL[range]) : null;
else if (exactDate) triggerLabel = exactDate;
else if (anchor) triggerLabel = t(ANCHOR_LABEL[anchor]);
// Consume Escape locally so the outer filter popover (bubble handler) keeps
// the panel open and only this picker closes.
const handleEscape = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOpened(false);
}
};
const presetRow = (
selected: boolean,
label: string,
onClick: () => void,
key: string,
) => (
<div
key={key}
className={clsx(
cellClasses.selectOption,
selected && cellClasses.selectOptionActive,
)}
onClick={onClick}
>
<span className={cellClasses.personOptionName}>{label}</span>
</div>
);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width={op === "isWithin" ? 200 : "auto"}
withinPortal={false}
closeOnEscape={false}
closeOnClickOutside
>
<Popover.Target>
<InputBase
component="button"
type="button"
size="xs"
pointer
w={170}
rightSection={<IconChevronDown size={14} />}
rightSectionPointerEvents="none"
onClick={() => setOpened((o) => !o)}
onKeyDown={handleEscape}
>
{triggerLabel ?? <Input.Placeholder>{t("Select")}</Input.Placeholder>}
</InputBase>
</Popover.Target>
<Popover.Dropdown p={op === "isWithin" ? 0 : "xs"} onKeyDown={handleEscape}>
{op === "isWithin" ? (
<div className={cellClasses.selectDropdown}>
{DATE_RANGE_PRESETS.map((p) =>
presetRow(
range === p.value,
t(p.labelKey),
() => {
onChange({ mode: "range", preset: p.value });
setOpened(false);
},
p.value,
),
)}
</div>
) : (
<>
<SegmentedControl
fullWidth
size="xs"
mb="xs"
value={mode}
onChange={(m) => {
setLocalMode(m as Mode);
onChange(undefined);
}}
data={[
{ value: "exact", label: t("Date") },
{ value: "relative", label: t("Relative") },
]}
/>
{mode === "exact" ? (
<DatePicker
value={exactDate}
onChange={(d) => {
const iso = toISODate(d);
onChange(iso ? { mode: "exact", date: iso } : undefined);
setOpened(false);
}}
size="sm"
/>
) : (
<div className={cellClasses.selectDropdown}>
{DATE_ANCHOR_PRESETS.map((p) =>
presetRow(
anchor === p.value,
t(p.labelKey),
() => {
onChange({ mode: "relative", preset: p.value });
setOpened(false);
},
p.value,
),
)}
</div>
)}
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,246 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover, InputBase, Input } from "@mantine/core";
import { IconX, IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import {
usePersonSearch,
type PersonSuggestion,
} from "@/ee/base/hooks/use-person-search";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type FilterPersonInputProps = {
pageId: string;
multiple: boolean;
value: unknown;
onChange: (value: unknown) => void;
placeholder: string;
label?: string;
w?: number | string;
portalTarget?: HTMLElement | null;
};
function toIds(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => !!v);
if (typeof value === "string" && value) return [value];
return [];
}
export function FilterPersonInput({
pageId,
multiple,
value,
onChange,
placeholder,
label,
w,
portalTarget,
}: FilterPersonInputProps) {
const ids = toIds(value);
const selectedSet = new Set(ids);
const [opened, setOpened] = useState(false);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const store = useReferenceStore(pageId);
const hydrateUsers = useHydrateUsers(pageId);
const suggestions = usePersonSearch(search, opened);
useEffect(() => {
if (opened) requestAnimationFrame(() => searchRef.current?.focus());
else setSearch("");
}, [opened]);
const filtered: PersonSuggestion[] = multiple
? suggestions.filter((s) => !selectedSet.has(s.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filtered.length, [search, opened]);
const emit = useCallback(
(nextIds: string[]) => {
if (multiple) onChange(nextIds.length > 0 ? nextIds : undefined);
else onChange(nextIds[0] ?? undefined);
},
[multiple, onChange],
);
const handleSelect = useCallback(
(id: string) => {
const picked = suggestions.find((s) => s.id === id);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (multiple) {
emit(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
} else {
emit([id]);
setOpened(false);
}
setSearch("");
},
[suggestions, hydrateUsers, multiple, ids, emit],
);
const handleRemove = useCallback(
(id: string) => emit(ids.filter((x) => x !== id)),
[emit, ids],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOpened(false);
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= filtered.length) return;
e.preventDefault();
handleSelect(filtered[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && ids.length > 0) {
e.preventDefault();
handleRemove(ids[ids.length - 1]);
}
},
[handleNavKey, activeIndex, filtered, handleSelect, search, ids, handleRemove],
);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width={260}
withinPortal={!!portalTarget}
portalProps={{ target: portalTarget ?? undefined }}
closeOnEscape={false}
closeOnClickOutside
>
<Popover.Target>
<InputBase
component="button"
type="button"
size="xs"
pointer
multiline
w={w ?? 170}
label={label}
rightSection={<IconChevronDown size={14} />}
rightSectionPointerEvents="none"
onClick={() => setOpened((o) => !o)}
>
{ids.length === 0 ? (
<Input.Placeholder>{placeholder}</Input.Placeholder>
) : (
<span className={cellClasses.filterTriggerChips}>
{ids.map((id) => {
const user = store.users[id];
const name = user?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.filterTriggerChip}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={16}
radius="xl"
/>
<span className={cellClasses.filterTriggerChipName}>
{name}
</span>
</span>
);
})}
</span>
)}
</InputBase>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{multiple &&
ids.map((id) => {
const user = store.users[id];
const name = user?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder="Find a user..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{filtered.map((member, idx) => (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
selectedSet.has(member.id) && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className={cellClasses.personDropdownHint}>No users found</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,35 +0,0 @@
import type {
DateFilterAnchor,
DateFilterRange,
} from "@/ee/base/types/base.types";
export const DATE_ANCHOR_PRESETS: { value: DateFilterAnchor; labelKey: string }[] =
[
{ value: "today", labelKey: "Today" },
{ value: "tomorrow", labelKey: "Tomorrow" },
{ value: "yesterday", labelKey: "Yesterday" },
{ value: "oneWeekAgo", labelKey: "One week ago" },
{ value: "oneWeekFromNow", labelKey: "One week from now" },
{ value: "oneMonthAgo", labelKey: "One month ago" },
{ value: "oneMonthFromNow", labelKey: "One month from now" },
];
export const DATE_RANGE_PRESETS: { value: DateFilterRange; labelKey: string }[] =
[
{ value: "pastWeek", labelKey: "Past week" },
{ value: "pastMonth", labelKey: "Past month" },
{ value: "pastYear", labelKey: "Past year" },
{ value: "thisWeek", labelKey: "This week" },
{ value: "thisMonth", labelKey: "This month" },
{ value: "thisYear", labelKey: "This year" },
{ value: "nextWeek", labelKey: "Next week" },
{ value: "nextMonth", labelKey: "Next month" },
{ value: "nextYear", labelKey: "Next year" },
];
export const ANCHOR_VALUES = new Set<string>(
DATE_ANCHOR_PRESETS.map((p) => p.value),
);
export const RANGE_VALUES = new Set<string>(
DATE_RANGE_PRESETS.map((p) => p.value),
);
@@ -1,140 +0,0 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAtom } from "jotai";
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase } from "@/ee/base/types/base.types";
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
type Panel = "types" | "groupBy";
type ViewCreateMenuProps = {
base: IBase;
pageId: string;
};
export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("types");
const dropdownRef = useRef<HTMLDivElement>(null);
const createViewMutation = useCreateViewMutation();
const [, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const groupable = base.properties.filter(
(p) => p.type === "select" || p.type === "status",
);
const close = useCallback(() => {
setOpened(false);
setPanel("types");
}, []);
const submitView = useCallback(
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
createViewMutation.mutate(
{ pageId, ...input },
{ onSuccess: (created) => setActiveViewId(created.id) },
);
close();
},
[pageId, createViewMutation, setActiveViewId, close],
);
const handleCreateTable = useCallback(() => {
submitView({ name: t("Table"), type: "table" });
}, [submitView, t]);
const handleBoardClick = useCallback(() => {
if (groupable.length <= 1) {
const config =
groupable.length === 1
? { groupByPropertyId: groupable[0].id }
: undefined;
submitView({ name: t("Kanban"), type: "kanban", config });
} else {
setPanel("groupBy");
}
}, [groupable, submitView, t]);
const handleGroupByPick = useCallback(
(propertyId: string) => {
submitView({
name: t("Kanban"),
type: "kanban",
config: { groupByPropertyId: propertyId },
});
},
[submitView, t],
);
useEffect(() => {
const raf = requestAnimationFrame(() => {
dropdownRef.current
?.querySelector<HTMLElement>("[data-menu-item]:not([data-disabled])")
?.focus();
});
return () => cancelAnimationFrame(raf);
}, [panel]);
return (
<Menu
opened={opened}
onChange={(o) => {
setOpened(o);
if (!o) setPanel("types");
}}
position="bottom-start"
shadow="md"
width={200}
withinPortal
closeOnItemClick={false}
>
<Menu.Target>
<Tooltip label={t("Add view")}>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add view")}>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown ref={dropdownRef}>
{panel === "types" && (
<>
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
{t("Table")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
{t("Kanban")}
</Menu.Item>
</>
)}
{panel === "groupBy" && (
<>
<Menu.Item leftSection={<IconArrowLeft size={14} />} onClick={() => setPanel("types")}>
{t("Group by")}
</Menu.Item>
<Menu.Divider />
{groupable.map((p) => {
const Icon = getDescriptor(p.type)?.icon;
return (
<Menu.Item
key={p.id}
leftSection={Icon ? <Icon size={14} /> : undefined}
onClick={() => handleGroupByPick(p.id)}
>
{p.name}
</Menu.Item>
);
})}
</>
)}
</Menu.Dropdown>
</Menu>
);
}
@@ -1,418 +0,0 @@
import {
useState,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import {
Group,
UnstyledButton,
Text,
TextInput,
Popover,
Stack,
Divider,
} from "@mantine/core";
import {
IconPencil,
IconTrash,
IconTable,
IconLink,
IconLayoutKanban,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
import {
useUpdateViewMutation,
useDeleteViewMutation,
} from "@/ee/base/queries/base-view-query";
import { useTranslation } from "react-i18next";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
const VIEW_DRAG_TYPE = "base-view";
type ViewTabsProps = {
views: IBaseView[];
activeViewId: string | undefined;
pageId: string;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
base?: IBase;
canAddView?: boolean;
/** Standalone base-page link for a view, used by "Copy link to view". */
getViewShareUrl?: (viewId: string) => string | null;
};
export function ViewTabs({
views,
activeViewId,
pageId,
onViewChange,
onAddView,
base,
canAddView,
getViewShareUrl,
}: ViewTabsProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [editingViewId, setEditingViewId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const updateViewMutation = useUpdateViewMutation();
const deleteViewMutation = useDeleteViewMutation();
const orderedViews = useMemo(
() =>
[...views].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[views],
);
const handleReorder = useCallback(
(sourceId: string, targetId: string, edge: Edge) => {
if (sourceId === targetId) return;
const remaining = orderedViews.filter((v) => v.id !== sourceId);
const targetIndex = remaining.findIndex((v) => v.id === targetId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (edge === "left") {
lowerPos =
targetIndex > 0 ? remaining[targetIndex - 1]?.position : null;
upperPos = remaining[targetIndex]?.position ?? null;
} else {
lowerPos = remaining[targetIndex]?.position ?? null;
upperPos =
targetIndex < remaining.length - 1
? remaining[targetIndex + 1]?.position
: null;
}
try {
const position =
lowerPos && upperPos && lowerPos === upperPos
? generateJitteredKeyBetween(lowerPos, null)
: generateJitteredKeyBetween(lowerPos, upperPos);
updateViewMutation.mutate({ viewId: sourceId, pageId, position });
} catch {
// Position computation failed; skip the reorder.
}
},
[orderedViews, pageId, updateViewMutation],
);
const handleRenameStart = useCallback(
(view: IBaseView) => {
setEditingViewId(view.id);
setEditingName(view.name);
},
[],
);
const handleRenameCommit = useCallback(() => {
if (!editingViewId) return;
const trimmed = editingName.trim();
const view = views.find((v) => v.id === editingViewId);
if (trimmed && view && trimmed !== view.name) {
updateViewMutation.mutate({
viewId: editingViewId,
pageId,
name: trimmed,
});
}
setEditingViewId(null);
}, [editingViewId, editingName, views, pageId, updateViewMutation]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameCommit();
}
if (e.key === "Escape") {
e.preventDefault();
setEditingViewId(null);
}
},
[handleRenameCommit],
);
const handleDelete = useCallback(
(viewId: string) => {
if (orderedViews.length <= 1) return;
deleteViewMutation.mutate({ viewId, pageId });
if (viewId === activeViewId) {
const remaining = orderedViews.filter((v) => v.id !== viewId);
onViewChange(remaining[0].id);
}
},
[orderedViews, pageId, activeViewId, deleteViewMutation, onViewChange],
);
return (
<Group gap={4}>
{orderedViews.map((view) => (
<ViewTab
key={view.id}
view={view}
isActive={view.id === activeViewId}
isEditing={view.id === editingViewId}
editingName={editingName}
canDelete={orderedViews.length > 1}
reorderEnabled={editable && orderedViews.length > 1}
onReorder={handleReorder}
onClick={() => onViewChange(view.id)}
onRenameStart={() => handleRenameStart(view)}
onRenameChange={setEditingName}
onRenameCommit={handleRenameCommit}
onRenameKeyDown={handleRenameKeyDown}
onDelete={() => handleDelete(view.id)}
getViewShareUrl={getViewShareUrl}
/>
))}
{canAddView && base && (
<ViewCreateMenu base={base} pageId={pageId} />
)}
</Group>
);
}
function ViewTab({
view,
isActive,
isEditing,
editingName,
canDelete,
reorderEnabled,
onReorder,
onClick,
onRenameStart,
onRenameChange,
onRenameCommit,
onRenameKeyDown,
onDelete,
getViewShareUrl,
}: {
view: IBaseView;
isActive: boolean;
isEditing: boolean;
editingName: string;
canDelete: boolean;
reorderEnabled: boolean;
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
onClick: () => void;
onRenameStart: () => void;
onRenameChange: (name: string) => void;
onRenameCommit: () => void;
onRenameKeyDown: (e: React.KeyboardEvent) => void;
onDelete: () => void;
getViewShareUrl?: (viewId: string) => string | null;
}) {
const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false);
const editable = useBaseEditable();
const tabRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
const el = tabRef.current;
if (!el || !reorderEnabled || isEditing) return;
return combine(
draggable({
element: el,
getInitialData: () => ({ type: VIEW_DRAG_TYPE, viewId: view.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === VIEW_DRAG_TYPE &&
source.data.viewId !== view.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ viewId: view.id },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(source.data.viewId as string, view.id, edge);
},
}),
);
}, [view.id, reorderEnabled, isEditing]);
const handleTabClick = useCallback(() => {
if (isActive) {
setMenuOpened((o) => !o);
} else {
onClick();
}
}, [isActive, onClick]);
const handleCopyLink = useCallback(() => {
setMenuOpened(false);
const url = getViewShareUrl?.(view.id);
if (!url) return;
void navigator.clipboard.writeText(url);
notifications.show({ message: t("Link copied to clipboard") });
}, [getViewShareUrl, view.id, t]);
if (isEditing) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
padding: "1px 10px",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: "var(--mantine-radius-xl)",
}}
>
<TextInput
variant="unstyled"
size="xs"
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
styles={{
input: {
height: "auto",
minHeight: 0,
padding: 0,
width: 100,
fontSize: "var(--mantine-font-size-sm)",
lineHeight: 1.2,
},
}}
/>
</div>
);
}
return (
<div
ref={tabRef}
style={{
position: "relative",
display: "inline-flex",
opacity: isDragging ? 0.4 : 1,
}}
>
<Popover
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
shadow="md"
width={180}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>
<UnstyledButton
onClick={handleTabClick}
style={{
padding: "2px 10px",
borderRadius: "var(--mantine-radius-xl)",
fontWeight: isActive ? 600 : 400,
backgroundColor: isActive
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
: undefined,
}}
>
<Group gap={6} wrap="nowrap">
{view.type === "kanban" ? (
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
) : (
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
)}
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
{view.name}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={4}>
<Stack gap={0}>
{editable && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onRenameStart();
}}
>
<Group gap={8} wrap="nowrap">
<IconPencil size={14} />
<Text size="sm">{t("Rename")}</Text>
</Group>
</UnstyledButton>
)}
{getViewShareUrl && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={handleCopyLink}
>
<Group gap={8} wrap="nowrap">
<IconLink size={14} />
<Text size="sm">{t("Copy link to view")}</Text>
</Group>
</UnstyledButton>
)}
{editable && canDelete && (
<>
<Divider my={4} />
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onDelete();
}}
style={{ color: "var(--mantine-color-red-6)" }}
>
<Group gap={8} wrap="nowrap">
<IconTrash size={14} />
<Text size="sm">{t("Delete view")}</Text>
</Group>
</UnstyledButton>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -1,39 +0,0 @@
export type Currency = { code: string; name: string };
// Most-used first; order drives the dropdown.
export const CURRENCIES: Currency[] = [
{ code: "USD", name: "US Dollar" },
{ code: "EUR", name: "Euro" },
{ code: "GBP", name: "Pound" },
{ code: "CAD", name: "Canadian dollar" },
{ code: "AUD", name: "Australian dollar" },
{ code: "SGD", name: "Singapore dollar" },
{ code: "JPY", name: "Yen" },
{ code: "CNY", name: "Chinese Yuan" },
];
export const DEFAULT_CURRENCY_CODE = "USD";
const CURRENCY_CODES = new Set(CURRENCIES.map((c) => c.code));
// Renders value with locale symbol and grouping. Falls back to USD for unknown codes,
// plain string if Intl throws. precision overrides the currency's natural decimal places.
export function formatCurrency(
value: number,
code: string | undefined,
precision: number | undefined,
): string {
const currency =
code && CURRENCY_CODES.has(code) ? code : DEFAULT_CURRENCY_CODE;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
...(precision != null
? { minimumFractionDigits: precision, maximumFractionDigits: precision }
: {}),
}).format(value);
} catch {
return String(value);
}
}
@@ -1,22 +0,0 @@
import { createContext, useContext, type ReactNode } from "react";
const BaseEditableContext = createContext<boolean>(true);
export function BaseEditableProvider({
editable,
children,
}: {
editable: boolean;
children: ReactNode;
}) {
return (
<BaseEditableContext.Provider value={editable}>
{children}
</BaseEditableContext.Provider>
);
}
/** Whether the current base subtree is editable. Defaults to true outside a provider. */
export function useBaseEditable(): boolean {
return useContext(BaseEditableContext);
}
@@ -1,12 +0,0 @@
import { createContext, useContext } from "react";
// Row order is only needed at interaction time (shift-select range math), so
// rows subscribe to a stable getter instead of the array itself — appending a
// page must not re-render every mounted row.
const GridRowOrderContext = createContext<() => string[]>(() => []);
export const GridRowOrderProvider = GridRowOrderContext.Provider;
export function useGridRowOrder(): () => string[] {
return useContext(GridRowOrderContext);
}
@@ -1,11 +0,0 @@
import { createContext, useContext } from "react";
// Rows only need the handler at click time; a stable context value keeps the
// expand affordance out of every GridRow/GridCell memo equality check.
const RowExpandContext = createContext<((rowId: string) => void) | null>(null);
export const RowExpandProvider = RowExpandContext.Provider;
export function useRowExpand(): ((rowId: string) => void) | null {
return useContext(RowExpandContext);
}
@@ -1,22 +0,0 @@
import { formatNumber } from "@/ee/base/components/cells/cell-number";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
export { formatNumber, formatDateDisplay };
export function formatTimestamp(value: string | null | undefined): string {
if (typeof value !== "string" || !value) return "";
const date = new Date(value);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatLongTextPreview(value: string | null | undefined): string {
if (typeof value !== "string") return "";
return value.replace(/\s+/g, " ").trim();
}
@@ -1,102 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useStore, type PrimitiveAtom } from "jotai";
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
export type UseEditableTextCellParams = {
value: unknown;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
/** value -> the draft string shown in the input when editing begins */
toDraft: (value: unknown) => string;
/** draft string -> the value passed to onCommit */
parse: (draft: string) => unknown;
rowId?: string;
propertyId?: string;
};
export type EditableTextCell = {
draft: string;
setDraft: (draft: string) => void;
inputRef: React.RefObject<HTMLInputElement>;
handleKeyDown: (e: React.KeyboardEvent) => void;
handleBlur: () => void;
};
export function useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId,
}: UseEditableTextCellParams): EditableTextCell {
const [draft, setDraft] = useState(() => toDraft(value));
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const toDraftRef = useRef(toDraft);
toDraftRef.current = toDraft;
const store = useStore();
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
committedRef.current = false;
const pending = store.get(pendingTypeInsertAtom);
const seeded =
pending != null &&
pending.rowId === rowId &&
pending.propertyId === propertyId;
if (seeded) {
setDraft(pending.char);
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
el.focus();
const len = el.value.length;
el.setSelectionRange(len, len);
}
});
} else {
setDraft(toDraftRef.current(value));
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}
wasEditingRef.current = isEditing;
}, [isEditing, value, rowId, propertyId, store]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(parse(draft));
} else if (e.key === "Escape") {
e.preventDefault();
committedRef.current = true;
onCancel();
}
},
[draft, parse, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(parse(draft));
}, [draft, parse, commitOnce]);
return { draft, setDraft, inputRef, handleKeyDown, handleBlur };
}
@@ -1,12 +0,0 @@
import { useEffect } from "react";
export function useEscapeClose(opened: boolean, onClose: () => void) {
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, onClose]);
}
@@ -1,120 +0,0 @@
import { type RefObject, useEffect } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
import { COLUMN_DRAG_TYPE } from "@/ee/base/components/grid/grid-header-cell";
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
const HEADER_BAND_REACH_PX = 60;
const EDGE_OUTWARD_REACH_PX = 80;
const EARLY_PAN_MARGIN_PX = 100;
const MIN_PAN_SPEED_PX = 3;
const MAX_PAN_SPEED_PX = 16;
export function useGridAutoScroll<T extends HTMLElement>(
bodyRef: RefObject<T | null>,
pageId: string,
): void {
useEffect(() => {
const element = bodyRef.current;
if (!element) return;
let rafId = 0;
let pointerX: number | null = null;
// Captured once at drag start: cursor-to-column-left-edge distance and column width.
let grabOffsetX = 0;
let columnWidth = 0;
let lockedScrollLeft: number | null = null;
const keepHorizontalScroll = () => {
if (lockedScrollLeft !== null && element.scrollLeft !== lockedScrollLeft) {
element.scrollLeft = lockedScrollLeft;
}
};
function speedForDepth(distanceFromEdge: number): number {
const depth = Math.min(1, (EARLY_PAN_MARGIN_PX - distanceFromEdge) / EARLY_PAN_MARGIN_PX);
return MIN_PAN_SPEED_PX + (MAX_PAN_SPEED_PX - MIN_PAN_SPEED_PX) * depth;
}
function pan() {
if (pointerX === null) {
rafId = 0;
return;
}
const rect = element.getBoundingClientRect();
const columnLeft = pointerX - grabOffsetX;
const columnRight = columnLeft + columnWidth;
const fromLeft = columnLeft - rect.left;
const fromRight = rect.right - columnRight;
let delta = 0;
if (fromLeft < EARLY_PAN_MARGIN_PX) {
delta = -speedForDepth(fromLeft);
} else if (fromRight < EARLY_PAN_MARGIN_PX) {
delta = speedForDepth(fromRight);
}
if (delta !== 0) element.scrollLeft += delta;
rafId = requestAnimationFrame(pan);
}
return combine(
autoScrollForElements({
element,
canScroll: ({ source }) =>
source.data?.type === COLUMN_DRAG_TYPE &&
source.data?.pageId === pageId,
getAllowedAxis: () => "horizontal" as const,
}),
unsafeOverflowAutoScrollForElements({
element,
canScroll: ({ source }) =>
source.data?.type === COLUMN_DRAG_TYPE &&
source.data?.pageId === pageId,
getAllowedAxis: () => "horizontal" as const,
getOverflow: () => ({
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
}),
}),
monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
onDragStart: ({ location, source }) => {
const cr = source.element.getBoundingClientRect();
grabOffsetX = location.current.input.clientX - cr.left;
columnWidth = cr.width;
pointerX = location.current.input.clientX;
if (rafId === 0) rafId = requestAnimationFrame(pan);
},
onDrag: ({ location }) => {
pointerX = location.current.input.clientX;
},
onDrop: () => {
pointerX = null;
if (rafId !== 0) {
cancelAnimationFrame(rafId);
rafId = 0;
}
},
}),
monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
onDragStart: () => {
lockedScrollLeft = element.scrollLeft;
element.addEventListener("scroll", keepHorizontalScroll);
},
onDrop: () => {
element.removeEventListener("scroll", keepHorizontalScroll);
lockedScrollLeft = null;
},
}),
() => {
if (rafId !== 0) cancelAnimationFrame(rafId);
element.removeEventListener("scroll", keepHorizontalScroll);
},
);
}, [bodyRef, pageId]);
}
@@ -1,293 +0,0 @@
import { useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
type UseGridKeyboardNavOptions = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
containerRef: React.RefObject<HTMLDivElement | null>;
focusedCell: FocusedCell;
setFocusedCell: (cell: FocusedCell) => void;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
openEditor: (coord: CellCoord) => void;
clearCell: (coord: CellCoord) => void;
beginTypeToEdit: (coord: CellCoord, char: string) => void;
scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void;
selectionCount: number;
clearSelection: () => void;
deleteSelected: () => void | Promise<void>;
toggleRowSelection: (rowId: string) => void;
expandRow: (rowId: string) => void;
};
const isPrintableKey = (e: KeyboardEvent) =>
e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
const isTextEntry = (el: Element | null) =>
!!el &&
(el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable);
export function useGridKeyboardNav({
table,
properties,
containerRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
}: UseGridKeyboardNavOptions) {
const getColIds = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id),
[table],
);
const getNavColIds = useCallback(
() => table.getVisibleLeafColumns().map((col) => col.id),
[table],
);
const getRowIds = useCallback(
() => table.getRowModel().rows.map((row) => row.id),
[table],
);
const propertyType = useCallback(
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
[properties],
);
const goEditing = useCallback(
(next: CellCoord) => {
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(next);
setFocusedCell(next);
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
},
[setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds],
);
const goFocused = useCallback(
(next: CellCoord) => {
setFocusedCell(next);
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
},
[setFocusedCell, scrollCellIntoView, getRowIds],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (editingCell) {
const inInput = isTextEntry(e.target as Element);
switch (e.key) {
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight": {
if (inInput) return;
e.preventDefault();
const d =
e.key === "ArrowUp"
? [-1, 0]
: e.key === "ArrowDown"
? [1, 0]
: e.key === "ArrowLeft"
? [0, -1]
: [0, 1];
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
d[0],
d[1],
false,
);
if (next) goEditing(next);
break;
}
case "Tab": {
e.preventDefault();
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
0,
e.shiftKey ? -1 : 1,
true,
);
if (next) goEditing(next);
break;
}
case "Enter": {
e.preventDefault();
const next = computeNextCell(
getRowIds(),
getColIds(),
editingCell,
1,
0,
false,
);
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(null);
if (next) goFocused(next);
else setFocusedCell(editingCell);
break;
}
case "Escape": {
e.preventDefault();
setEditingCell(null);
setFocusedCell(editingCell);
break;
}
}
return;
}
if (isTextEntry(document.activeElement)) return;
if (e.key === "Escape") {
if (selectionCount > 0) {
e.preventDefault();
clearSelection();
} else if (focusedCell) {
e.preventDefault();
setFocusedCell(null);
}
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (selectionCount > 0) {
e.preventDefault();
void deleteSelected();
} else if (focusedCell) {
e.preventDefault();
clearCell(focusedCell);
}
return;
}
if (!focusedCell) return;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowDown":
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowLeft":
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
if (next) goFocused(next);
}
break;
case "ArrowRight":
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
if (next) goFocused(next);
}
break;
case "Tab": {
const next = computeNextCell(
getRowIds(),
getNavColIds(),
focusedCell,
0,
e.shiftKey ? -1 : 1,
true,
);
if (next) {
e.preventDefault();
goFocused(next);
}
break;
}
case "Enter":
case "F2":
e.preventDefault();
if (focusedCell.propertyId === "__row_number") {
toggleRowSelection(focusedCell.rowId);
} else {
openEditor(focusedCell);
}
break;
default: {
if (e.key === " ") {
e.preventDefault();
if (focusedCell.propertyId === "__row_number") {
toggleRowSelection(focusedCell.rowId);
} else if (propertyType(focusedCell.propertyId) === "checkbox") {
openEditor(focusedCell);
} else {
expandRow(focusedCell.rowId);
}
} else if (isPrintableKey(e)) {
e.preventDefault();
beginTypeToEdit(focusedCell, e.key);
}
}
}
},
[
editingCell,
focusedCell,
getRowIds,
getColIds,
getNavColIds,
goEditing,
goFocused,
setEditingCell,
setFocusedCell,
openEditor,
clearCell,
beginTypeToEdit,
propertyType,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
],
);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleKeyDown]);
}
@@ -1,70 +0,0 @@
import { type RefObject, useEffect } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
import { KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
const HEADER_BAND_REACH_PX = 60;
const EDGE_OUTWARD_REACH_PX = 80;
export function useKanbanBoardAutoScroll<T extends HTMLElement>(
boardRef: RefObject<T | null>,
pageId: string,
): void {
useEffect(() => {
const element = boardRef.current;
if (!element) return;
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
(source.data?.type === KANBAN_CARD_DRAG_TYPE ||
source.data?.type === KANBAN_COLUMN_DRAG_TYPE) &&
source.data?.pageId === pageId;
return combine(
autoScrollForElements({
element,
canScroll,
getAllowedAxis: () => "horizontal" as const,
}),
unsafeOverflowAutoScrollForElements({
element,
canScroll,
getAllowedAxis: () => "horizontal" as const,
getOverflow: () => ({
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
}),
}),
);
}, [boardRef, pageId]);
}
export function useKanbanColumnAutoScroll<T extends HTMLElement>(
listRef: RefObject<T | null>,
pageId: string,
): void {
useEffect(() => {
const element = listRef.current;
if (!element) return;
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId;
return combine(
autoScrollForElements({
element,
canScroll,
getAllowedAxis: () => "vertical" as const,
}),
unsafeOverflowAutoScrollForElements({
element,
canScroll,
getAllowedAxis: () => "vertical" as const,
getOverflow: () => ({
forTopEdge: { top: EDGE_OUTWARD_REACH_PX },
forBottomEdge: { bottom: EDGE_OUTWARD_REACH_PX },
}),
}),
);
}, [listRef, pageId]);
}
@@ -1,80 +0,0 @@
import { type RefObject, useEffect, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/kanban.module.css";
export function useKanbanCardDnd({
cardRef,
rowId,
columnKey,
pageId,
}: {
cardRef: RefObject<HTMLDivElement | null>;
rowId: string;
columnKey: string;
pageId: string;
}): { closestEdge: Edge | null; isDragging: boolean } {
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const cardEl = cardRef.current;
if (!cardEl) return;
return combine(
draggable({
element: cardEl,
getInitialData: () => ({
type: KANBAN_CARD_DRAG_TYPE,
rowId,
columnKey,
pageId,
}),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
const width = cardEl.getBoundingClientRect().width;
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
render: ({ container }) => {
const card = document.createElement("div");
card.className = classes.cardDragPreview;
card.style.width = `${width}px`;
const clone = cardEl.cloneNode(true) as HTMLElement;
clone.style.opacity = "1";
card.appendChild(clone);
container.appendChild(card);
},
});
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: cardEl,
canDrop: ({ source }) =>
source.data.type === KANBAN_CARD_DRAG_TYPE &&
source.data.pageId === pageId,
getData: ({ input, element }) =>
attachClosestEdge(
{ rowId, columnKey },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [cardRef, rowId, columnKey, pageId]);
return { closestEdge, isDragging };
}
@@ -1,34 +0,0 @@
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { NO_VALUE_CHOICE_ID, type IBaseRow } from "@/ee/base/types/base.types";
export function resolveCardDrop(args: {
draggedRowId: string;
targetRowId: string | null;
edge: "top" | "bottom" | null;
targetColumnKey: string;
sourceColumnKey: string;
targetColumnRows: IBaseRow[];
}): { columnChanged: boolean; destChoiceValue: string | null; position: string } | null {
const { draggedRowId, targetRowId, edge, targetColumnKey, sourceColumnKey, targetColumnRows } = args;
const columnChanged = sourceColumnKey !== targetColumnKey;
if (!columnChanged && draggedRowId === targetRowId) return null;
const destChoiceValue = targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey;
const rows = targetColumnRows.filter((r) => r.id !== draggedRowId);
let position: string;
if (!targetRowId || edge === null) {
const last = rows[rows.length - 1];
position = generateJitteredKeyBetween(last?.position ?? null, null);
} else {
const idx = rows.findIndex((r) => r.id === targetRowId);
if (idx === -1) {
const last = rows[rows.length - 1];
position = generateJitteredKeyBetween(last?.position ?? null, null);
} else {
const neighbor = edge === "top" ? idx - 1 : idx + 1;
const lower = edge === "top" ? rows[neighbor]?.position ?? null : rows[idx].position;
const upper = edge === "top" ? rows[idx].position : rows[neighbor]?.position ?? null;
position = generateJitteredKeyBetween(lower, upper);
}
}
return { columnChanged, destChoiceValue, position };
}
@@ -1,63 +0,0 @@
import { type RefObject, useEffect, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
export function useKanbanColumnDnd({
headerRef,
handleRef,
columnKey,
pageId,
}: {
headerRef: RefObject<HTMLDivElement | null>;
handleRef: RefObject<HTMLDivElement | null>;
columnKey: string;
pageId: string;
}): { closestEdge: Edge | null; isDragging: boolean } {
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
useEffect(() => {
const headerEl = headerRef.current;
const handleEl = handleRef.current;
if (!headerEl || !handleEl) return;
return combine(
draggable({
element: headerEl,
dragHandle: handleEl,
getInitialData: () => ({
type: KANBAN_COLUMN_DRAG_TYPE,
columnKey,
pageId,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: headerEl,
canDrop: ({ source }) =>
source.data.type === KANBAN_COLUMN_DRAG_TYPE &&
source.data.pageId === pageId &&
source.data.columnKey !== columnKey,
getData: ({ input, element }) =>
attachClosestEdge(
{ columnKey },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: () => setClosestEdge(null),
}),
);
}, [headerRef, handleRef, columnKey, pageId]);
return { closestEdge, isDragging };
}
@@ -1,52 +0,0 @@
import { useMemo } from "react";
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
export type KanbanGroup = KanbanColumn & { hidden: boolean };
export function useKanbanColumns(
base: IBase | undefined,
view: IBaseView | undefined,
): {
groupByPropertyId: string | undefined;
columns: KanbanColumn[];
allGroups: KanbanGroup[];
hasValidGroupBy: boolean;
} {
return useMemo(() => {
const groupByPropertyId = view?.config?.groupByPropertyId;
const prop = groupByPropertyId ? base?.properties.find((p) => p.id === groupByPropertyId) : undefined;
const groupable = prop && (prop.type === "select" || prop.type === "status");
if (!groupable || !prop || !view) {
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
}
const typeOptions = prop.typeOptions as SelectTypeOptions;
const choices = typeOptions?.choices ?? [];
const choiceMap = new Map(choices.map((c) => [c.id, c]));
const validKeys = new Set([NO_VALUE_CHOICE_ID, ...choices.map((c) => c.id)]);
const config = view.config;
const configChoiceOrder: string[] = config.choiceOrder?.length
? config.choiceOrder.filter((k) => validKeys.has(k))
: [...(typeOptions?.choiceOrder ?? choices.map((c) => c.id)), NO_VALUE_CHOICE_ID];
const inOrder = new Set(configChoiceOrder);
const baseOrder = [
...configChoiceOrder,
...choices.map((c) => c.id).filter((id) => !inOrder.has(id)),
];
const hidden = new Set(config.hiddenChoiceIds ?? []);
const allGroups: KanbanGroup[] = baseOrder.map((k) => {
if (k === NO_VALUE_CHOICE_ID) {
return { key: k, name: "No value", color: undefined, isNoValue: true, hidden: hidden.has(k) };
}
const choice = choiceMap.get(k);
return { key: k, name: choice?.name ?? k, color: choice?.color, isNoValue: false, hidden: hidden.has(k) };
});
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
}, [base, view]);
}
@@ -1,32 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@mantine/hooks";
import { searchSuggestions } from "@/features/search/services/search-service";
export type PersonSuggestion = {
id: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
};
export function usePersonSearch(
search: string,
enabled: boolean,
): PersonSuggestion[] {
const [debounced] = useDebouncedValue(search, 250);
const trimmed = debounced.trim();
const { data = [] } = useQuery({
queryKey: ["bases", "persons", "search", trimmed],
queryFn: async () => {
const res = await searchSuggestions({
query: trimmed,
includeUsers: true,
limit: trimmed ? 25 : 10,
});
return (res.users ?? []) as PersonSuggestion[];
},
enabled,
staleTime: 15_000,
});
return data;
}
@@ -1,28 +0,0 @@
import { useEffect } from "react";
import {
autoScrollForElements,
autoScrollWindowForElements,
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
export function useRowAutoScroll(
scrollElement: HTMLElement | Window | null,
pageId: string,
): void {
useEffect(() => {
if (!scrollElement) return;
if (scrollElement === window) {
return autoScrollWindowForElements({
canScroll: ({ source }) =>
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
getAllowedAxis: () => "vertical" as const,
});
}
return autoScrollForElements({
element: scrollElement as HTMLElement,
canScroll: ({ source }) =>
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
getAllowedAxis: () => "vertical" as const,
});
}, [scrollElement, pageId]);
}
@@ -1,33 +0,0 @@
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
const PARAM = "row";
const BASE_PARAM = "rowBase";
export function useRowDetailModal(baseId: string) {
const [searchParams, setSearchParams] = useSearchParams();
const rowParam = searchParams.get(PARAM);
const openRowId =
rowParam && searchParams.get(BASE_PARAM) === baseId ? rowParam : null;
const openRow = useCallback(
(rowId: string, options?: { replace?: boolean }) => {
const next = new URLSearchParams(searchParams);
next.set(PARAM, rowId);
next.set(BASE_PARAM, baseId);
// Prev/next inside the modal replaces the entry so Back leaves the
// modal instead of replaying every visited record.
setSearchParams(next, { replace: options?.replace ?? false });
},
[searchParams, setSearchParams, baseId],
);
const closeRow = useCallback(() => {
const next = new URLSearchParams(searchParams);
next.delete(PARAM);
next.delete(BASE_PARAM);
setSearchParams(next, { replace: false });
}, [searchParams, setSearchParams]);
return { openRowId, openRow, closeRow };
}
@@ -1,42 +0,0 @@
import type React from 'react';
import type { IconLetterT } from '@tabler/icons-react';
import type {
BasePropertyType,
IBaseProperty,
IBaseRow,
TypeOptions,
} from '@/ee/base/types/base.types';
export type CellComponentProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
/** When true the cell must not write; reveal content read-only. */
readOnly?: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
export type FilterInputKind =
| 'choices'
| 'number'
| 'boolean'
| 'text'
| 'person'
| 'date';
export type ClientPropertyTypeDescriptor = {
type: BasePropertyType;
cellComponent: React.ComponentType<CellComponentProps>;
icon: typeof IconLetterT;
labelKey: string;
filterOperators: string[];
filterInput: FilterInputKind;
isSystem: boolean;
hasOptions: boolean;
systemAccessor?: (row: IBaseRow) => unknown;
defaultTypeOptions?: () => TypeOptions;
};
@@ -1,263 +0,0 @@
import {
IconLetterT,
IconAlignLeft,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconFileDescription,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
IconMathFunction,
} from "@tabler/icons-react";
import type {
BasePropertyType,
TypeOptions,
} from "@/ee/base/types/base.types";
import { CellText } from "@/ee/base/components/cells/cell-text";
import { CellLongText } from "@/ee/base/components/cells/cell-long-text";
import { CellNumber } from "@/ee/base/components/cells/cell-number";
import { CellSelect } from "@/ee/base/components/cells/cell-select";
import { CellStatus } from "@/ee/base/components/cells/cell-status";
import { CellMultiSelect } from "@/ee/base/components/cells/cell-multi-select";
import { CellDate } from "@/ee/base/components/cells/cell-date";
import { CellCheckbox } from "@/ee/base/components/cells/cell-checkbox";
import { CellUrl } from "@/ee/base/components/cells/cell-url";
import { CellEmail } from "@/ee/base/components/cells/cell-email";
import { CellPerson } from "@/ee/base/components/cells/cell-person";
import { CellFile } from "@/ee/base/components/cells/cell-file";
import { CellPage } from "@/ee/base/components/cells/cell-page";
import { CellCreatedAt } from "@/ee/base/components/cells/cell-created-at";
import { CellLastEditedAt } from "@/ee/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/ee/base/components/cells/cell-last-edited-by";
import { CellFormula } from "@/ee/base/components/cells/cell-formula";
import { defaultStatusChoices } from "@/ee/base/components/property/choice-editor";
import type { ClientPropertyTypeDescriptor } from "./property-type.descriptor";
export const PROPERTY_TYPE_REGISTRY: Record<
BasePropertyType,
ClientPropertyTypeDescriptor
> = {
text: {
type: "text",
cellComponent: CellText,
icon: IconLetterT,
labelKey: "Text",
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: true,
},
longText: {
type: "longText",
cellComponent: CellLongText,
icon: IconAlignLeft,
labelKey: "Long text",
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: true,
},
number: {
type: "number",
cellComponent: CellNumber,
icon: IconHash,
labelKey: "Number",
filterOperators: ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"],
filterInput: "number",
isSystem: false,
hasOptions: true,
defaultTypeOptions: () => ({ separators: "local" }),
},
select: {
type: "select",
cellComponent: CellSelect,
icon: IconCircleDot,
labelKey: "Select",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
filterInput: "choices",
isSystem: false,
hasOptions: true,
},
status: {
type: "status",
cellComponent: CellStatus,
icon: IconProgressCheck,
labelKey: "Status",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
filterInput: "choices",
isSystem: false,
hasOptions: true,
defaultTypeOptions: () => {
const choices = defaultStatusChoices();
return {
choices,
choiceOrder: choices.map((c) => c.id),
defaultValue: choices[0].id,
};
},
},
multiSelect: {
type: "multiSelect",
cellComponent: CellMultiSelect,
icon: IconTags,
labelKey: "Multi-select",
filterOperators: ["any", "none", "isEmpty", "isNotEmpty"],
filterInput: "choices",
isSystem: false,
hasOptions: true,
},
date: {
type: "date",
cellComponent: CellDate,
icon: IconCalendar,
labelKey: "Date",
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
filterInput: "date",
isSystem: false,
hasOptions: true,
},
person: {
type: "person",
cellComponent: CellPerson,
icon: IconUser,
labelKey: "Person",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
filterInput: "person",
isSystem: false,
hasOptions: true,
},
file: {
type: "file",
cellComponent: CellFile,
icon: IconPaperclip,
labelKey: "File",
filterOperators: ["isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: false,
},
formula: {
type: "formula",
cellComponent: CellFormula,
icon: IconMathFunction,
labelKey: "Formula",
filterOperators: ["eq", "neq", "isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: true,
hasOptions: false,
},
page: {
type: "page",
cellComponent: CellPage,
icon: IconFileDescription,
labelKey: "Page",
filterOperators: ["isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: false,
},
checkbox: {
type: "checkbox",
cellComponent: CellCheckbox,
icon: IconCheckbox,
labelKey: "Checkbox",
filterOperators: ["eq", "isEmpty", "isNotEmpty"],
filterInput: "boolean",
isSystem: false,
hasOptions: true,
},
url: {
type: "url",
cellComponent: CellUrl,
icon: IconLink,
labelKey: "URL",
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: true,
},
email: {
type: "email",
cellComponent: CellEmail,
icon: IconMail,
labelKey: "Email",
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
filterInput: "text",
isSystem: false,
hasOptions: true,
},
createdAt: {
type: "createdAt",
cellComponent: CellCreatedAt,
icon: IconClockPlus,
labelKey: "Created at",
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
filterInput: "date",
isSystem: true,
hasOptions: false,
systemAccessor: (row) => row.createdAt,
},
lastEditedAt: {
type: "lastEditedAt",
cellComponent: CellLastEditedAt,
icon: IconClockEdit,
labelKey: "Last edited at",
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
filterInput: "date",
isSystem: true,
hasOptions: false,
systemAccessor: (row) => row.updatedAt,
},
lastEditedBy: {
type: "lastEditedBy",
cellComponent: CellLastEditedBy,
icon: IconUserEdit,
labelKey: "Last edited by",
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
filterInput: "person",
isSystem: true,
hasOptions: false,
systemAccessor: (row) => row.lastUpdatedById ?? row.creatorId,
},
};
export function getDescriptor(type: string): ClientPropertyTypeDescriptor | undefined {
return (PROPERTY_TYPE_REGISTRY as Record<string, ClientPropertyTypeDescriptor>)[type];
}
export const SYSTEM_PROPERTY_TYPES: ReadonlySet<string> = new Set(
Object.values(PROPERTY_TYPE_REGISTRY).filter((d) => d.isSystem).map((d) => d.type),
);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
export const DEFAULT_FILTER_OPERATORS = ["eq", "neq", "isEmpty", "isNotEmpty"];
export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
"text", "longText", "number", "select", "status", "multiSelect", "date",
"person", "file", "formula", "page", "checkbox", "url", "email",
"createdAt", "lastEditedAt", "lastEditedBy",
];
export const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
const d = getDescriptor(type)!;
return { type, icon: d.icon, labelKey: d.labelKey };
});
export function systemAccessorFor(type: string) {
return getDescriptor(type)?.systemAccessor;
}
export function defaultTypeOptionsFor(type: string): TypeOptions {
return getDescriptor(type)?.defaultTypeOptions?.() ?? ({} as TypeOptions);
}
@@ -1,567 +0,0 @@
import { useMemo } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
InfiniteData,
} from "@tanstack/react-query";
import {
createRow,
updateRow,
deleteRow,
deleteRows,
getRowInfo,
listRows,
reorderRow,
IBaseRowsPage,
} from "@/ee/base/services/base-service";
import {
IBase,
IBaseRow,
CreateRowInput,
UpdateRowInput,
DeleteRowInput,
DeleteRowsInput,
ReorderRowInput,
FilterNode,
ViewSortConfig,
RowReferences,
NO_VALUE_CHOICE_ID,
} from "@/ee/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
import { v7 as uuid7 } from "uuid";
type RowCacheContext = {
snapshots: [readonly unknown[], InfiniteData<IBaseRowsPage> | undefined][];
};
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
// Strip it at the query boundary to keep request payloads clean and cache keys stable.
export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
if (!filter) return undefined;
if ('children' in filter && filter.children.length === 0) return undefined;
return filter;
}
// Pre-register the requestId as outbound so the socket echo is suppressed by useBaseSocket.
function newRequestId(): string {
const id = uuid7();
markRequestIdOutbound(id);
return id;
}
export function baseRowsQueryKey(
pageId: string | undefined,
filter: FilterNode | undefined,
sorts: ViewSortConfig[] | undefined,
) {
return [
"base-rows",
pageId,
normalizeFilter(filter),
sorts?.length ? sorts : undefined,
] as const;
}
export function findRowInInfinite(
data: InfiniteData<IBaseRowsPage> | undefined,
rowId: string,
): IBaseRow | undefined {
if (!data) return undefined;
for (const page of data.pages) {
const row = page.items.find((r) => r.id === rowId);
if (row) return row;
}
return undefined;
}
export function useBaseRowsQuery(
pageId: string | undefined,
filter?: FilterNode,
sorts?: ViewSortConfig[],
) {
const activeFilter = normalizeFilter(filter);
const activeSorts = sorts?.length ? sorts : undefined;
const query = useInfiniteQuery({
queryKey: baseRowsQueryKey(pageId, filter, sorts),
queryFn: ({ pageParam }) =>
listRows(pageId!, {
cursor: pageParam,
limit: 100,
filter: activeFilter,
sorts: activeSorts,
}),
enabled: !!pageId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IBaseRowsPage) =>
lastPage.meta?.nextCursor ?? undefined,
staleTime: 5 * 60 * 1000,
});
const refPages = useMemo(
() =>
(query.data?.pages ?? [])
.map((p) => p.references)
.filter(Boolean) as RowReferences[],
[query.data],
);
useHydrateReferences(pageId, refPages);
return query;
}
export function flattenRows(
data: InfiniteData<IBaseRowsPage> | undefined,
): IBaseRow[] {
if (!data) return [];
return data.pages.flatMap((page) => page.items);
}
export function useCreateRowMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, CreateRowInput>({
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
onSuccess: (newRow) => {
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", newRow.pageId] },
(old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === lastPageIndex) {
return { ...page, items: [...page.items, newRow] };
}
return page;
}),
};
},
);
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
invalidateBaseRows(newRow.pageId);
}
},
onError: (error) => {
notifications.show({
message: getApiErrorMessage(error, t("Failed to create row")),
color: "red",
});
},
});
}
/** Single row by id for deep links (?row=) pointing outside the loaded
* pages or the active view's filter. No retry: an error means the row is
* gone and the caller should close. */
export function useBaseRowQuery(
pageId: string | undefined,
rowId: string | undefined,
options?: { enabled?: boolean },
) {
return useQuery<IBaseRow, Error>({
queryKey: ["base-row", pageId, rowId],
queryFn: () => getRowInfo(rowId!, pageId!),
enabled: !!pageId && !!rowId && (options?.enabled ?? true),
retry: false,
});
}
export function useUpdateRowMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.pageId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IBaseRowsPage>
>({ queryKey: ["base-rows", variables.pageId] });
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", variables.pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === variables.rowId
? {
...row,
cells: { ...row.cells, ...variables.cells },
...(variables.position !== undefined && {
position: variables.position,
}),
}
: row,
),
})),
};
},
);
queryClient.setQueryData<IBaseRow>(
["base-row", variables.pageId, variables.rowId],
(old) =>
old
? { ...old, cells: { ...old.cells, ...variables.cells } }
: old,
);
return { snapshots };
},
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
queryClient.invalidateQueries({
queryKey: ["base-row", variables.pageId, variables.rowId],
});
notifications.show({
message: getApiErrorMessage(error, t("Failed to update row")),
color: "red",
});
},
onSuccess: (updatedRow, variables) => {
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", updatedRow.pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === updatedRow.id
? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
: row,
),
})),
};
},
);
queryClient.setQueryData<IBaseRow>(
["base-row", updatedRow.pageId, updatedRow.id],
(old) =>
old
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
: old,
);
const base = queryClient.getQueryData<IBase>(["bases", variables.pageId]);
const kanbanGroupByIds = new Set(
(base?.views ?? [])
.filter((v) => v.type === "kanban")
.map((v) => v.config?.groupByPropertyId)
.filter(Boolean) as string[],
);
const changedPropertyIds = Object.keys(variables.cells ?? {});
if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
invalidateBaseRows(variables.pageId);
}
},
});
}
export function useDeleteRowMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.pageId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IBaseRowsPage>
>({ queryKey: ["base-rows", variables.pageId] });
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", variables.pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== variables.rowId),
})),
};
},
);
return { snapshots };
},
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: getApiErrorMessage(error, t("Failed to delete row")),
color: "red",
});
},
});
}
export function useDeleteRowsMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.pageId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IBaseRowsPage>
>({ queryKey: ["base-rows", variables.pageId] });
const removeSet = new Set(variables.rowIds);
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", variables.pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
);
return { snapshots };
},
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: getApiErrorMessage(error, t("Failed to delete rows")),
color: "red",
});
},
});
}
export function useReorderRowMutation() {
const { t } = useTranslation();
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.pageId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IBaseRowsPage>
>({ queryKey: ["base-rows", variables.pageId] });
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", variables.pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === variables.rowId
? { ...row, position: variables.position }
: row,
),
})),
};
},
);
return { snapshots };
},
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: getApiErrorMessage(error, t("Failed to reorder row")),
color: "red",
});
},
});
}
type KanbanMoveCardInput = {
pageId: string;
rowId: string;
sourceColumnFilter: FilterNode | undefined;
destColumnFilter: FilterNode | undefined;
columnChanged: boolean;
groupByPropertyId: string;
destChoiceValue: string | null;
position: string;
};
type KanbanMoveCardContext = {
snapshots: [readonly unknown[], unknown][];
};
export function useKanbanMoveCardMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, KanbanMoveCardInput, KanbanMoveCardContext>({
mutationFn: ({ pageId, rowId, columnChanged, groupByPropertyId, destChoiceValue, position }) =>
updateRow({
pageId,
rowId,
cells: columnChanged ? { [groupByPropertyId]: destChoiceValue } : {},
position,
requestId: newRequestId(),
}),
onMutate: async (variables) => {
const { pageId, rowId, sourceColumnFilter, destColumnFilter, columnChanged, groupByPropertyId, destChoiceValue, position } = variables;
await queryClient.cancelQueries({ queryKey: ["base-rows", pageId] });
const sourceKey = baseRowsQueryKey(pageId, sourceColumnFilter, undefined);
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
const sourceSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(sourceKey);
const destSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(destKey);
const snapshots: KanbanMoveCardContext["snapshots"] = [
[sourceKey, sourceSnapshot],
[destKey, destSnapshot],
];
if (columnChanged) {
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(sourceKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((r) => r.id !== rowId),
})),
};
});
const movingRow = findRowInInfinite(sourceSnapshot, rowId);
if (movingRow) {
const moved: IBaseRow = {
...movingRow,
cells: { ...movingRow.cells, [groupByPropertyId]: destChoiceValue },
position,
};
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) =>
index === lastPageIndex
? { ...page, items: [...page.items, moved] }
: page,
),
};
});
}
} else {
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((r) =>
r.id === rowId ? { ...r, position } : r,
),
})),
};
});
}
return { snapshots };
},
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: getApiErrorMessage(error, t("Failed to move card")),
color: "red",
});
},
});
}
type KanbanCreateCardInput = {
pageId: string;
destColumnFilter: FilterNode | undefined;
groupByPropertyId: string;
columnKey: string;
position?: string;
};
export function useKanbanCreateCardMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, KanbanCreateCardInput>({
mutationFn: ({ pageId, groupByPropertyId, columnKey, position }) =>
createRow({
pageId,
cells: columnKey === NO_VALUE_CHOICE_ID ? {} : { [groupByPropertyId]: columnKey },
position,
requestId: newRequestId(),
}),
onSuccess: (newRow, variables) => {
const { pageId, destColumnFilter } = variables;
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) =>
index === lastPageIndex
? { ...page, items: [...page.items, newRow] }
: page,
),
};
});
},
onError: (error) => {
notifications.show({
message: getApiErrorMessage(error, t("Failed to add card")),
color: "red",
});
},
});
}
export function invalidateBaseRows(pageId: string) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
}
@@ -1,60 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { referenceStoreAtomFamily, mergeReferences } from "@/ee/base/atoms/reference-store-atom";
import { useResolvedPages, type ResolvedPage } from "@/ee/base/queries/base-page-resolver-query";
import type { RowReferences, UserRef } from "@/ee/base/types/base.types";
import useCurrentUser from "@/features/user/hooks/use-current-user";
export function useHydrateReferences(
pageId: string | undefined,
pages: RowReferences[],
): void {
const setStore = useSetAtom(referenceStoreAtomFamily(pageId ?? ""));
const flat = useMemo(() => pages, [pages]);
useEffect(() => {
if (!pageId || flat.length === 0) return;
setStore((prev) => flat.reduce((acc, r) => mergeReferences(acc, r), prev));
}, [pageId, flat, setStore]);
}
export function useReferenceStore(pageId: string): RowReferences {
return useAtomValue(referenceStoreAtomFamily(pageId));
}
// Imperatively merges resolved users on pick so a just-selected person resolves without a rows-page refetch.
export function useHydrateUsers(pageId: string): (users: UserRef[]) => void {
const setStore = useSetAtom(referenceStoreAtomFamily(pageId));
return useCallback(
(users: UserRef[]) => {
if (users.length === 0) return;
const map: Record<string, UserRef> = {};
for (const u of users) map[u.id] = u;
setStore((prev) => mergeReferences(prev, { users: map, pages: {} }));
},
[setStore],
);
}
// Hydrates the signed-in user so lastEditedBy cells resolve without a refetch.
export function useHydrateCurrentUser(pageId: string): void {
const { data: currentUser } = useCurrentUser();
const hydrateUsers = useHydrateUsers(pageId);
const u = currentUser?.user;
useEffect(() => {
if (!u) return;
hydrateUsers([{ id: u.id, name: u.name ?? null, avatarUrl: u.avatarUrl ?? null }]);
}, [u?.id, u?.name, u?.avatarUrl, hydrateUsers]);
}
// Store-first lookup; falls back to the batched /bases/pages/expand on a miss.
export function useResolvePage(
pageId: string,
id: string | null,
): ResolvedPage | null | undefined {
const store = useReferenceStore(pageId);
const stored = id ? store.pages[id] : undefined;
const { pages } = useResolvedPages(stored || !id ? [] : [id]);
if (!id) return undefined;
if (stored) return stored;
return pages.get(id);
}
@@ -1,7 +0,0 @@
export const KANBAN_COUNT_CAP = 99;
export function formatKanbanCount(loaded: number, hasMore: boolean): string {
return hasMore || loaded > KANBAN_COUNT_CAP
? `${KANBAN_COUNT_CAP}+`
: String(loaded);
}
@@ -1,16 +0,0 @@
import { NO_VALUE_CHOICE_ID, FilterGroup, FilterNode } from "@/ee/base/types/base.types";
import { normalizeFilter } from "@/ee/base/queries/base-row-query";
export function buildColumnFilter(
viewFilter: FilterGroup | undefined,
groupByPropertyId: string,
columnKey: string,
): FilterNode | undefined {
const condition = columnKey === NO_VALUE_CHOICE_ID
? { propertyId: groupByPropertyId, op: "isEmpty" as const }
: { propertyId: groupByPropertyId, op: "eq" as const, value: columnKey };
const children: FilterGroup["children"] = viewFilter?.children?.length
? [viewFilter, condition]
: [condition];
return normalizeFilter({ op: "and", children });
}

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