Compare commits

...

157 Commits

Author SHA1 Message Date
Philipinho 123771e841 ms 2026-04-23 19:40:27 +01:00
Philipinho 8c21675a75 fix(base): update base.module import to renamed QueryCacheModule 2026-04-23 16:59:28 +01:00
Philipinho 02a78b2ec7 test(base): wire integration + parity specs to duckdb runtime 2026-04-23 16:58:16 +01:00
Philipinho dbc1eb539c fix(base): serialize writer operations and prune dead code in cache service 2026-04-23 16:50:11 +01:00
Philipinho 38cd94b2d7 refactor(base): single duckdb instance with per-base attached databases 2026-04-23 16:40:14 +01:00
Philipinho 4437dcbb62 feat(base): single-instance duckdb runtime with writer + reader pool 2026-04-23 16:23:24 +01:00
Philipinho 568d94be1f feat(base): schema-qualified query builder for single-instance duckdb 2026-04-23 16:19:47 +01:00
Philipinho f12a0675ea feat(base): schema-qualified loader sql for single-instance duckdb 2026-04-23 16:15:42 +01:00
Philipinho 838d8892f0 feat(base): minimal async connection pool for duckdb reader pool 2026-04-23 16:10:32 +01:00
Philipinho 08711791d6 feat(base): add baseSchemaName helper for duckdb schema naming 2026-04-23 16:05:45 +01:00
Philipinho b04bcb5b0c feat(base): env var for duckdb reader-pool size 2026-04-23 15:52:35 +01:00
Philipinho 709d927544 fix(base): declare primary key on loaded rows so upsert has a conflict target 2026-04-23 14:31:26 +01:00
Philipinho 5b96dfe6c9 feat(base): log duckdb heap + spill per base on cold load 2026-04-23 14:07:36 +01:00
Philipinho 17db634029 fix(base): enable duckdb disk spill + raise memory default to avoid oom on large bases 2026-04-23 13:56:31 +01:00
Philipinho 5ebab5cd9e fix(base): make cell-extractor pg functions genuinely parallel-safe
The plpgsql + EXCEPTION versions of base_cell_numeric,
base_cell_timestamptz, and base_cell_bool were labeled PARALLEL SAFE
but EXCEPTION blocks require subtransactions, which Postgres cannot
start in a parallel worker. Any parallel scan that invoked them
crashed with 'cannot start subtransactions during a parallel
operation' — notably DuckDB's postgres extension on large base COPY
reads.

Rewrite each as a pure SQL function using jsonb_typeof + regex
validation for the 'coerce-or-null' semantics. No plpgsql, no
subtransactions, genuinely parallel-safe. Signatures unchanged so
existing call sites (loader, expression indexes, engine predicates)
are untouched.
2026-04-23 13:52:20 +01:00
Philipinho 2d9e060d9e feat(base): add BASE_QUERY_CACHE_TRACE flag for duckdb operation logging 2026-04-23 13:37:25 +01:00
Philipinho b2ed8f9936 Revert "refactor(base): use uuid package instead of inlined uuid7 in tests"
This reverts commit f819f633c9.
2026-04-23 13:14:09 +01:00
Philipinho 7192b4bacb Revert "refactor(base): use uuid package validator in loader-sql"
This reverts commit cfc50b7cae.
2026-04-23 13:14:09 +01:00
Philipinho cfc50b7cae refactor(base): use uuid package validator in loader-sql 2026-04-23 13:09:25 +01:00
Philipinho f819f633c9 refactor(base): use uuid package instead of inlined uuid7 in tests 2026-04-23 13:07:19 +01:00
Philipinho db1b1464e2 test(base): assert pure-postgres path when query cache is disabled 2026-04-23 12:50:59 +01:00
Philipinho cc47a6d65c refactor(base): drop prepared binding now that loader sql inlines uuids 2026-04-23 12:39:33 +01:00
Philipinho 378d17350c fix(base): use postgres_query to invoke pg-side udfs from duckdb loader 2026-04-23 12:39:30 +01:00
Philipinho eea989260a test(base): filter/sort parity matrix against postgres
Integration spec that seeds a 10K-row base with diverse property shapes
(text, number, date, checkbox, select, multi-select) and runs an
exhaustive matrix of filter/sort combinations against both the DuckDB
cache path and the Postgres-direct path. Asserts identical row ids and,
where semantics allow, identical cursor strings and pagination meta.

The suite is gated by INTEGRATION_DB_URL and skips cleanly without it.
34 tests total: 26 flat filter ops (text/number/date/checkbox/select/
multi-select), 4 nested boolean trees (AND/OR/mixed/max-depth), 3
multi-key sorts, and one full filter+sort+pagination walk.

Seed tuning to keep both engines in lock-step:
  * digit-only row positions so PG default collation and DuckDB
    bytewise collation agree on the tail tiebreak.
  * lowercase name pool so mixed-case locale/bytewise divergence
    doesn't surface on text-secondary sort.
  * priority is non-NULL to avoid the PG keyset stall when a boundary
    cursor encodes the '+/-Infinity' numeric sentinel (postgres.js
    parses it as NaN, which applyCursor re-emits as null).
2026-04-23 05:00:17 +01:00
Philipinho fc08cffd37 test(server): init LRU test module so pg extension bootstraps 2026-04-23 04:40:30 +01:00
Philipinho fde0ccb3c7 refactor(base): replace streaming loader with pg-extension CREATE TABLE AS SELECT 2026-04-23 04:28:25 +01:00
Philipinho e663d7eecf test(server): align integration stubs with new config + pg-extension injection 2026-04-23 04:28:21 +01:00
Philipinho 96e875f1de test(base): tighten loader-sql mapping assertions to full projections 2026-04-23 03:37:23 +01:00
Philipinho 6544ff6d38 feat(base): pure SQL builder for pg-extension loader 2026-04-23 03:31:00 +01:00
Philipinho 7ca712c9ab fix(base): propagate pg-extension bootstrap failure reason; align closeSync style 2026-04-23 03:26:41 +01:00
Philipinho a798397af0 feat(base): postgres extension service with bootstrap install + per-connection attach 2026-04-23 03:17:36 +01:00
Philipinho 9ba6459427 feat(base): env vars for per-instance duckdb memory limit + threads 2026-04-23 03:09:58 +01:00
Philipinho 14827ec6a0 test(server): add getBaseQueryCacheDebug to integration test env stubs 2026-04-19 23:41:06 +01:00
Philipinho c931fa5ec9 perf(server): skip per-request row count when collection is resident 2026-04-19 23:39:27 +01:00
Philipinho 7e07d77510 chore(server): add per-request perf logs for base query cache diagnostics 2026-04-19 22:44:39 +01:00
Philipinho 02c3bdf028 docs(base): add implementation plan for duckdb query cache 2026-04-19 22:35:56 +01:00
Philipinho 55feb01249 test(server): assert duckdb cache matches postgres on a 100K-row base 2026-04-19 22:28:07 +01:00
Philipinho 4636af3870 feat(server): warm duckdb collections on boot from redis recent-access set 2026-04-19 22:16:20 +01:00
Philipinho c9adf84260 feat(server): evict least-recently-used duckdb collections when cap exceeded 2026-04-19 22:11:55 +01:00
Philipinho 4f38c61725 fix(server): avoid acquiring redis client when base query cache is disabled 2026-04-19 22:05:56 +01:00
Philipinho df22efb290 feat(server): propagate row mutations to duckdb cache via redis pubsub 2026-04-19 22:00:37 +01:00
Philipinho 7534b44e6e refactor(server): preserve cache-failure stack trace and reuse hasSearch 2026-04-19 21:50:34 +01:00
Philipinho cf6b48cd58 feat(server): route large base list queries through the duckdb cache 2026-04-19 21:46:27 +01:00
Philipinho 45000bbd8b fix(server): close duckdb resources on load failure, dedupe concurrent loads, drop unused cells projection 2026-04-19 21:39:05 +01:00
Philipinho 91ad3de258 feat(server): load bases into DuckDB and serve list queries from cache
- collection-loader streams base rows via postgres and bulk-inserts into an
  in-memory DuckDB instance using the Appender API, then builds an index on
  each indexable column
- base-query-cache service routes list() calls through the prepared-statement
  path; ensureLoaded does schema-version checks with single-pass LRU eviction
- keyset param-ordering bug in the DuckDB builder fixed: placeholders appear
  head-to-tail but were being pushed tail-to-head, which made DuckDB bind the
  wrong value for each ? and throw Binder Error on typed columns
- base-row repo gains countActiveRows for the router to use in task 6
- seed script split into an importable helper so integration tests can seed a
  10k-row base deterministically without shelling out
- new integration spec compares Postgres vs DuckDB pagination end-to-end for
  a numeric sort and guards against duplicate rows from DuckDB

Integration test is skipped unless INTEGRATION_DB_URL is set.
2026-04-19 21:31:05 +01:00
Philipinho b28597125d fix(server): use DuckDB json_contains for multi-select filters and expand builder coverage 2026-04-19 21:11:29 +01:00
Philipinho a9db3ef008 feat(server): add DuckDB SQL builder for base list queries 2026-04-19 21:06:41 +01:00
Philipinho 574c5316f0 feat(server): scaffold base query-cache module behind feature flag 2026-04-19 20:59:24 +01:00
Philipinho 3af2db7a8b feat(server): add property-type to DuckDB column-spec mapping 2026-04-19 20:54:59 +01:00
Philipinho f181c6d9e8 fix(server): case-insensitive parse for BASE_QUERY_CACHE_ENABLED env var 2026-04-19 20:52:06 +01:00
Philipinho 8ac4c97c98 docs(server): explain base-query-cache max-collections default 2026-04-19 20:50:21 +01:00
Philipinho abd42fd007 chore(server): add duckdb dependency and query-cache env getters 2026-04-19 20:48:16 +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 dba8e315ab override 2026-04-14 17:59:59 +01:00
Philipinho 81ae7a17a6 confirm dialog 2026-04-14 17:56:36 +01:00
Philipinho 271f855761 v0.80.0 2026-04-14 17:08:44 +01:00
Philipinho 3e6d915227 sync 2026-04-14 16:34:44 +01:00
Philip Okugbe a6a7e4370a feat(ee): PDF export api (#2112)
* feat(ee): server side PDF export

* feat: pdf export queue

* sync

* sync
2026-04-14 16:26:54 +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
188 changed files with 30085 additions and 49 deletions
+3
View File
@@ -43,6 +43,9 @@ POSTMARK_TOKEN=
# for custom drawio server # for custom drawio server
DRAWIO_URL= DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
DISABLE_TELEMETRY=false DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false) # Enable debug logging in production (default: false)
+8 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.71.1", "version": "0.80.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -11,6 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@casl/react": "^5.0.1", "@casl/react": "^5.0.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -23,7 +27,9 @@
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.18", "@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0", "@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "5.99.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "1.15.0", "axios": "1.15.0",
"blueimp-load-image": "^5.16.0", "blueimp-load-image": "^5.16.0",
@@ -222,6 +222,8 @@
"Edit comment": "Kommentar bearbeiten", "Edit comment": "Kommentar bearbeiten",
"Delete comment": "Kommentar löschen", "Delete comment": "Kommentar löschen",
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?", "Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
"Delete chat": "Chat löschen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"Comment created successfully": "Kommentar erfolgreich erstellt", "Comment created successfully": "Kommentar erfolgreich erstellt",
"Error creating comment": "Fehler beim Erstellen des Kommentars", "Error creating comment": "Fehler beim Erstellen des Kommentars",
"Comment updated successfully": "Kommentar erfolgreich aktualisiert", "Comment updated successfully": "Kommentar erfolgreich aktualisiert",
@@ -222,6 +222,8 @@
"Edit comment": "Edit comment", "Edit comment": "Edit comment",
"Delete comment": "Delete comment", "Delete comment": "Delete comment",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?", "Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Delete chat": "Delete chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
"Comment created successfully": "Comment created successfully", "Comment created successfully": "Comment created successfully",
"Error creating comment": "Error creating comment", "Error creating comment": "Error creating comment",
"Comment updated successfully": "Comment updated successfully", "Comment updated successfully": "Comment updated successfully",
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentario", "Edit comment": "Editar comentario",
"Delete comment": "Eliminar comentario", "Delete comment": "Eliminar comentario",
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?", "Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
"Delete chat": "Eliminar chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}'? Esta acción no se puede deshacer.",
"Comment created successfully": "Comentario creado con éxito", "Comment created successfully": "Comentario creado con éxito",
"Error creating comment": "Error al crear comentario", "Error creating comment": "Error al crear comentario",
"Comment updated successfully": "Comentario actualizado con éxito", "Comment updated successfully": "Comentario actualizado con éxito",
@@ -222,6 +222,8 @@
"Edit comment": "Modifier le commentaire", "Edit comment": "Modifier le commentaire",
"Delete comment": "Supprimer le commentaire", "Delete comment": "Supprimer le commentaire",
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?", "Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
"Delete chat": "Supprimer la conversation",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer '{{title}}' ? Cette action est irréversible.",
"Comment created successfully": "Commentaire créé avec succès", "Comment created successfully": "Commentaire créé avec succès",
"Error creating comment": "Erreur lors de la création du commentaire", "Error creating comment": "Erreur lors de la création du commentaire",
"Comment updated successfully": "Commentaire mis à jour avec succès", "Comment updated successfully": "Commentaire mis à jour avec succès",
@@ -222,6 +222,8 @@
"Edit comment": "Modifica commento", "Edit comment": "Modifica commento",
"Delete comment": "Elimina commento", "Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?", "Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
"Delete chat": "Elimina chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
"Comment created successfully": "Commento creato con successo", "Comment created successfully": "Commento creato con successo",
"Error creating comment": "Si è verificato un errore durante la creazione del commento", "Error creating comment": "Si è verificato un errore durante la creazione del commento",
"Comment updated successfully": "Commento aggiornato con successo", "Comment updated successfully": "Commento aggiornato con successo",
@@ -222,6 +222,8 @@
"Edit comment": "コメントを編集する", "Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する", "Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?", "Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Delete chat": "チャットを削除",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "「{{title}}」を削除してもよろしいですか?この操作は元に戻せません。",
"Comment created successfully": "コメントを作成しました", "Comment created successfully": "コメントを作成しました",
"Error creating comment": "コメントの作成に失敗しました", "Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメントを更新しました", "Comment updated successfully": "コメントを更新しました",
@@ -222,6 +222,8 @@
"Edit comment": "댓글 수정", "Edit comment": "댓글 수정",
"Delete comment": "댓글 삭제", "Delete comment": "댓글 삭제",
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?", "Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료", "Comment created successfully": "댓글 생성 완료",
"Error creating comment": "댓글 생성 오류", "Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료", "Comment updated successfully": "댓글 업데이트 완료",
@@ -222,6 +222,8 @@
"Edit comment": "Bewerk reactie", "Edit comment": "Bewerk reactie",
"Delete comment": "Verwijder reactie", "Delete comment": "Verwijder reactie",
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?", "Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
"Delete chat": "Chat verwijderen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Weet je zeker dat je '{{title}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Comment created successfully": "Reactie succesvol aangemaakt", "Comment created successfully": "Reactie succesvol aangemaakt",
"Error creating comment": "Fout bij het aanmaken van reactie", "Error creating comment": "Fout bij het aanmaken van reactie",
"Comment updated successfully": "Opmerking succesvol bijgewerkt", "Comment updated successfully": "Opmerking succesvol bijgewerkt",
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentário", "Edit comment": "Editar comentário",
"Delete comment": "Excluir comentário", "Delete comment": "Excluir comentário",
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?", "Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
"Delete chat": "Excluir chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
"Comment created successfully": "Comentário criado com sucesso", "Comment created successfully": "Comentário criado com sucesso",
"Error creating comment": "Erro ao criar comentário", "Error creating comment": "Erro ao criar comentário",
"Comment updated successfully": "Comentário atualizado com sucesso", "Comment updated successfully": "Comentário atualizado com sucesso",
@@ -222,6 +222,8 @@
"Edit comment": "Редактировать комментарий", "Edit comment": "Редактировать комментарий",
"Delete comment": "Удалить комментарий", "Delete comment": "Удалить комментарий",
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?", "Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
"Delete chat": "Удалить чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите удалить '{{title}}'? Это действие нельзя отменить.",
"Comment created successfully": "Комментарий успешно создан", "Comment created successfully": "Комментарий успешно создан",
"Error creating comment": "Ошибка при создании комментария", "Error creating comment": "Ошибка при создании комментария",
"Comment updated successfully": "Комментарий успешно обновлён", "Comment updated successfully": "Комментарий успешно обновлён",
@@ -222,6 +222,8 @@
"Edit comment": "Редагувати коментар", "Edit comment": "Редагувати коментар",
"Delete comment": "Видалити коментар", "Delete comment": "Видалити коментар",
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?", "Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
"Delete chat": "Видалити чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете видалити '{{title}}'? Цю дію неможливо скасувати.",
"Comment created successfully": "Коментар успішно створено", "Comment created successfully": "Коментар успішно створено",
"Error creating comment": "Помилка при створенні коментаря", "Error creating comment": "Помилка при створенні коментаря",
"Comment updated successfully": "Коментар успішно оновлено", "Comment updated successfully": "Коментар успішно оновлено",
@@ -222,6 +222,8 @@
"Edit comment": "编辑评论", "Edit comment": "编辑评论",
"Delete comment": "删除评论", "Delete comment": "删除评论",
"Are you sure you want to delete this comment?": "你确定要删除这条评论吗?", "Are you sure you want to delete this comment?": "你确定要删除这条评论吗?",
"Delete chat": "删除聊天",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "您确定要删除「{{title}}」吗?此操作无法撤销。",
"Comment created successfully": "成功创建评论", "Comment created successfully": "成功创建评论",
"Error creating comment": "创建评论时出错", "Error creating comment": "创建评论时出错",
"Comment updated successfully": "评论更新成功", "Comment updated successfully": "评论更新成功",
+5
View File
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx"; import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx"; import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx"; import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx";
@@ -37,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/pages/base/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list"; import TemplateList from "@/ee/template/pages/template-list";
@@ -81,6 +83,7 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} /> <Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route> </Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} /> <Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} /> <Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@@ -102,6 +105,8 @@ export default function App() {
element={<Page />} element={<Page />}
/> />
<Route path={"/base/:baseId"} element={<BasePage />} />
<Route path={"/settings"}> <Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} /> <Route path={"account/profile"} element={<AccountSettings />} />
<Route <Route
@@ -9,7 +9,7 @@ import classes from "../styles/chat-sidebar.module.css";
type Props = { type Props = {
chat: AiChat; chat: AiChat;
isActive: boolean; isActive: boolean;
onDelete: (chatId: string) => void; onDelete: (chatId: string, title: string | null) => void;
onRename: (chatId: string, title: string) => void; onRename: (chatId: string, title: string) => void;
}; };
@@ -153,7 +153,7 @@ export default function AiChatSidebarItem({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDelete(chat.id); onDelete(chat.id, chat.title);
}} }}
> >
{t("Delete")} {t("Delete")}
@@ -1,6 +1,14 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core"; import {
ActionIcon,
Center,
Text,
TextInput,
Loader,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react"; import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -73,16 +81,31 @@ export default function AiChatSidebar() {
); );
const handleDelete = useCallback( const handleDelete = useCallback(
(id: string) => { (id: string, title: string | null) => {
deleteMutation.mutate(id, { modals.openConfirmModal({
onSuccess: () => { title: t("Delete chat"),
if (chatId === id) { centered: true,
navigate("/ai"); children: (
} <Text size="sm">
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
title: title || t("Untitled"),
})}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
}, },
}); });
}, },
[deleteMutation, chatId, navigate], [deleteMutation, chatId, navigate, t],
); );
const handleRename = useCallback( const handleRename = useCallback(
@@ -0,0 +1,64 @@
import "@/features/editor/styles/index.css";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
import { Container } from "@mantine/core";
type PdfRenderData = {
pageId: string;
title: string;
content: any;
};
export default function PdfRenderPage() {
const { pageId } = useParams<{ pageId: string }>();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [data, setData] = useState<PdfRenderData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!pageId || !token) {
setError("Missing page ID or token");
return;
}
fetch('/api/pdf-export/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pageId, token }),
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((result) => setData(result.data))
.catch((err) => setError(err.message));
}, [pageId, token]);
useEffect(() => {
if (data?.title) {
document.title = data.title;
}
}, [data?.title]);
if (error) {
return <div>{error}</div>;
}
if (!data) {
return null;
}
return (
<Container size={900} p={0}>
<ReadonlyPageEditor
key={data.pageId}
title={data.title}
content={data.content}
pageId={data.pageId}
/>
</Container>
);
}
@@ -0,0 +1,15 @@
import { atom } from "jotai";
import { EditingCell } from "@/features/base/types/base.types";
export const activeViewIdAtom = atom<string | null>(null);
export const editingCellAtom = atom<EditingCell>(null);
export const activePropertyMenuAtom = atom<string | null>(null);
export const propertyMenuDirtyAtom = atom<boolean>(false);
export const propertyMenuCloseRequestAtom = atom<number>(0);
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
export const lastToggledRowIndexAtom = atom<number | null>(null);
@@ -0,0 +1,84 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const COLUMN_COUNT = 6;
const ROW_COUNT = 10;
// Deterministic per-cell widths so the skeleton doesn't flicker between
// renders. Values are rough normal distribution around 55-85 % of cell.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
export function BaseTableSkeleton() {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { arrayMove } from "@dnd-kit/sortable";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
import {
useBaseRowsQuery,
flattenRows,
} from "@/features/base/queries/base-row-query";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import classes from "@/features/base/styles/grid.module.css";
type BaseTableProps = {
baseId: string;
};
export function BaseTable({ baseId }: BaseTableProps) {
const { t } = useTranslation();
// Subscribe to the base's realtime room so other clients' edits,
// schema changes, and async-job completions reconcile into our cache.
useBaseSocket(baseId);
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
const views = base?.views ?? [];
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const activeFilter = activeView?.config?.filter;
const activeSorts = activeView?.config?.sorts;
// Hold the rows query until `base` has loaded. Otherwise the query
// fires once with `activeFilter` / `activeSorts` still undefined
// (a "bland" list request), then fires a second time as soon as the
// active view's config resolves — doubling network traffic on every
// base open for any view that has sort or filter.
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBaseRowsQuery(base ? baseId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
const { clear: clearSelection } = useRowSelection();
useEffect(() => {
clearSelection();
}, [baseId, activeView?.id, clearSelection]);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
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 { table, persistViewConfig } = useBaseTable(base, rows, activeView);
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
baseId,
cells: { [propertyId]: value },
});
},
[baseId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ baseId });
}, [baseId, createRowMutation]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
baseId,
name: t("New view"),
type: "table",
});
}, [baseId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(activeId: string, overId: string) => {
const currentOrder = table.getState().columnOrder;
const oldIndex = currentOrder.indexOf(activeId);
const newIndex = currentOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
table.setColumnOrder(newOrder);
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.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);
}
reorderRowMutation.mutate({
rowId,
baseId,
position: newPosition,
});
} catch {
// Position computation failed — skip silently
}
},
[rows, baseId, reorderRowMutation],
);
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<BaseToolbar
base={base}
activeView={activeView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
/>
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
baseId={baseId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
/>
</div>
);
}
@@ -0,0 +1,310 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/features/base/types/base.types";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table";
import { exportBaseToCsv } from "@/features/base/services/base-service";
import { ViewTabs } from "@/features/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config";
import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type BaseToolbarProps = {
base: IBase;
activeView: IBaseView | undefined;
views: IBaseView[];
table: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
onPersistViewConfig: () => void;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
onPersistViewConfig,
}: BaseToolbarProps) {
const { t } = useTranslation();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [fieldsOpened, setFieldsOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const toolbarRightRef = useRef<HTMLDivElement>(null);
// Mantine `<Popover>`'s built-in dismiss handlers don't fire reliably
// for the toolbar popovers (same issue that drove the property menu to
// use custom listeners in `grid-container.tsx`). Close any open toolbar
// popover on outside mousedown AND on ESC.
useEffect(() => {
if (!sortOpened && !filterOpened && !fieldsOpened) return;
const closeAll = () => {
setSortOpened(false);
setFilterOpened(false);
setFieldsOpened(false);
};
const mouseHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (toolbarRightRef.current?.contains(target)) return;
// Ignore clicks that land inside any Mantine popover dropdown
// (role=dialog), any Select/Combobox dropdown (role=listbox, the
// container; option elements have role=option), or anything
// rendered into Mantine's shared portal node. Without these, a
// nested Select inside the popover would close the parent.
if (target.closest('[role="dialog"]')) return;
if (target.closest('[role="listbox"]')) return;
if (target.closest('[role="option"]')) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
closeAll();
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") closeAll();
};
const id = setTimeout(() => {
document.addEventListener("mousedown", mouseHandler);
}, 0);
document.addEventListener("keydown", keyHandler);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", mouseHandler);
document.removeEventListener("keydown", keyHandler);
};
}, [sortOpened, filterOpened, fieldsOpened]);
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
const openToolbar = useCallback((panel: "sort" | "filter" | "fields") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setFieldsOpened(panel === "fields" ? (v) => !v : false);
}, []);
const updateViewMutation = useUpdateViewMutation();
const sorts = activeView?.config?.sorts ?? [];
// Stored view config uses the engine's filter tree. The popover edits
// an AND-only flat list; we unwrap the top-level group's children when
// reading and rewrap on save.
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 hiddenFieldCount = useMemo(() => {
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[]) => {
if (!activeView) return;
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: newSorts,
});
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config,
});
},
[activeView, base.id, table, updateViewMutation],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
if (!activeView) return;
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
// `filter: undefined` in overrides removes the filter key; the helper's
// spread-then-overrides order means `undefined` wins over any base filter.
const config = buildViewConfigFromTable(table, activeView.config, {
filter,
});
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config,
});
},
[activeView, base.id, table, updateViewMutation],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
baseId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
/>
<div className={classes.toolbarRight} ref={toolbarRightRef}>
<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"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
<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"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
<ViewFieldVisibility
opened={fieldsOpened}
onClose={() => setFieldsOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide fields")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenFieldCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("fields")}
>
<IconEye size={16} />
{hiddenFieldCount > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{hiddenFieldCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFieldVisibility>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({
value,
onCommit,
}: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
onCommit(!checked);
}, [checked, onCommit]);
return (
<div className={cellClasses.checkboxCell} onClick={handleChange}>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{ input: { cursor: "pointer", pointerEvents: "none" } }}
/>
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,141 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -0,0 +1,90 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellEmail({
value,
isEditing,
onCommit,
onCancel,
}: CellEmailProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
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(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
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}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -0,0 +1,241 @@
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import api from "@/lib/api-client";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
filePath?: string;
};
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
for (const file of Array.from(fileList)) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("baseId", property.baseId);
const res = await api.post<FileValue>(
"/bases/files/upload",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
const attachment = res as unknown as FileValue;
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
filePath: attachment.filePath,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.baseId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div
key={file.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 0",
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
<IconFile
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-gray-6)",
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</div>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
</div>
))}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
<UnstyledButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 0",
marginTop: 4,
fontSize: "var(--mantine-font-size-xs)",
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
</Popover.Dropdown>
</Popover>
);
}
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
}
function FileList({
files,
maxVisible,
}: {
files: FileValue[];
maxVisible: number;
}) {
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}>
<IconPaperclip size={12} />
{file.fileName}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import { Group } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/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 }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const user = useMemo(() => {
if (!userId || !membersData?.items) return null;
return membersData.items.find((u) => u.id === userId) ?? null;
}, [userId, membersData?.items]);
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={user?.name ?? ""}
size={20}
radius="xl"
/>
{user?.name && (
<span
style={{
fontSize: "var(--mantine-font-size-sm)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.name}
</span>
)}
</Group>
);
}
@@ -0,0 +1,260 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellMultiSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedSet = new Set(selectedIds);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices;
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onCommit(newIds);
},
[selectedIds, selectedSet, onCommit],
);
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 = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, 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,
baseId: property.baseId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
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") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
const MAX_VISIBLE = 3;
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = 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)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (selectedChoices.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />;
}
function BadgeList({
choices,
maxVisible,
}: {
choices: Choice[];
maxVisible: number;
}) {
const visible = choices.slice(0, maxVisible);
const overflow = choices.length - maxVisible;
return (
<div className={cellClasses.badgeGroup}>
{visible.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState, useRef, useEffect, useCallback } from "react";
import {
IBaseProperty,
NumberTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision ?? 0;
const format = options?.format ?? "plain";
switch (format) {
case "currency":
return `${options?.currencySymbol ?? "$"}${val.toFixed(precision)}`;
case "percent":
return `${val.toFixed(precision)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
}
}
export function CellNumber({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const numValue = typeof value === "number" ? value : null;
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const [draft, setDraft] = useState(numValue != null ? String(numValue) : "");
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(numValue != null ? String(numValue) : "");
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, numValue]);
const parseDraft = useCallback(() => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
}, [draft]);
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(parseDraft());
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[parseDraft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(parseDraft());
}, [parseDraft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={cellClasses.cellInput}
style={{ textAlign: "right" }}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.numberValue}>
{formatNumber(numValue, typeOptions)}
</span>
);
}
@@ -0,0 +1,263 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
} from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const members = membersData?.items ?? [];
const memberMap = useMemo(() => {
const map = new Map<string, (typeof members)[0]>();
for (const m of members) map.set(m.id, m);
return map;
}, [members]);
const filteredMembers = search
? members.filter(
(m) =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.email && m.email.toLowerCase().includes(search.toLowerCase())),
)
: members;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
if (allowMultiple) {
// Multi mode: toggle add/remove
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
} else {
onCommit([...personIds, memberId]);
}
} else {
// Single mode: replace or clear
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[allowMultiple, personIds, onCommit],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
);
const selectedSet = new Set(personIds);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<PersonReadList personIds={personIds} memberMap={memberMap} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
{/* Tag input area */}
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={member?.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={personIds.length === 0 ? "Search for a person..." : ""}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
{/* Dropdown */}
<div className={cellClasses.personDropdownDivider} />
{allowMultiple && (
<div className={cellClasses.personDropdownHint}>
Select as many as you like
</div>
)}
<div className={cellClasses.selectDropdown}>
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
return (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
</div>
);
})}
{filteredMembers.length === 0 && (
<div className={cellClasses.personDropdownHint}>
No members found
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <PersonReadList personIds={personIds} memberMap={memberMap} />;
}
function PersonReadList({
personIds,
memberMap,
}: {
personIds: string[];
memberMap: Map<
string,
{ id: string; name: string; email?: string; avatarUrl?: string }
>;
}) {
return (
<div className={cellClasses.personGroup}>
{personIds.map((id) => {
const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 8);
return (
<div key={id} className={cellClasses.personRow}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<span className={cellClasses.personName}>{name}</span>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,242 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
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 = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, 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,
baseId: property.baseId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit(newChoice.id);
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
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") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
type CellStatusProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type CategoryGroup = {
label: string;
choices: Choice[];
};
const categoryLabels: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
export function CellStatus({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellStatusProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const groups = useMemo(() => {
const filtered = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const grouped: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(choice);
}
const result: CategoryGroup[] = [];
for (const key of ["todo", "inProgress", "complete"]) {
if (grouped[key]?.length) {
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
}
}
return result;
}, [choices, search]);
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 { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(flatChoices.length, [search, isEditing]);
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[activeIndex]);
}
},
[onCancel, handleNavKey, activeIndex, flatChoices, handleSelect],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label}>
<div className={cellClasses.selectCategoryLabel}>
{group.label}
</div>
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -0,0 +1,82 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import gridClasses from "@/features/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellText({
value,
isEditing,
onCommit,
onCancel,
}: CellTextProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
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(draft);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={gridClasses.cellContent}>{displayValue}</span>;
}
@@ -0,0 +1,92 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellUrl({
value,
isEditing,
onCommit,
onCancel,
}: CellUrlProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
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(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
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}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.urlLink}
href={displayValue}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -0,0 +1,25 @@
import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1971c2", textDark: "#74c0fc" },
cyan: { bg: "#c3fae8", bgDark: "#1a3a3a", text: "#0c8599", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#087f5b", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#2b8a3e", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#5c940d", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#e67700", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#d9480f", textDark: "#ffa94d" },
};
export function choiceColor(color: string): CSSProperties {
const c = colorMap[color] ?? colorMap.gray;
return {
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
color: `light-dark(${c.text}, ${c.textDark})`,
};
}
@@ -0,0 +1,26 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type AddRowButtonProps = {
onClick?: () => void;
};
export const AddRowButton = memo(function AddRowButton({
onClick,
}: AddRowButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addRowButton}
onClick={onClick}
role="button"
tabIndex={0}
>
<IconPlus size={14} />
<span>{t("New row")}</span>
</div>
);
});
@@ -0,0 +1,149 @@
import { memo, useCallback } from "react";
import { Cell } from "@tanstack/react-table";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import { CellText } from "@/features/base/components/cells/cell-text";
import { CellNumber } from "@/features/base/components/cells/cell-number";
import { CellSelect } from "@/features/base/components/cells/cell-select";
import { CellStatus } from "@/features/base/components/cells/cell-status";
import { CellMultiSelect } from "@/features/base/components/cells/cell-multi-select";
import { CellDate } from "@/features/base/components/cells/cell-date";
import { CellCheckbox } from "@/features/base/components/cells/cell-checkbox";
import { CellUrl } from "@/features/base/components/cells/cell-url";
import { CellEmail } from "@/features/base/components/cells/cell-email";
import { CellPerson } from "@/features/base/components/cells/cell-person";
import { CellFile } from "@/features/base/components/cells/cell-file";
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/features/base/styles/grid.module.css";
type CellComponentProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const cellComponents: Record<
string,
React.ComponentType<CellComponentProps>
> = {
text: CellText,
number: CellNumber,
select: CellSelect,
status: CellStatus,
multiSelect: CellMultiSelect,
date: CellDate,
checkbox: CellCheckbox,
url: CellUrl,
email: CellEmail,
person: CellPerson,
file: CellFile,
createdAt: CellCreatedAt,
lastEditedAt: CellLastEditedAt,
lastEditedBy: CellLastEditedBy,
};
type RowDragProps = {
draggable: boolean;
onDragStart: (e: React.DragEvent) => void;
};
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
rowDragProps?: RowDragProps;
orderedRowIds?: string[];
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
onCellUpdate,
rowDragProps,
orderedRowIds,
}: 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(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id;
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, setEditingCell]);
const handleCommit = useCallback(
(value: unknown) => {
if (!property) return;
const currentValue = cell.getValue();
const hasChanged = value !== currentValue
&& !(value === "" && (currentValue === null || currentValue === undefined))
&& !(value === null && (currentValue === null || currentValue === undefined));
if (hasChanged) {
onCellUpdate(rowId, property.id, value);
}
setEditingCell(null);
},
[property, rowId, cell, onCellUpdate, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
orderedRowIds={orderedRowIds ?? []}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
rowDragProps={rowDragProps}
/>
);
}
if (!property) return null;
const CellComponent = cellComponents[property.type];
if (!CellComponent) return null;
const value = cell.getValue();
return (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
}}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
onCommit={handleCommit}
onCancel={handleCancel}
/>
</div>
);
});
@@ -0,0 +1,306 @@
import { useRef, useMemo, useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { SelectionActionBar } from "./selection-action-bar";
import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36;
const OVERSCAN = 10;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
baseId?: string;
onColumnReorder?: (columnId: string, overColumnId: string) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
baseId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
}: GridContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
propertyMenuDirtyRef.current = propertyMenuDirty;
const closeRequestCounterRef = useRef(0);
const { selectionCount, clear: clearSelection } = useRowSelection();
const { deleteSelected } = useDeleteSelectedRows(baseId ?? "");
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
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;
if (propertyMenuDirtyRef.current) {
closeRequestCounterRef.current += 1;
setCloseRequest(closeRequestCounterRef.current);
} else {
setActivePropertyMenu(null);
}
setEditingCell(null);
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
useColumnResize(table, onResizeEnd ?? (() => {}));
useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef: scrollRef,
});
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
});
const virtualItems = virtualizer.getVirtualItems();
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 underlying row set shrinks (filter changed, sort toggled,
// view switched) or resets to zero, we're on a fresh pagination
// sequence — un-gate the trigger so the first page triggers a
// potential next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !baseId) return;
const handler = (e: KeyboardEvent) => {
if (editingCell) return;
const active = document.activeElement as HTMLElement | null;
if (!active || !el.contains(active)) return;
const tag = active.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
return;
}
if (e.key === "Escape" && selectionCount > 0) {
clearSelection();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
e.preventDefault();
void deleteSelected();
}
};
el.addEventListener("keydown", handler);
return () => el.removeEventListener("keydown", handler);
}, [editingCell, selectionCount, clearSelection, deleteSelected, baseId]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return columnWidths.join(" ") + (baseId ? " 40px" : "");
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
const totalHeight = virtualizer.getTotalSize();
const paddingTop =
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
const paddingBottom =
virtualItems.length > 0
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
: 0;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, position: "above" | "below") => {
onRowReorder?.(rowId, targetRowId, position);
},
[onRowReorder],
);
const {
dragState: rowDragState,
handleDragStart: handleRowDragStart,
handleDragOver: handleRowDragOver,
handleDragEnd: handleRowDragEnd,
handleDragLeave: handleRowDragLeave,
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor),
);
const sortableColumnIds = useMemo(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table, table.getState().columnOrder]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onColumnReorder?.(active.id as string, over.id as string);
},
[onColumnReorder],
);
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<div
className={classes.gridWrapper}
ref={scrollRef}
tabIndex={0}
>
<div
className={classes.grid}
style={{ gridTemplateColumns }}
role="grid"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
<GridHeader
table={table}
baseId={baseId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
{paddingTop > 0 && (
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
)}
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
onCellUpdate={onCellUpdate}
orderedRowIds={rowIds}
columnVisibility={table.getState().columnVisibility}
dragHandlers={
onRowReorder
? {
onDragStart: handleRowDragStart,
onDragOver: handleRowDragOver,
onDragEnd: handleRowDragEnd,
onDragLeave: handleRowDragLeave,
isDragging: rowDragState.dragRowId === row.id,
isDropTarget: rowDragState.dropTargetRowId === row.id,
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
}
: undefined
}
/>
);
})}
{paddingBottom > 0 && (
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
)}
<AddRowButton onClick={handleAddRow} />
{baseId && <SelectionActionBar baseId={baseId} />}
</div>
</div>
</DndContext>
);
}
@@ -0,0 +1,214 @@
import { memo, useCallback, useEffect, useRef } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Popover } from "@mantine/core";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
} from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
const typeIcons: Record<string, typeof IconLetterT> = {
text: IconLetterT,
number: IconHash,
select: IconCircleDot,
status: IconProgressCheck,
multiSelect: IconTags,
date: IconCalendar,
person: IconUser,
file: IconPaperclip,
checkbox: IconCheckbox,
url: IconLink,
email: IconMail,
createdAt: IconClockPlus,
lastEditedAt: IconClockEdit,
lastEditedBy: IconUserEdit,
};
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
}: GridHeaderCellProps) {
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection();
const hasSelection = selectionCount > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || isPinned === "left";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: header.column.id,
disabled: isSortableDisabled,
});
const combinedRef = useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node);
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[setNodeRef],
);
const handleHeaderClick = useCallback(() => {
setEditingCell(null);
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
// Mantine's built-in `closeOnEscape` only fires when focus is inside the
// dropdown, but opening the property menu (clicking the header) leaves
// focus on the header itself. Mirror the click-outside path: when dirty,
// bump `propertyMenuCloseRequestAtom` so property-menu shows its
// "Unsaved changes" confirmation panel; otherwise close directly.
useEffect(() => {
if (!menuOpened) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (propertyMenuDirty) {
setCloseRequest(closeRequest + 1);
} else {
handleMenuClose();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [menuOpened, propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose]);
const TypeIcon = property ? typeIcons[property.type] : undefined;
const sortableStyle = transform
? {
transform: CSS.Transform.toString({
...transform,
scaleX: 1,
scaleY: 1,
}),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}
: {};
return (
<div
ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle,
}}
onClick={handleHeaderClick}
{...(isSortableDisabled ? {} : attributes)}
{...(isSortableDisabled ? {} : listeners)}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
)}
{header.column.getCanResize() && (
<div
className={`${classes.resizeHandle} ${
header.column.getIsResizing() ? classes.resizeHandleActive : ""
}`}
onMouseDown={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
)}
{property && !isRowNumber && (
<Popover
opened={menuOpened}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
>
<Popover.Target>
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
);
});
@@ -0,0 +1,56 @@
import { memo, useMemo } from "react";
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
import classes from "@/features/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
baseId?: string;
// Passed explicitly to break memo when columns change
// (table ref is stable from useReactTable, so memo won't fire without these)
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
properties: IBaseProperty[];
loadedRowIds: string[];
onPropertyCreated?: () => void;
};
export const GridHeader = memo(function GridHeader({
table,
baseId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnOrder: _columnOrder,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnVisibility: _columnVisibility,
properties,
loadedRowIds,
onPropertyCreated,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
const propertyById = useMemo(() => {
const map = new Map<string, IBaseProperty>();
for (const p of properties) map.set(p.id, p);
return map;
}, [properties]);
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell
key={header.id}
header={header}
property={propertyById.get(header.column.id)}
loadedRowIds={loadedRowIds}
/>
))}
{baseId && (
<CreatePropertyPopover
baseId={baseId}
onPropertyCreated={onPropertyCreated}
/>
)}
</div>
);
});
@@ -0,0 +1,92 @@
import { memo, useCallback } from "react";
import { Row, VisibilityState } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { GridCell } from "./grid-cell";
import classes from "@/features/base/styles/grid.module.css";
type RowDragHandlers = {
onDragStart: (rowId: string) => void;
onDragOver: (rowId: string, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragLeave: () => void;
isDragging: boolean;
isDropTarget: boolean;
dropPosition: "above" | "below" | null;
};
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
dragHandlers?: RowDragHandlers;
orderedRowIds: string[];
columnVisibility: VisibilityState;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
onCellUpdate,
dragHandlers,
orderedRowIds,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnVisibility: _columnVisibility,
}: GridRowProps) {
const isSelected = useRowSelection().isSelected(row.id);
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", row.id);
dragHandlers?.onDragStart(row.id);
},
[row.id, dragHandlers],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
dragHandlers?.onDragOver(row.id, e);
},
[row.id, dragHandlers],
);
const dropIndicatorClass = dragHandlers?.isDropTarget
? dragHandlers.dropPosition === "above"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
className={`${classes.row} ${dragHandlers?.isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
onDragOver={handleDragOver}
onDrop={(e) => {
e.preventDefault();
dragHandlers?.onDragEnd();
}}
onDragLeave={dragHandlers?.onDragLeave}
>
{row.getVisibleCells().map((cell) => {
const isRowNumber = cell.column.id === "__row_number";
return (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
onCellUpdate={onCellUpdate}
orderedRowIds={orderedRowIds}
rowDragProps={
isRowNumber && dragHandlers
? {
draggable: true,
onDragStart: handleDragStart,
}
: undefined
}
/>
);
})}
</div>
);
});
@@ -0,0 +1,70 @@
import { memo, useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
type RowDragProps = {
draggable: boolean;
onDragStart: (e: React.DragEvent) => void;
};
type RowNumberCellProps = {
rowId: string;
rowIndex: number;
orderedRowIds: string[];
isPinned: boolean;
pinOffset?: number;
rowDragProps?: RowDragProps;
};
export const RowNumberCell = memo(function RowNumberCell({
rowId,
rowIndex,
orderedRowIds,
isPinned,
pinOffset,
rowDragProps,
}: RowNumberCellProps) {
const { isSelected, toggle } = useRowSelection();
const selected = isSelected(rowId);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds,
});
},
[rowId, rowIndex, orderedRowIds, toggle],
);
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
style={isPinned ? { left: pinOffset } : undefined}
>
<div className={classes.rowNumberCellInner}>
<span
className={classes.rowNumberDragHandle}
draggable={rowDragProps?.draggable}
onDragStart={rowDragProps?.onDragStart}
aria-label="Drag row"
>
<IconGripVertical size={12} />
</span>
<span className={classes.rowNumberCheckbox}>
<Checkbox
size="xs"
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
/>
</span>
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
</div>
</div>
);
});
@@ -0,0 +1,48 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
};
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
loadedRowIds,
}: RowNumberHeaderCellProps) {
const { selectedIds, toggleAll } = useRowSelection();
const { checked, indeterminate } = useMemo(() => {
if (loadedRowIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedInLoaded = loadedRowIds.reduce(
(acc, id) => (selectedIds.has(id) ? acc + 1 : acc),
0,
);
return {
checked: selectedInLoaded === loadedRowIds.length,
indeterminate:
selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length,
};
}, [loadedRowIds, selectedIds]);
if (loadedRowIds.length === 0) return null;
return (
<div className={classes.rowNumberHeaderInner}>
<span className={classes.rowNumberHeaderHash}>#</span>
<span className={classes.rowNumberHeaderCheckbox}>
<Tooltip label="Select all loaded rows" withinPortal>
<Checkbox
size="xs"
checked={checked}
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
/>
</Tooltip>
</span>
</div>
);
});
@@ -0,0 +1,52 @@
import { memo } from "react";
import { Transition } from "@mantine/core";
import { IconTrash, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import classes from "@/features/base/styles/grid.module.css";
type SelectionActionBarProps = {
baseId: string;
};
export const SelectionActionBar = memo(function SelectionActionBar({
baseId,
}: SelectionActionBarProps) {
const { t } = useTranslation();
const { selectionCount, clear } = useRowSelection();
const { deleteSelected, isPending } = useDeleteSelectedRows(baseId);
const isOpen = selectionCount > 0;
return (
<Transition mounted={isOpen} transition="slide-up" duration={150}>
{(styles) => (
<div className={classes.selectionActionBarWrapper} style={styles}>
<div className={classes.selectionActionBar} role="toolbar">
<span className={classes.selectionActionBarCount}>
{t("{{count}} selected", { count: selectionCount })}
</span>
<button
type="button"
className={classes.selectionActionBarDelete}
disabled={isPending}
onClick={() => void deleteSelected()}
>
<IconTrash size={14} />
{t("Delete")}
</button>
<button
type="button"
className={classes.selectionActionBarClose}
onClick={clear}
aria-label={t("Clear selection")}
>
<IconX size={14} />
</button>
</div>
</div>
)}
</Transition>
);
});
@@ -0,0 +1,547 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import {
TextInput,
Group,
Stack,
Text,
Button,
Popover,
SimpleGrid,
UnstyledButton,
CloseButton,
Divider,
} from "@mantine/core";
import {
IconPlus,
IconGripVertical,
IconArrowsSort,
} from "@tabler/icons-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { Choice } from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORIES = [
{ value: "todo", label: "To Do" },
{ value: "inProgress", label: "In Progress" },
{ value: "complete", label: "Complete" },
] as const;
type ChoiceEditorProps = {
initialChoices: Choice[];
onSave: (choices: Choice[]) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
hideButtons?: boolean;
};
export function ChoiceEditor({
initialChoices,
onSave,
onClose,
onDirtyChange,
showCategories = false,
hideButtons = false,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Choice[]>(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
// Sync from parent only when not in live mode (hideButtons = create flow)
useEffect(() => {
if (!hideButtons) {
setDraft(initialChoices);
}
}, [initialChoices, hideButtons]);
// In live mode, propagate draft changes to parent immediately
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
if (hideButtons) {
onSaveRef.current(draft.filter((c) => c.name.trim()));
}
}, [hideButtons, draft]);
const isDirty = useMemo(() => {
if (draft.length !== initialChoices.length) return true;
return draft.some((d, i) => {
const o = initialChoices[i];
return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
});
}, [draft, initialChoices]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const hasEmptyNames = draft.some((c) => !c.name.trim());
const handleRename = useCallback((choiceId: string, name: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
}, []);
const handleColorChange = useCallback((choiceId: string, color: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
}, []);
const handleRemove = useCallback((choiceId: string) => {
setDraft((prev) => prev.filter((c) => c.id !== choiceId));
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = uuid7();
setDraft((prev) => {
const colorIndex = prev.length % CHOICE_COLORS.length;
const newChoice: Choice = {
id,
name: "",
color: CHOICE_COLORS[colorIndex],
...(category ? { category } : {}),
};
return [...prev, newChoice];
});
setFocusChoiceId(id);
}, []);
const handleAlphabetize = useCallback(() => {
setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
}, []);
const handleSave = useCallback(() => {
const cleaned = draft.filter((c) => c.name.trim());
onSave(cleaned);
onClose();
}, [draft, onSave, onClose]);
const handleCancel = useCallback(() => {
setDraft(initialChoices);
onDirtyChange?.(false);
onClose();
}, [initialChoices, onDirtyChange, onClose]);
const handleReorder = useCallback((activeId: string, overId: string) => {
setDraft((prev) => {
const oldIndex = prev.findIndex((c) => c.id === activeId);
const newIndex = prev.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleCategoryReorder = useCallback(
(category: string, activeId: string, overId: string) => {
setDraft((prev) => {
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
const oldIndex = catChoices.findIndex((c) => c.id === activeId);
const newIndex = catChoices.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
const reordered = arrayMove(catChoices, oldIndex, newIndex);
const result: Choice[] = [];
for (const cat of ["todo", "inProgress", "complete"]) {
if (cat === category) {
result.push(...reordered);
} else {
result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
}
}
return result;
});
},
[],
);
return (
<Stack gap="xs">
<Group justify="space-between">
<Text size="xs" fw={600}>
{t("Options")}
</Text>
<UnstyledButton onClick={handleAlphabetize} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<IconArrowsSort size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Alphabetize")}</Text>
</UnstyledButton>
</Group>
{showCategories ? (
<StatusChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onCategoryReorder={handleCategoryReorder}
/>
) : (
<FlatChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onReorder={handleReorder}
/>
)}
{!hideButtons && (
<>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
{t("Save")}
</Button>
</Group>
</>
)}
</Stack>
);
}
function FlatChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: () => void;
onReorder: (activeId: string, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(active.id as string, over.id as string);
},
[onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{draft.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd()}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function StatusChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onCategoryReorder,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onCategoryReorder: (category: string, activeId: string, overId: string) => void;
}) {
const grouped = useMemo(() => {
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
for (const choice of draft) {
const cat = choice.category ?? "todo";
(groups[cat] ?? groups.todo).push(choice);
}
return groups;
}, [draft]);
return (
<Stack gap="sm">
{STATUS_CATEGORIES.map(({ value: category, label }) => (
<CategorySection
key={category}
category={category as "todo" | "inProgress" | "complete"}
label={label}
choices={grouped[category] ?? []}
focusChoiceId={focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onAdd={onAdd}
onReorder={onCategoryReorder}
/>
))}
</Stack>
);
}
function CategorySection({
category,
label,
choices,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
}: {
category: "todo" | "inProgress" | "complete";
label: string;
choices: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onReorder: (category: string, activeId: string, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(category, active.id as string, over.id as string);
},
[category, onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t(label)}
</Text>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{choices.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd(category)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function SortableChoiceRow({
choice,
autoFocus,
onFocused,
onRename,
onColorChange,
onRemove,
}: {
choice: Choice;
autoFocus?: boolean;
onFocused?: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: choice.id });
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus();
onFocused?.();
}
}, [autoFocus, onFocused]);
const style = {
transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const hasError = !choice.name.trim();
return (
<Group ref={setNodeRef} style={style} gap={6} wrap="nowrap" align="center">
<div
{...attributes}
{...listeners}
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<ColorDot color={choice.color} onChange={(c) => onColorChange(choice.id, c)} />
<TextInput
ref={inputRef}
size="xs"
value={choice.name}
onChange={(e) => onRename(choice.id, e.currentTarget.value)}
style={{ flex: 1 }}
error={hasError}
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
/>
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
</Group>
);
}
function ColorDot({
color,
onChange,
}: {
color: string;
onChange: (color: string) => void;
}) {
const [opened, setOpened] = useState(false);
const colors = choiceColor(color);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" shadow="sm" withinPortal>
<Popover.Target>
<UnstyledButton
onClick={() => setOpened((o) => !o)}
style={{
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
</Popover.Target>
<Popover.Dropdown p={8}>
<SimpleGrid cols={5} spacing={6}>
{CHOICE_COLORS.map((c) => {
const dotColors = choiceColor(c);
return (
<UnstyledButton
key={c}
onClick={() => {
onChange(c);
setOpened(false);
}}
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: dotColors.backgroundColor as string,
border: c === color
? `2px solid ${dotColors.color as string}`
: "2px solid transparent",
}}
/>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,316 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Popover,
Portal,
TextInput,
Button,
Group,
Stack,
Divider,
UnstyledButton,
Text,
ScrollArea,
} from "@mantine/core";
import { IconPlus, IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
BasePropertyType,
IBaseProperty,
TypeOptions,
} from "@/features/base/types/base.types";
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import classes from "@/features/base/styles/grid.module.css";
type CreatePropertyPopoverProps = {
baseId: string;
onPropertyCreated?: () => void;
};
type Panel = "typePicker" | "configure" | "confirmDiscard";
const noop = () => {};
// Keep in sync with the switch cases in PropertyOptions
const typesWithOptions = new Set<BasePropertyType>([
"select",
"multiSelect",
"status",
"number",
"date",
"person",
]);
export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("typePicker");
const [selectedType, setSelectedType] = useState<BasePropertyType | null>(null);
const [name, setName] = useState("");
const [typeOptions, setTypeOptions] = useState<Record<string, unknown>>({});
const nameInputRef = useRef<HTMLInputElement>(null);
const createPropertyMutation = useCreatePropertyMutation();
const selectedTypeDef = useMemo(
() => propertyTypes.find((pt) => pt.type === selectedType),
[selectedType],
);
const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
const selectedTypeIcon = selectedTypeDef?.icon;
const hasContent = useMemo(() => {
return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
}, [name, typeOptions]);
const resetState = useCallback(() => {
setPanel("typePicker");
setSelectedType(null);
setName("");
setTypeOptions({});
}, []);
const handleOpen = useCallback(() => {
resetState();
setOpened(true);
}, [resetState]);
const handleClose = useCallback(() => {
setOpened(false);
resetState();
}, [resetState]);
const attemptClose = useCallback(() => {
if (panel === "configure" && hasContent) {
setPanel("confirmDiscard");
} else {
handleClose();
}
}, [panel, hasContent, handleClose]);
const handleConfirmDiscard = useCallback(() => {
handleClose();
}, [handleClose]);
const handleCancelDiscard = useCallback(() => {
setPanel("configure");
}, []);
const handleTypeSelect = useCallback((type: BasePropertyType) => {
setSelectedType(type);
setTypeOptions({});
setPanel("configure");
}, []);
useEffect(() => {
if (panel === "configure") {
setTimeout(() => nameInputRef.current?.focus(), 0);
}
}, [panel]);
const handleCreate = useCallback(() => {
if (!selectedType) return;
const finalName = name.trim() || selectedTypeLabel;
createPropertyMutation.mutate(
{
baseId,
name: finalName,
type: selectedType,
typeOptions: Object.keys(typeOptions).length > 0
? typeOptions as TypeOptions
: undefined,
},
{
onSuccess: () => {
onPropertyCreated?.();
},
},
);
handleClose();
}, [selectedType, name, selectedTypeLabel, typeOptions, baseId, createPropertyMutation, handleClose, onPropertyCreated]);
const handleBackToTypePicker = useCallback(() => {
setPanel("typePicker");
setTypeOptions({});
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (panel === "confirmDiscard") {
handleCancelDiscard();
} else if (panel === "configure") {
handleBackToTypePicker();
} else {
handleClose();
}
}
},
[panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
);
const handleNameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
}
},
[handleCreate],
);
const handleOptionsUpdate = useCallback(
(newTypeOptions: Record<string, unknown>) => {
setTypeOptions(newTypeOptions);
},
[],
);
const syntheticProperty: IBaseProperty = useMemo(() => ({
id: "",
baseId,
name: name || "",
type: selectedType ?? "text",
position: "",
typeOptions: typeOptions as TypeOptions,
isPrimary: false,
workspaceId: "",
createdAt: "",
updatedAt: "",
}), [baseId, name, selectedType, typeOptions]);
const TypeIcon = selectedTypeIcon;
const showOptions = selectedType && typesWithOptions.has(selectedType);
return (
<>
{opened && (
<Portal>
<div
style={{
position: "fixed",
inset: 0,
zIndex: 299,
}}
onClick={attemptClose}
/>
</Portal>
)}
<Popover
opened={opened}
onClose={noop}
position="bottom-start"
shadow="md"
width={320}
withinPortal
>
<Popover.Target>
<div
className={classes.addColumnButton}
onClick={handleOpen}
role="button"
tabIndex={0}
>
<IconPlus size={16} />
</div>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
style={{ zIndex: 300 }}
>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<PropertyTypePicker
onSelect={handleTypeSelect}
showSearch
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && (
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<TextInput
ref={nameInputRef}
size="xs"
placeholder={selectedTypeLabel}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={handleNameKeyDown}
mb="xs"
/>
<UnstyledButton
onClick={handleBackToTypePicker}
py={6}
px={0}
mb={showOptions ? "xs" : 0}
>
<Group gap={8} wrap="nowrap">
{TypeIcon && <TypeIcon size={14} />}
<Text size="sm" style={{ flex: 1 }}>
{selectedTypeLabel}
</Text>
<IconChevronRight size={14} />
</Group>
</UnstyledButton>
{showOptions && (
<>
<Divider mb="xs" />
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
<PropertyOptions
property={syntheticProperty}
onUpdate={handleOptionsUpdate}
onClose={noop}
onDirtyChange={noop}
hideButtons
/>
</ScrollArea.Autosize>
</>
)}
<Divider my="xs" />
<Group gap="xs" justify="flex-end">
<Button variant="default" size="xs" onClick={attemptClose}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleCreate}>
{t("Create field")}
</Button>
</Group>
</Stack>
)}
{panel === "confirmDiscard" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Unsaved changes")}
</Text>
<Text size="xs" c="dimmed">
{t("You have unsaved changes. Do you want to discard them?")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={handleCancelDiscard}
>
{t("Keep editing")}
</Button>
<Button
color="red"
size="xs"
onClick={handleConfirmDiscard}
>
{t("Discard")}
</Button>
</Group>
</Stack>
)}
</Popover.Dropdown>
</Popover>
</>
);
}
@@ -0,0 +1,416 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
UnstyledButton,
TextInput,
Button,
Stack,
Text,
Group,
ActionIcon,
Divider,
ScrollArea,
} from "@mantine/core";
import {
IconTrash,
IconPencil,
IconChevronRight,
IconSettings,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/features/base/queries/base-property-query";
import { propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import cellClasses from "@/features/base/styles/cells.module.css";
type PropertyMenuContentProps = {
property: IBaseProperty;
opened: boolean;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
};
type MenuPanel = "main" | "rename" | "options" | "confirmDelete" | "confirmDiscard";
export function PropertyMenuContent({
property,
opened,
onClose,
onDirtyChange,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>("main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
const pendingActionRef = useRef<"back" | "close" | null>(null);
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
const closeRequestRef = useRef(closeRequest);
const renameDirty = renameValue !== property.name;
const updatePropertyMutation = useUpdatePropertyMutation();
const deletePropertyMutation = useDeletePropertyMutation();
useEffect(() => {
if (opened) {
setPanel("main");
setRenameValue(property.name);
setOptionsDirty(false);
}
}, [opened, property.name]);
useEffect(() => {
if (panel === "rename") {
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [panel]);
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
setOptionsDirty(dirty);
}, []);
// Single dirty signal to the outside — reflects whichever panel is
// currently accumulating unsaved work. Keeps rename and options in
// lockstep with the `propertyMenuDirtyAtom` so the grid-container's
// outside-click handler and the header's ESC handler both prompt
// "Unsaved changes" consistently.
useEffect(() => {
const dirty =
(panel === "rename" && renameDirty) ||
(panel === "options" && optionsDirty);
onDirtyChange?.(dirty);
}, [panel, renameDirty, optionsDirty, onDirtyChange]);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== property.name) {
updatePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
name: trimmed,
});
}
}, [renameValue, property, updatePropertyMutation]);
const handleRenameAndClose = useCallback(() => {
commitRename();
onClose();
}, [commitRename, onClose]);
const requestClose = useCallback(() => {
if (panel === "rename" && renameDirty) {
sourcePanelRef.current = "rename";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else if (panel === "options" && optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else {
onClose();
}
}, [panel, renameDirty, optionsDirty, onClose]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
handleRenameAndClose();
}
if (e.key === "Escape") {
e.preventDefault();
requestClose();
}
},
[handleRenameAndClose, requestClose],
);
const handleOptionsUpdate = useCallback(
(typeOptions: Record<string, unknown>) => {
updatePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
typeOptions,
});
setOptionsDirty(false);
},
[property, updatePropertyMutation],
);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
});
onClose();
}, [property, deletePropertyMutation, onClose]);
const handleOptionsBack = useCallback(() => {
if (optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "back";
setPanel("confirmDiscard");
} else {
setPanel("main");
}
}, [optionsDirty]);
useEffect(() => {
if (closeRequest !== closeRequestRef.current) {
closeRequestRef.current = closeRequest;
if (opened) {
requestClose();
}
}
}, [closeRequest, opened, requestClose]);
const handleConfirmDiscard = useCallback(() => {
setOptionsDirty(false);
setRenameValue(property.name);
const action = pendingActionRef.current;
pendingActionRef.current = null;
sourcePanelRef.current = null;
if (action === "back") {
setPanel("main");
} else {
onClose();
}
}, [property.name, onClose]);
const handleCancelDiscard = useCallback(() => {
const source = sourcePanelRef.current ?? "options";
pendingActionRef.current = null;
sourcePanelRef.current = null;
setPanel(source);
}, []);
return (
<>
{panel === "main" && (
<MainPanel
property={property}
onRename={() => setPanel("rename")}
onOptions={() => setPanel("options")}
onDelete={() => setPanel("confirmDelete")}
/>
)}
{panel === "rename" && (
<Stack gap="xs" p="sm">
<Text size="xs" fw={600} c="dimmed">
{t("Rename property")}
</Text>
<TextInput
ref={renameInputRef}
size="xs"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={handleRenameKeyDown}
/>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={requestClose}>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={handleRenameAndClose}
disabled={!renameValue.trim() || renameValue.trim() === property.name}
>
{t("Save")}
</Button>
</Group>
</Stack>
)}
{(panel === "options" || panel === "confirmDiscard") && (
<Stack gap="xs" p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={handleOptionsBack}
>
<IconChevronRight
size={14}
style={{ transform: "rotate(180deg)" }}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Property options")}
</Text>
</Group>
<ScrollArea.Autosize mah={400} scrollbarSize={6} offsetScrollbars>
<PropertyOptions
property={property}
onUpdate={handleOptionsUpdate}
onClose={onClose}
onDirtyChange={handleOptionsDirtyChange}
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "confirmDelete" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Delete property")}
</Text>
<Text size="xs" c="dimmed">
{t("Are you sure you want to delete")} <b>{property.name}</b>?{" "}
{t("All data in this column will be lost.")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setPanel("main")}
>
{t("Cancel")}
</Button>
<Button
color="red"
size="xs"
onClick={handleDelete}
>
{t("Delete")}
</Button>
</Group>
</Stack>
)}
{panel === "confirmDiscard" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Unsaved changes")}
</Text>
<Text size="xs" c="dimmed">
{t("You have unsaved changes. Do you want to discard them?")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={handleCancelDiscard}
>
{t("Keep editing")}
</Button>
<Button
color="red"
size="xs"
onClick={handleConfirmDiscard}
>
{t("Discard")}
</Button>
</Group>
</Stack>
)}
</>
);
}
// Expose requestClose for use by parent (grid-header-cell)
PropertyMenuContent.displayName = "PropertyMenuContent";
function MenuItem({
icon,
label,
rightIcon,
color,
onClick,
}: {
icon: React.ReactNode;
label: string;
rightIcon?: React.ReactNode;
color?: string;
onClick: () => void;
}) {
return (
<UnstyledButton
className={cellClasses.menuItem}
onClick={onClick}
style={{ color: color ? `var(--mantine-color-${color}-6)` : undefined }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{icon}
<Text size="sm">{label}</Text>
</Group>
{rightIcon}
</UnstyledButton>
);
}
function MainPanel({
property,
onRename,
onOptions,
onDelete,
}: {
property: IBaseProperty;
onRename: () => void;
onOptions: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isSystem = isSystemPropertyType(property.type);
const hasOptions =
!isSystem &&
(property.type === "select" ||
property.type === "multiSelect" ||
property.type === "status" ||
property.type === "number" ||
property.type === "date");
const typeDef = propertyTypes.find((pt) => pt.type === property.type);
const TypeIcon = typeDef?.icon;
return (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconPencil size={14} />}
label={t("Rename")}
onClick={onRename}
/>
{!isSystem && (
<Stack gap={4} px="sm" py={6}>
<Text size="xs" c="dimmed">{t("Type")}</Text>
<TextInput
size="xs"
value={typeDef ? t(typeDef.labelKey) : property.type}
disabled
leftSection={TypeIcon ? <TypeIcon size={14} /> : null}
readOnly
/>
</Stack>
)}
{hasOptions && (
<MenuItem
icon={<IconSettings size={14} />}
label={t("Options")}
rightIcon={<IconChevronRight size={14} />}
onClick={onOptions}
/>
)}
{!property.isPrimary && (
<>
<Divider my={4} />
<MenuItem
icon={<IconTrash size={14} />}
label={t("Delete property")}
color="red"
onClick={onDelete}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,263 @@
import { useCallback } from "react";
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
PersonTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
};
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange, hideButtons }: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
/>
);
case "person":
return (
<PersonOptions
property={property}
onUpdate={onUpdate}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
hideButtons={hideButtons}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
hideButtons={hideButtons}
/>
);
}
function NumberOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as NumberTypeOptions | undefined;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
allowDeselect={false}
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) =>
onUpdate({ ...property.typeOptions, format: val ?? "plain" })
}
/>
<NumberInput
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options?.precision ?? 0}
onChange={(val) =>
onUpdate({ ...property.typeOptions, precision: val })
}
/>
</Stack>
);
}
function DateOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as DateTypeOptions | undefined;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options?.includeTime ?? false}
onChange={(e) =>
onUpdate({
...property.typeOptions,
includeTime: e.currentTarget.checked,
})
}
/>
{options?.includeTime && (
<Select
size="xs"
label={t("Time format")}
allowDeselect={false}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options?.timeFormat ?? "12h"}
onChange={(val) =>
onUpdate({ ...property.typeOptions, timeFormat: val ?? "12h" })
}
/>
)}
</Stack>
);
}
function PersonOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as PersonTypeOptions | undefined;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Allow multiple people")}
checked={options?.allowMultiple !== false}
onChange={(e) =>
onUpdate({
...property.typeOptions,
allowMultiple: e.currentTarget.checked,
})
}
/>
</Stack>
);
}
@@ -0,0 +1,110 @@
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
IconCheck,
IconSearch,
} from "@tabler/icons-react";
import { BasePropertyType } from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
import { useState, useRef, useEffect } from "react";
import classes from "@/features/base/styles/cells.module.css";
const propertyTypes: {
type: BasePropertyType;
icon: typeof IconLetterT;
labelKey: string;
}[] = [
{ type: "text", icon: IconLetterT, labelKey: "Text" },
{ type: "number", icon: IconHash, labelKey: "Number" },
{ type: "select", icon: IconCircleDot, labelKey: "Select" },
{ type: "status", icon: IconProgressCheck, labelKey: "Status" },
{ type: "multiSelect", icon: IconTags, labelKey: "Multi-select" },
{ type: "date", icon: IconCalendar, labelKey: "Date" },
{ type: "person", icon: IconUser, labelKey: "Person" },
{ type: "file", icon: IconPaperclip, labelKey: "File" },
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
{ type: "url", icon: IconLink, labelKey: "URL" },
{ type: "email", icon: IconMail, labelKey: "Email" },
{ type: "createdAt", icon: IconClockPlus, labelKey: "Created at" },
{ type: "lastEditedAt", icon: IconClockEdit, labelKey: "Last edited at" },
{ type: "lastEditedBy", icon: IconUserEdit, labelKey: "Last edited by" },
];
type PropertyTypePickerProps = {
onSelect: (type: BasePropertyType) => void;
currentType?: BasePropertyType;
excludeTypes?: Set<BasePropertyType>;
showSearch?: boolean;
};
export function PropertyTypePicker({
onSelect,
currentType,
excludeTypes,
showSearch,
}: PropertyTypePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showSearch) {
setTimeout(() => searchRef.current?.focus(), 0);
}
}, [showSearch]);
const types = propertyTypes
.filter(({ type }) => !excludeTypes?.has(type))
.filter(({ labelKey }) =>
!search || t(labelKey).toLowerCase().includes(search.toLowerCase())
);
return (
<>
{showSearch && (
<TextInput
ref={searchRef}
size="xs"
placeholder={t("Find a field type")}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mx="sm"
mt="sm"
mb={4}
/>
)}
{types.map(({ type, icon: Icon, labelKey }) => (
<UnstyledButton
key={type}
className={classes.menuItem}
onClick={() => onSelect(type)}
style={{
fontWeight: type === currentType ? 600 : 400,
}}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Icon size={14} />
<Text size="sm">{t(labelKey)}</Text>
</Group>
{type === currentType && <IconCheck size={14} />}
</UnstyledButton>
))}
</>
);
}
export { propertyTypes };
@@ -0,0 +1,161 @@
import { useMemo, useCallback } from "react";
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { propertyTypes } from "@/features/base/components/property/property-type-picker";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewFieldVisibilityProps = {
opened: boolean;
onClose: () => void;
table: Table<IBaseRow>;
properties: IBaseProperty[];
onPersist: () => void;
children: React.ReactNode;
};
export function ViewFieldVisibility({
opened,
onClose,
table,
properties,
onPersist,
children,
}: ViewFieldVisibilityProps) {
const { t } = useTranslation();
const columns = useMemo(() => {
return table
.getAllLeafColumns()
.filter((col) => col.id !== "__row_number");
}, [table, properties]);
const allVisible = columns.every((col) => col.getIsVisible());
const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
const handleToggle = useCallback(
(columnId: string, visible: boolean) => {
const col = table.getColumn(columnId);
if (!col) return;
col.toggleVisibility(visible);
onPersist();
},
[table, onPersist],
);
const handleShowAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(true);
}
});
onPersist();
}, [columns, onPersist]);
const handleHideAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(false);
}
});
onPersist();
}, [columns, onPersist]);
return (
<Popover
opened={opened}
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("Fields")}
</Text>
<Group gap={8}>
<UnstyledButton
onClick={handleShowAll}
disabled={allVisible}
style={{ opacity: allVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Show all")}
</Text>
</UnstyledButton>
<UnstyledButton
onClick={handleHideAll}
disabled={noneVisible}
style={{ opacity: noneVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Hide all")}
</Text>
</UnstyledButton>
</Group>
</Group>
<Divider />
<Stack gap={0}>
{columns.map((col) => {
const property = col.columnDef.meta?.property as IBaseProperty | undefined;
if (!property) return null;
const canHide = col.getCanHide();
const isVisible = col.getIsVisible();
const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
const TypeIcon = typeConfig?.icon;
return (
<UnstyledButton
key={col.id}
className={cellClasses.menuItem}
onClick={() => {
if (canHide) {
handleToggle(col.id, !isVisible);
}
}}
style={{ opacity: canHide ? 1 : 0.5 }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
disabled={!canHide}
onChange={() => {}}
// Mantine's Switch spreads `onClick` onto its hidden
// <input>. When the user clicks the visible track, the
// label's default action synthesizes a second click on
// that input — both clicks bubble to the parent
// UnstyledButton and fire handleToggle twice (hide then
// immediately unhide, net zero). stopPropagation here
// blocks ONLY the synthesized input click from reaching
// UnstyledButton; the original track click still bubbles
// normally, so handleToggle fires exactly once.
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,445 @@
import { useCallback, useEffect, useState } from "react";
import {
Popover,
Stack,
Group,
Select,
TextInput,
ActionIcon,
Text,
UnstyledButton,
Button,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
IBaseProperty,
SelectTypeOptions,
FilterCondition,
FilterOperator,
} from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
/*
* Operator metadata for the filter popover. Values use the server
* engine's operator set (`core/base/engine/schema.zod.ts`); labels are
* i18n-translated display strings.
*/
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
{ value: "eq", labelKey: "Equals" },
{ value: "neq", labelKey: "Not equals" },
{ value: "contains", labelKey: "Contains" },
{ value: "ncontains", labelKey: "Not contains" },
{ value: "isEmpty", labelKey: "Is empty" },
{ value: "isNotEmpty", labelKey: "Is not empty" },
{ value: "gt", labelKey: "Greater than" },
{ value: "lt", labelKey: "Less than" },
{ value: "before", labelKey: "Before" },
{ value: "after", labelKey: "After" },
{ value: "any", labelKey: "Any of" },
{ value: "none", labelKey: "None of" },
];
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
function getOperatorsForType(type: string): FilterOperator[] {
switch (type) {
case "text":
case "email":
case "url":
return ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"];
case "number":
return ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"];
case "date":
case "createdAt":
case "lastEditedAt":
return ["eq", "neq", "before", "after", "isEmpty", "isNotEmpty"];
case "select":
case "status":
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
case "multiSelect":
return ["any", "none", "isEmpty", "isNotEmpty"];
case "checkbox":
return ["eq", "isEmpty", "isNotEmpty"];
case "person":
case "lastEditedBy":
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
case "file":
return ["isEmpty", "isNotEmpty"];
default:
return ["eq", "neq", "isEmpty", "isNotEmpty"];
}
}
function FilterValueInput({
condition,
property,
onChange,
t,
}: {
condition: FilterCondition;
property: IBaseProperty | undefined;
onChange: (value: string) => void;
t: (key: string) => string;
}) {
if (!property) {
return (
<TextInput
size="xs"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
const type = property.type;
if (type === "select" || type === "status" || type === "multiSelect") {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
return (
<Select
size="xs"
data={choiceOptions}
value={(condition.value as string) ?? null}
onChange={(val) => onChange(val ?? "")}
w={120}
placeholder={t("Select")}
/>
);
}
if (type === "number") {
return (
<TextInput
size="xs"
type="number"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
if (type === "checkbox") {
return (
<Select
size="xs"
data={[
{ value: "true", label: t("True") },
{ value: "false", label: t("False") },
]}
value={(condition.value as string) ?? null}
onChange={(val) => onChange(val ?? "")}
w={100}
/>
);
}
return (
<TextInput
size="xs"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
type ViewFilterConfigProps = {
opened: boolean;
onClose: () => void;
conditions: FilterCondition[];
properties: IBaseProperty[];
onChange: (conditions: FilterCondition[]) => void;
children: React.ReactNode;
};
export function ViewFilterConfigPopover({
opened,
onClose,
conditions,
properties,
onChange,
children,
}: ViewFilterConfigProps) {
const { t } = useTranslation();
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const [draft, setDraft] = useState<FilterCondition | null>(null);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
if (!firstProperty) return;
const validOperators = getOperatorsForType(firstProperty.type);
const defaultOperator = validOperators.includes("contains")
? ("contains" as FilterOperator)
: validOperators[0];
setDraft({ propertyId: firstProperty.id, op: defaultOperator });
}, [properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...conditions, draft]);
setDraft(null);
}, [draft, conditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
if (!propertyId || !draft) return;
const newProperty = properties.find((p) => p.id === propertyId);
if (!newProperty) {
setDraft({ ...draft, propertyId });
return;
}
const validOperators = getOperatorsForType(newProperty.type);
const currentOperatorValid = validOperators.includes(draft.op);
setDraft({
...draft,
propertyId,
op: currentOperatorValid ? draft.op : validOperators[0],
value: currentOperatorValid ? draft.value : undefined,
});
},
[draft, properties],
);
const handleDraftOperatorChange = useCallback(
(operator: string | null) => {
if (!operator || !draft) return;
const op = operator as FilterOperator;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
setDraft({ ...draft, op, value: needsValue ? draft.value : undefined });
},
[draft],
);
const handleDraftValueChange = useCallback(
(value: string) => {
if (!draft) return;
setDraft({ ...draft, value: value || undefined });
},
[draft],
);
const handleRemove = useCallback(
(index: number) => {
onChange(conditions.filter((_, i) => i !== index));
},
[conditions, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId);
onChange(
conditions.map((f, i) => {
if (i !== index) return f;
if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type);
const currentOperatorValid = validOperators.includes(f.op);
return {
...f,
propertyId,
op: currentOperatorValid ? f.op : validOperators[0],
value: currentOperatorValid ? f.value : undefined,
};
}
return { ...f, propertyId };
}),
);
},
[conditions, properties, onChange],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as FilterOperator;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
onChange(
conditions.map((f, i) =>
i === index
? {
...f,
op,
value: needsValue ? f.value : undefined,
}
: f,
),
);
},
[conditions, onChange],
);
const handleValueChange = useCallback(
(index: number, value: string) => {
onChange(
conditions.map((f, i) =>
i === index ? { ...f, value: value || undefined } : f,
),
);
},
[conditions, onChange],
);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={440}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Filter by")}
</Text>
{conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{conditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find(
(p) => p.id === condition.propertyId,
);
const validOperators = property
? getOperatorsForType(property.type)
: OPERATORS.map((op) => op.value);
const operatorOptions = OPERATORS.filter((op) =>
validOperators.includes(op.value),
).map((op) => ({
value: op.value,
label: t(op.labelKey),
}));
return (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={condition.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
value={condition.op}
onChange={(val) => handleOperatorChange(index, val)}
w={130}
/>
{needsValue && (
<FilterValueInput
condition={condition}
property={property}
onChange={(val) => handleValueChange(index, val)}
t={t}
/>
)}
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
);
})}
{draft && (() => {
const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
const property = properties.find((p) => p.id === draft.propertyId);
const validOperators = property
? getOperatorsForType(property.type)
: OPERATORS.map((op) => op.value);
const operatorOptions = OPERATORS.filter((op) =>
validOperators.includes(op.value),
).map((op) => ({ value: op.value, label: t(op.labelKey) }));
return (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={draft.propertyId}
onChange={handleDraftPropertyChange}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
value={draft.op}
onChange={handleDraftOperatorChange}
w={130}
/>
{needsValue && (
<FilterValueInput
condition={draft}
property={property}
onChange={handleDraftValueChange}
t={t}
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
{!draft && (
<UnstyledButton
onClick={handleStartDraft}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add filter")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import {
Popover,
Stack,
Group,
Select,
ActionIcon,
Text,
UnstyledButton,
Button,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
IBaseProperty,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
type ViewSortConfigProps = {
opened: boolean;
onClose: () => void;
sorts: ViewSortConfig[];
properties: IBaseProperty[];
onChange: (sorts: ViewSortConfig[]) => void;
children: React.ReactNode;
};
export function ViewSortConfigPopover({
opened,
onClose,
sorts,
properties,
onChange,
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
// Discard any half-configured draft when the popover closes.
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const directionOptions = [
{ value: "asc", label: t("Ascending") },
{ value: "desc", label: t("Descending") },
];
const handleStartDraft = useCallback(() => {
const usedIds = new Set(sorts.map((s) => s.propertyId));
const available = properties.find((p) => !usedIds.has(p.id));
if (!available) return;
setDraft({ propertyId: available.id, direction: "asc" });
}, [sorts, properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...sorts, draft]);
setDraft(null);
}, [draft, sorts, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleRemove = useCallback(
(index: number) => {
onChange(sorts.filter((_, i) => i !== index));
},
[sorts, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
onChange(
sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
);
},
[sorts, onChange],
);
const handleDirectionChange = useCallback(
(index: number, direction: string | null) => {
if (!direction) return;
onChange(
sorts.map((s, i) =>
i === index
? { ...s, direction: direction as "asc" | "desc" }
: s,
),
);
},
[sorts, onChange],
);
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sort by")}
</Text>
{sorts.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No sorts applied")}
</Text>
)}
{sorts.map((sort, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={sort.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={directionOptions}
value={sort.direction}
onChange={(val) => handleDirectionChange(index, val)}
w={110}
/>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
{draft && (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={draft.propertyId}
onChange={(val) =>
val && setDraft({ ...draft, propertyId: val })
}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={directionOptions}
value={draft.direction}
onChange={(val) =>
val &&
setDraft({
...draft,
direction: val as "asc" | "desc",
})
}
w={110}
/>
</Group>
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
)}
{!draft && canAddMore && (
<UnstyledButton
onClick={handleStartDraft}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add sort")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,237 @@
import { useState, useCallback } from "react";
import {
Group,
UnstyledButton,
Text,
ActionIcon,
Tooltip,
TextInput,
Popover,
Stack,
Divider,
} from "@mantine/core";
import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
import { IBaseView } from "@/features/base/types/base.types";
import {
useUpdateViewMutation,
useDeleteViewMutation,
} from "@/features/base/queries/base-view-query";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewTabsProps = {
views: IBaseView[];
activeViewId: string | undefined;
baseId: string;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
};
export function ViewTabs({
views,
activeViewId,
baseId,
onViewChange,
onAddView,
}: ViewTabsProps) {
const { t } = useTranslation();
const [editingViewId, setEditingViewId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const updateViewMutation = useUpdateViewMutation();
const deleteViewMutation = useDeleteViewMutation();
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,
baseId,
name: trimmed,
});
}
setEditingViewId(null);
}, [editingViewId, editingName, views, baseId, 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 (views.length <= 1) return;
deleteViewMutation.mutate({ viewId, baseId });
if (viewId === activeViewId && views.length > 1) {
const remaining = views.filter((v) => v.id !== viewId);
onViewChange(remaining[0].id);
}
},
[views, baseId, activeViewId, deleteViewMutation, onViewChange],
);
return (
<Group gap={4}>
{views.map((view) => (
<ViewTab
key={view.id}
view={view}
isActive={view.id === activeViewId}
isEditing={view.id === editingViewId}
editingName={editingName}
canDelete={views.length > 1}
onClick={() => onViewChange(view.id)}
onRenameStart={() => handleRenameStart(view)}
onRenameChange={setEditingName}
onRenameCommit={handleRenameCommit}
onRenameKeyDown={handleRenameKeyDown}
onDelete={() => handleDelete(view.id)}
/>
))}
{onAddView && (
<Tooltip label={t("Add view")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onAddView}
>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
function ViewTab({
view,
isActive,
isEditing,
editingName,
canDelete,
onClick,
onRenameStart,
onRenameChange,
onRenameCommit,
onRenameKeyDown,
onDelete,
}: {
view: IBaseView;
isActive: boolean;
isEditing: boolean;
editingName: string;
canDelete: boolean;
onClick: () => void;
onRenameStart: () => void;
onRenameChange: (name: string) => void;
onRenameCommit: () => void;
onRenameKeyDown: (e: React.KeyboardEvent) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false);
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
);
}
return (
<Popover
opened={menuOpened}
onClose={() => setMenuOpened(false)}
position="bottom-start"
shadow="md"
width={180}
withinPortal
>
<Popover.Target>
<UnstyledButton
onClick={onClick}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpened(true);
}}
style={{
padding: "4px 10px",
borderRadius: "var(--mantine-radius-sm)",
fontWeight: isActive ? 600 : 400,
}}
>
<Group gap={6} wrap="nowrap">
<IconTable size={14} opacity={0.5} />
<Text
size="sm"
c={isActive ? undefined : "dimmed"}
>
{view.name}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={4}>
<Stack gap={0}>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onRenameStart();
}}
>
<Group gap={8} wrap="nowrap">
<IconPencil size={14} />
<Text size="sm">{t("Rename")}</Text>
</Group>
</UnstyledButton>
{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>
);
}
@@ -0,0 +1,269 @@
import { useEffect } from "react";
import { useAtomValue, getDefaultStore } from "jotai";
import { useQueryClient, InfiniteData } from "@tanstack/react-query";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import {
IBaseProperty,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
import { selectedRowIdsAtom } from "@/features/base/atoms/base-atoms";
import { IPagination } from "@/lib/types";
type BaseRowCreated = {
operation: "base:row:created";
baseId: string;
row: IBaseRow;
requestId?: string | null;
};
type BaseRowUpdated = {
operation: "base:row:updated";
baseId: string;
rowId: string;
updatedCells: Record<string, unknown>;
requestId?: string | null;
};
type BaseRowDeleted = {
operation: "base:row:deleted";
baseId: string;
rowId: string;
requestId?: string | null;
};
type BaseRowsDeleted = {
operation: "base:rows:deleted";
baseId: string;
rowIds: string[];
requestId?: string | null;
};
type BaseRowReordered = {
operation: "base:row:reordered";
baseId: string;
rowId: string;
position: string;
requestId?: string | null;
};
type BasePropertyEvent = {
operation:
| "base:property:created"
| "base:property:updated"
| "base:property:deleted"
| "base:property:reordered";
baseId: string;
property?: IBaseProperty;
propertyId?: string;
requestId?: string | null;
};
type BaseViewEvent = {
operation:
| "base:view:created"
| "base:view:updated"
| "base:view:deleted";
baseId: string;
view?: IBaseView;
viewId?: string;
};
type BaseInboundEvent =
| BaseRowCreated
| BaseRowUpdated
| BaseRowDeleted
| BaseRowsDeleted
| BaseRowReordered
| BasePropertyEvent
| BaseViewEvent
| { operation: string; baseId: string };
/*
* Module-level set of requestIds we've just sent to the server. When the
* socket echoes back the mutation as a `base:row:*` / `base:property:*`
* event with a matching `requestId`, the socket handler drops it because
* the local mutation already updated the cache. Bounded so it can't grow
* unbounded on a long-lived tab.
*/
const outboundRequestIds = new Set<string>();
const OUTBOUND_MAX = 256;
export function markRequestIdOutbound(requestId: string): void {
outboundRequestIds.add(requestId);
if (outboundRequestIds.size > OUTBOUND_MAX) {
const oldest = outboundRequestIds.values().next().value;
if (oldest) outboundRequestIds.delete(oldest);
}
}
/*
* Realtime bridge for a single base. Joins the server's `base-{baseId}`
* room on mount, leaves on unmount, and reconciles the React Query caches
* (`["base-rows", baseId, ...]` and `["bases", baseId]`) when events
* arrive from other clients.
*/
export function useBaseSocket(baseId: string | undefined): void {
const socket = useAtomValue(socketAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!socket || !baseId) return;
socket.emit("message", { operation: "base:subscribe", baseId });
const handler = (raw: unknown) => {
if (!raw || typeof raw !== "object") return;
const event = raw as BaseInboundEvent;
if (event.baseId !== baseId) return;
const requestId = (event as any).requestId as string | undefined;
if (requestId && outboundRequestIds.has(requestId)) {
outboundRequestIds.delete(requestId);
return;
}
switch (event.operation) {
case "base:row:created": {
const e = event as BaseRowCreated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(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, e.row] }
: page,
),
};
},
);
break;
}
case "base:row:updated": {
const e = event as BaseRowUpdated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? {
...row,
cells: { ...row.cells, ...e.updatedCells },
}
: row,
),
})),
},
);
break;
}
case "base:row:deleted": {
const e = event as BaseRowDeleted;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== e.rowId),
})),
},
);
const store = getDefaultStore();
const current = store.get(selectedRowIdsAtom);
if (current.has(e.rowId)) {
const next = new Set(current);
next.delete(e.rowId);
store.set(selectedRowIdsAtom, next);
}
break;
}
case "base:rows:deleted": {
const e = event as BaseRowsDeleted;
const removeSet = new Set(e.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
);
const store = getDefaultStore();
const current = store.get(selectedRowIdsAtom);
if (current.size > 0) {
let changed = false;
const next = new Set(current);
for (const id of e.rowIds) {
if (next.delete(id)) changed = true;
}
if (changed) store.set(selectedRowIdsAtom, next);
}
break;
}
case "base:row:reordered": {
const e = event as BaseRowReordered;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? { ...row, position: e.position }
: row,
),
})),
},
);
break;
}
case "base:property:created":
case "base:property:updated":
case "base:property:deleted":
case "base:property:reordered":
case "base:view:created":
case "base:view:updated":
case "base:view:deleted": {
// Schema/metadata events only touch the base's `properties` /
// `views`, not the cell data — so we invalidate just
// `["bases", baseId]` here. Row reconciliation is handled
// per-event by the row cases above.
queryClient.invalidateQueries({ queryKey: ["bases", baseId] });
break;
}
default:
break;
}
};
socket.on("message", handler);
return () => {
socket.off("message", handler);
socket.emit("message", { operation: "base:unsubscribe", baseId });
};
}, [socket, baseId, queryClient]);
}
@@ -0,0 +1,399 @@
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
createColumnHelper,
ColumnDef,
SortingState,
ColumnSizingState,
VisibilityState,
ColumnOrderState,
ColumnPinningState,
Table,
} from "@tanstack/react-table";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
ViewConfig,
} from "@/features/base/types/base.types";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
const DEFAULT_COLUMN_WIDTH = 180;
const MIN_COLUMN_WIDTH = 80;
const MAX_COLUMN_WIDTH = 600;
const ROW_NUMBER_COLUMN_WIDTH = 64;
export const SYSTEM_PROPERTY_TYPES = new Set(["createdAt", "lastEditedAt", "lastEditedBy"]);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
const columnHelper = createColumnHelper<IBaseRow>();
function getSystemAccessor(type: string): ((row: IBaseRow) => unknown) | null {
switch (type) {
case "createdAt":
return (row) => row.createdAt;
case "lastEditedAt":
return (row) => row.updatedAt;
case "lastEditedBy":
return (row) => row.lastUpdatedById ?? row.creatorId;
default:
return null;
}
}
function buildColumns(properties: IBaseProperty[]): ColumnDef<IBaseRow, unknown>[] {
const rowNumberColumn = columnHelper.display({
id: "__row_number",
header: "#",
size: ROW_NUMBER_COLUMN_WIDTH,
minSize: ROW_NUMBER_COLUMN_WIDTH,
maxSize: ROW_NUMBER_COLUMN_WIDTH,
enableResizing: false,
enableSorting: false,
enableHiding: false,
});
const propertyColumns = properties.map((property) => {
const sysAccessor = getSystemAccessor(property.type);
if (sysAccessor) {
return columnHelper.accessor(sysAccessor, {
id: property.id,
header: property.name,
size: DEFAULT_COLUMN_WIDTH,
minSize: MIN_COLUMN_WIDTH,
maxSize: MAX_COLUMN_WIDTH,
enableResizing: true,
enableSorting: false,
enableHiding: !property.isPrimary,
meta: { property },
});
}
return columnHelper.accessor((row) => row.cells[property.id], {
id: property.id,
header: property.name,
size: DEFAULT_COLUMN_WIDTH,
minSize: MIN_COLUMN_WIDTH,
maxSize: MAX_COLUMN_WIDTH,
enableResizing: true,
enableSorting: true,
enableHiding: !property.isPrimary,
meta: { property },
});
});
return [rowNumberColumn, ...propertyColumns];
}
function buildSortingState(config: ViewConfig | undefined): SortingState {
if (!config?.sorts?.length) return [];
return config.sorts.map((sort) => ({
id: sort.propertyId,
desc: sort.direction === "desc",
}));
}
function buildColumnSizing(
config: ViewConfig | undefined,
): ColumnSizingState {
const sizing: ColumnSizingState = {
__row_number: ROW_NUMBER_COLUMN_WIDTH,
};
if (config?.propertyWidths) {
Object.entries(config.propertyWidths).forEach(([id, width]) => {
sizing[id] = width;
});
}
return sizing;
}
function buildColumnVisibility(
config: ViewConfig | undefined,
properties: IBaseProperty[],
): VisibilityState {
const visibility: VisibilityState = { __row_number: true };
if (config?.hiddenPropertyIds) {
const hiddenSet = new Set(config.hiddenPropertyIds);
properties.forEach((p) => {
visibility[p.id] = !hiddenSet.has(p.id);
});
return visibility;
}
if (config?.visiblePropertyIds?.length) {
const visibleSet = new Set(config.visiblePropertyIds);
properties.forEach((p) => {
visibility[p.id] = visibleSet.has(p.id);
});
return visibility;
}
properties.forEach((p) => {
visibility[p.id] = true;
});
return visibility;
}
function buildColumnOrder(
config: ViewConfig | undefined,
properties: IBaseProperty[],
): ColumnOrderState {
if (config?.propertyOrder?.length) {
const orderSet = new Set(config.propertyOrder);
const missing = properties
.filter((p) => !orderSet.has(p.id))
.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
.map((p) => p.id);
return ["__row_number", ...config.propertyOrder, ...missing];
}
const sorted = [...properties].sort((a, b) => {
if (a.isPrimary) return -1;
if (b.isPrimary) return 1;
return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
});
return ["__row_number", ...sorted.map((p) => p.id)];
}
function buildColumnPinning(
properties: IBaseProperty[],
): ColumnPinningState {
const primary = properties.find((p) => p.isPrimary);
return {
left: primary ? ["__row_number", primary.id] : ["__row_number"],
right: [],
};
}
// Serializes the live react-table state into a persisted ViewConfig.
// Sort/filter toolbar mutations and the debounced `persistViewConfig`
// both go through this so a direct mutation (e.g. adding a sort) can't
// clobber a pending hide/reorder/resize by reading stale `activeView.config`.
export function buildViewConfigFromTable(
table: Table<IBaseRow>,
base: ViewConfig | undefined,
overrides: Partial<ViewConfig> = {},
): ViewConfig {
// Guard against corrupted persisted configs — if `base` ever comes
// back as something other than a plain object (e.g. a jsonb-stored
// string `"{}"` from a buggy seed), spreading it would iterate its
// characters into keys `0`, `1`, … and poison the config forever.
const safeBase =
base && typeof base === "object" && !Array.isArray(base) ? base : {};
const state = table.getState();
const sorts = state.sorting.map((s) => ({
propertyId: s.id,
direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
}));
const propertyWidths: Record<string, number> = {};
Object.entries(state.columnSizing).forEach(([id, width]) => {
if (id !== "__row_number") propertyWidths[id] = width;
});
const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number");
const hiddenPropertyIds = Object.entries(state.columnVisibility)
.filter(([id, visible]) => id !== "__row_number" && !visible)
.map(([id]) => id);
return {
...safeBase,
sorts,
propertyWidths,
propertyOrder,
hiddenPropertyIds,
visiblePropertyIds: undefined,
...overrides,
};
}
export type UseBaseTableResult = {
table: Table<IBaseRow>;
persistViewConfig: () => void;
};
export function useBaseTable(
base: IBase | undefined,
rows: IBaseRow[],
activeView: IBaseView | undefined,
): UseBaseTableResult {
const updateViewMutation = useUpdateViewMutation();
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// While a local edit is pending (debounce scheduled OR mutation in
// flight), the reconcile effect preserves local state so we don't
// stomp the user's in-flight toggle. When no local edit is pending,
// the effect adopts server state — that's what makes remote updates
// (another client hiding a column) actually show up on this client.
const [hasPendingEdit, setHasPendingEdit] = useState(false);
// `base?.properties ?? []` minted a fresh `[]` every render while the
// base query was loading, which invalidated every downstream memo and
// tripped the setState-in-useEffect pairs below → "Maximum update
// depth exceeded". Memoize so the identity is stable.
const properties = useMemo(() => base?.properties ?? [], [base?.properties]);
const viewConfig = activeView?.config;
const columns = useMemo(
() => buildColumns(properties),
[properties],
);
const initialSorting = useMemo(
() => buildSortingState(viewConfig),
[viewConfig],
);
const initialColumnSizing = useMemo(
() => buildColumnSizing(viewConfig),
[viewConfig],
);
const derivedColumnOrder = useMemo(
() => buildColumnOrder(viewConfig, properties),
[viewConfig, properties],
);
const derivedColumnVisibility = useMemo(
() => buildColumnVisibility(viewConfig, properties),
[viewConfig, properties],
);
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
// Re-seed from server only when the user switches views. Within the same
// view, local state is the source of truth — the debounced persist flushes
// it. Without this guard, any ws-driven `invalidateQueries(["bases", baseId])`
// or concurrent view mutation lands a new `derivedColumnVisibility`
// reference and the effect would overwrite a pending hide/reorder toggle
// before `persistViewConfig` has a chance to flush it.
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
// View switch → full re-seed from the server's stored config.
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
return;
}
// Same view. If a local edit is pending (user just toggled and
// the debounce hasn't flushed yet, or the mutation is in flight),
// preserve local state — only reconcile the id set so that newly
// created columns show up and deleted columns drop out without
// stomping the user's toggle. If nothing local is pending, adopt
// the server's state — this is what lets remote updates from
// other clients show up here.
const validIds = new Set<string>(["__row_number"]);
for (const p of properties) validIds.add(p.id);
if (hasPendingEdit) {
setColumnOrder((prev) => {
const prevSet = new Set(prev);
const kept = prev.filter((id) => validIds.has(id));
const appended = derivedColumnOrder.filter(
(id) => !prevSet.has(id) && validIds.has(id),
);
if (appended.length === 0 && kept.length === prev.length) return prev;
return [...kept, ...appended];
});
setColumnVisibility((prev) => {
let changed = false;
const next: VisibilityState = {};
for (const [id, visible] of Object.entries(prev)) {
if (validIds.has(id)) {
next[id] = visible;
} else {
changed = true;
}
}
for (const id of derivedColumnOrder) {
if (!(id in next)) {
next[id] = derivedColumnVisibility[id] ?? true;
changed = true;
}
}
return changed ? next : prev;
});
} else {
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [
activeView?.id,
derivedColumnOrder,
derivedColumnVisibility,
properties,
hasPendingEdit,
]);
const columnPinning = useMemo(
() => buildColumnPinning(properties),
[properties],
);
const table = useReactTable({
data: rows,
columns,
state: {
columnPinning,
columnOrder,
columnVisibility,
},
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
initialState: {
sorting: initialSorting,
columnSizing: initialColumnSizing,
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
columnResizeMode: "onChange",
enableColumnResizing: true,
enableSorting: true,
enableHiding: true,
getRowId: (row) => row.id,
});
const persistViewConfig = useCallback(() => {
if (!activeView || !base) return;
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
}
setHasPendingEdit(true);
persistTimerRef.current = setTimeout(() => {
persistTimerRef.current = null;
const config = buildViewConfigFromTable(table, activeView.config);
updateViewMutation.mutate(
{ viewId: activeView.id, baseId: base.id, config },
{
onSettled: () => {
// Don't clear if the user has already scheduled another
// debounce while this one was in flight.
if (persistTimerRef.current === null) {
setHasPendingEdit(false);
}
},
},
);
}, 300);
}, [activeView, base, table, updateViewMutation]);
return { table, persistViewConfig };
}
@@ -0,0 +1,26 @@
import { useEffect, useRef, useCallback } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
export function useColumnResize(
table: Table<IBaseRow>,
onResizeEnd: () => void,
) {
const wasResizingRef = useRef(false);
const checkResizeEnd = useCallback(() => {
const isResizing = table.getState().columnSizingInfo.isResizingColumn;
if (wasResizingRef.current && !isResizing) {
onResizeEnd();
}
wasResizingRef.current = !!isResizing;
}, [table, onResizeEnd]);
useEffect(() => {
checkResizeEnd();
});
return {
isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
};
}
@@ -0,0 +1,55 @@
import { useCallback } from "react";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteRowsMutation } from "@/features/base/queries/base-row-query";
const BATCH_SIZE = 500;
export function useDeleteSelectedRows(baseId: string) {
const { t } = useTranslation();
const { selectedIds, clear } = useRowSelection();
const mutation = useDeleteRowsMutation();
const runDelete = useCallback(
async (ids: string[]) => {
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
chunks.push(ids.slice(i, i + BATCH_SIZE));
}
try {
for (const chunk of chunks) {
await mutation.mutateAsync({ baseId, rowIds: chunk });
}
notifications.show({
message: t("{{count}} rows deleted", { count: ids.length }),
});
clear();
} catch {
// mutation onError already shows notification
}
},
[baseId, mutation, clear, t],
);
const deleteSelected = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
modals.openConfirmModal({
title: t("Delete {{count}} rows?", { count: ids.length }),
centered: true,
children: (
<Text size="sm">
{t("This action cannot be undone.")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => void runDelete(ids),
});
}, [selectedIds, runDelete, t]);
return { deleteSelected, isPending: mutation.isPending };
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
type UseGridKeyboardNavOptions = {
table: Table<IBaseRow>;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
};
export function useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef,
}: UseGridKeyboardNavOptions) {
const getNavigableColumns = useCallback(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table]);
const getRowIds = useCallback(() => {
return table.getRowModel().rows.map((row) => row.id);
}, [table]);
const navigate = useCallback(
(rowDelta: number, colDelta: number) => {
if (!editingCell) return;
const columns = getNavigableColumns();
const rowIds = getRowIds();
const currentColIndex = columns.indexOf(editingCell.propertyId);
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
if (currentColIndex === -1 || currentRowIndex === -1) return;
let nextColIndex = currentColIndex + colDelta;
let nextRowIndex = currentRowIndex + rowDelta;
if (nextColIndex < 0) {
nextColIndex = columns.length - 1;
nextRowIndex -= 1;
} else if (nextColIndex >= columns.length) {
nextColIndex = 0;
nextRowIndex += 1;
}
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
setEditingCell({
rowId: rowIds[nextRowIndex],
propertyId: columns[nextColIndex],
});
},
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!editingCell) return;
const target = e.target as HTMLElement;
const isInputActive =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
switch (e.key) {
case "ArrowUp":
if (!isInputActive) {
e.preventDefault();
navigate(-1, 0);
}
break;
case "ArrowDown":
if (!isInputActive) {
e.preventDefault();
navigate(1, 0);
}
break;
case "ArrowLeft":
if (!isInputActive) {
e.preventDefault();
navigate(0, -1);
}
break;
case "ArrowRight":
if (!isInputActive) {
e.preventDefault();
navigate(0, 1);
}
break;
case "Tab":
e.preventDefault();
navigate(0, e.shiftKey ? -1 : 1);
break;
case "Escape":
e.preventDefault();
setEditingCell(null);
break;
}
},
[editingCell, navigate, setEditingCell],
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleKeyDown]);
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from "react";
type UseListKeyboardNavResult = {
activeIndex: number;
setActiveIndex: (idx: number) => void;
handleNavKey: (e: React.KeyboardEvent) => boolean;
setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
};
export function useListKeyboardNav(
itemCount: number,
resetDeps: ReadonlyArray<unknown>,
): UseListKeyboardNavResult {
const [activeIndex, setActiveIndex] = useState(-1);
const optionRefs = useRef<Array<HTMLElement | null>>([]);
// Reset highlight when filter/open-state changes. resetDeps is intentional.
useEffect(() => {
setActiveIndex(-1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, resetDeps);
useEffect(() => {
if (activeIndex < 0) return;
const el = optionRefs.current[activeIndex];
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const setOptionRef = useCallback(
(idx: number) => (el: HTMLElement | null) => {
optionRefs.current[idx] = el;
},
[],
);
const handleNavKey = useCallback(
(e: React.KeyboardEvent): boolean => {
if (itemCount === 0) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
return true;
}
if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
return true;
}
if (e.key === "End") {
e.preventDefault();
setActiveIndex(itemCount - 1);
return true;
}
return false;
},
[itemCount],
);
return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
}
@@ -0,0 +1,115 @@
import { useState, useCallback, useRef, useEffect } from "react";
type RowDragState = {
dragRowId: string | null;
dropTargetRowId: string | null;
dropPosition: "above" | "below" | null;
};
type UseRowDragOptions = {
rowIds: string[];
onReorder: (rowId: string, targetRowId: string, position: "above" | "below") => void;
};
export function useRowDrag({ rowIds, onReorder }: UseRowDragOptions) {
const [dragState, setDragState] = useState<RowDragState>({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
const dragRowIdRef = useRef<string | null>(null);
const dropTargetRef = useRef<string | null>(null);
const dropPositionRef = useRef<"above" | "below" | null>(null);
const onReorderRef = useRef(onReorder);
onReorderRef.current = onReorder;
const handleDragStart = useCallback((rowId: string) => {
dragRowIdRef.current = rowId;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: rowId,
dropTargetRowId: null,
dropPosition: null,
});
}, []);
const handleDragOver = useCallback(
(targetRowId: string, e: React.DragEvent) => {
e.preventDefault();
if (!dragRowIdRef.current || dragRowIdRef.current === targetRowId) return;
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "above" | "below" = e.clientY < midY ? "above" : "below";
if (dropTargetRef.current === targetRowId && dropPositionRef.current === position) {
return;
}
dropTargetRef.current = targetRowId;
dropPositionRef.current = position;
setDragState({
dragRowId: dragRowIdRef.current,
dropTargetRowId: targetRowId,
dropPosition: position,
});
},
[],
);
const handleDragEnd = useCallback(() => {
const dragRowId = dragRowIdRef.current;
const dropTargetRowId = dropTargetRef.current;
const dropPosition = dropPositionRef.current;
if (dragRowId && dropTargetRowId && dropPosition && dragRowId !== dropTargetRowId) {
onReorderRef.current(dragRowId, dropTargetRowId, dropPosition);
}
dragRowIdRef.current = null;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
}, []);
const handleDragLeave = useCallback(() => {
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState((prev) => ({
...prev,
dropTargetRowId: null,
dropPosition: null,
}));
}, []);
useEffect(() => {
const handleGlobalDragEnd = () => {
dragRowIdRef.current = null;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
};
document.addEventListener("dragend", handleGlobalDragEnd);
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
}, []);
return {
dragState,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDragLeave,
};
}
@@ -0,0 +1,99 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import {
selectedRowIdsAtom,
lastToggledRowIndexAtom,
} from "@/features/base/atoms/base-atoms";
type ToggleOpts = {
shiftKey: boolean;
rowIndex: number;
orderedRowIds: string[];
};
export function useRowSelection() {
const [selectedIds, setSelectedIds] = useAtom(selectedRowIdsAtom) as unknown as [
Set<string>,
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => void,
];
const [lastToggledIndex, setLastToggledIndex] = useAtom(
lastToggledRowIndexAtom,
) as unknown as [number | null, (val: number | null) => void];
const isSelected = useCallback(
(rowId: string) => selectedIds.has(rowId),
[selectedIds],
);
const toggle = useCallback(
(rowId: string, opts: ToggleOpts) => {
const { shiftKey, rowIndex, orderedRowIds } = opts;
const next = new Set(selectedIds);
if (shiftKey && lastToggledIndex !== null && lastToggledIndex !== rowIndex) {
const start = Math.min(lastToggledIndex, rowIndex);
const end = Math.max(lastToggledIndex, rowIndex);
const anchorId = orderedRowIds[lastToggledIndex];
const turnOn = anchorId ? next.has(anchorId) : true;
for (let i = start; i <= end; i += 1) {
const id = orderedRowIds[i];
if (!id) continue;
if (turnOn) next.add(id);
else next.delete(id);
}
} else {
if (next.has(rowId)) next.delete(rowId);
else next.add(rowId);
}
setSelectedIds(next);
setLastToggledIndex(rowIndex);
},
[selectedIds, lastToggledIndex, setSelectedIds, setLastToggledIndex],
);
const toggleAll = useCallback(
(loadedRowIds: string[]) => {
if (loadedRowIds.length === 0) return;
const allSelected = loadedRowIds.every((id) => selectedIds.has(id));
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(loadedRowIds));
}
setLastToggledIndex(null);
},
[selectedIds, setSelectedIds, setLastToggledIndex],
);
const clear = useCallback(() => {
setSelectedIds(new Set());
setLastToggledIndex(null);
}, [setSelectedIds, setLastToggledIndex]);
const removeIds = useCallback(
(rowIds: string[]) => {
if (rowIds.length === 0) return;
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
let changed = false;
const next = new Set(prev);
for (const id of rowIds) {
if (next.delete(id)) changed = true;
}
return changed ? next : prev;
});
},
[setSelectedIds],
);
return {
selectedIds,
selectionCount: selectedIds.size,
isSelected,
toggle,
toggleAll,
clear,
removeIds,
};
}
@@ -0,0 +1,164 @@
import { InfiniteData, useMutation } from "@tanstack/react-query";
import {
createProperty,
updateProperty,
deleteProperty,
reorderProperty,
} from "@/features/base/services/base-service";
import {
IBase,
IBaseProperty,
IBaseRow,
CreatePropertyInput,
UpdatePropertyInput,
DeletePropertyInput,
ReorderPropertyInput,
UpdatePropertyResult,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types";
export function useCreatePropertyMutation() {
const { t } = useTranslation();
return useMutation<IBaseProperty, Error, CreatePropertyInput>({
mutationFn: (data) => createProperty(data),
onSuccess: (newProperty) => {
queryClient.setQueryData<IBase>(
["bases", newProperty.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: [...old.properties, newProperty],
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create property"),
color: "red",
});
},
});
}
export function useUpdatePropertyMutation() {
const { t } = useTranslation();
return useMutation<UpdatePropertyResult, Error, UpdatePropertyInput>({
mutationFn: (data) => updateProperty(data),
onSuccess: (result, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.map((p) =>
p.id === result.property.id ? result.property : p,
),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to update property"),
color: "red",
});
},
});
}
export function useDeletePropertyMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeletePropertyInput>({
mutationFn: (data) => deleteProperty(data),
onSuccess: (_, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.filter(
(p) => p.id !== variables.propertyId,
),
};
},
);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) => {
if (!(variables.propertyId in row.cells)) return row;
const { [variables.propertyId]: _, ...rest } = row.cells;
return { ...row, cells: rest };
}),
})),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to delete property"),
color: "red",
});
},
});
}
export function useReorderPropertyMutation() {
const { t } = useTranslation();
return useMutation<void, Error, ReorderPropertyInput, { previous: IBase | undefined }>({
mutationFn: (data) => reorderProperty(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["bases", variables.baseId],
});
const previous = queryClient.getQueryData<IBase>([
"bases",
variables.baseId,
]);
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.map((p) =>
p.id === variables.propertyId
? { ...p, position: variables.position }
: p,
),
};
},
);
return { previous };
},
onError: (_, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.baseId],
context.previous,
);
}
notifications.show({
message: t("Failed to reorder property"),
color: "red",
});
},
});
}
@@ -0,0 +1,87 @@
import {
useMutation,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
createBase,
getBaseInfo,
updateBase,
deleteBase,
} from "@/features/base/services/base-service";
import {
IBase,
CreateBaseInput,
UpdateBaseInput,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
export function useBaseQuery(
baseId: string | undefined,
): UseQueryResult<IBase, Error> {
return useQuery({
queryKey: ["bases", baseId],
queryFn: () => getBaseInfo(baseId!),
enabled: !!baseId,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateBaseMutation() {
const { t } = useTranslation();
return useMutation<IBase, Error, CreateBaseInput>({
mutationFn: (data) => createBase(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ["bases", "list", data.spaceId],
});
},
onError: () => {
notifications.show({
message: t("Failed to create base"),
color: "red",
});
},
});
}
export function useUpdateBaseMutation() {
const { t } = useTranslation();
return useMutation<IBase, Error, UpdateBaseInput>({
mutationFn: (data) => updateBase(data),
onSuccess: (data) => {
queryClient.setQueryData<IBase>(["bases", data.id], (old) => {
if (!old) return old;
return { ...old, ...data };
});
},
onError: () => {
notifications.show({
message: t("Failed to update base"),
color: "red",
});
},
});
}
export function useDeleteBaseMutation() {
const { t } = useTranslation();
return useMutation<void, Error, { baseId: string; spaceId: string }>({
mutationFn: ({ baseId }) => deleteBase(baseId),
onSuccess: (_, { baseId, spaceId }) => {
queryClient.removeQueries({ queryKey: ["bases", baseId] });
queryClient.invalidateQueries({
queryKey: ["bases", "list", spaceId],
});
notifications.show({ message: t("Base deleted") });
},
onError: () => {
notifications.show({
message: t("Failed to delete base"),
color: "red",
});
},
});
}
@@ -0,0 +1,314 @@
import {
useInfiniteQuery,
useMutation,
InfiniteData,
} from "@tanstack/react-query";
import {
createRow,
updateRow,
deleteRow,
deleteRows,
listRows,
reorderRow,
} from "@/features/base/services/base-service";
import {
IBaseRow,
CreateRowInput,
UpdateRowInput,
DeleteRowInput,
DeleteRowsInput,
ReorderRowInput,
FilterNode,
SearchSpec,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types";
import { markRequestIdOutbound } from "@/features/base/hooks/use-base-socket";
type RowCacheContext = {
snapshots: [readonly unknown[], InfiniteData<IPagination<IBaseRow>> | undefined][];
};
// Generate a fresh requestId and pre-register it as outbound so the
// incoming socket echo is suppressed by `useBaseSocket`.
function newRequestId(): string {
const id =
typeof crypto !== "undefined" &&
typeof (crypto as any).randomUUID === "function"
? (crypto as any).randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
markRequestIdOutbound(id);
return id;
}
export function useBaseRowsQuery(
baseId: string | undefined,
filter?: FilterNode,
sorts?: ViewSortConfig[],
search?: SearchSpec,
) {
const activeFilter = filter ?? undefined;
const activeSorts = sorts?.length ? sorts : undefined;
const activeSearch = search?.query ? search : undefined;
return useInfiniteQuery({
queryKey: ["base-rows", baseId, activeFilter, activeSorts, activeSearch],
queryFn: ({ pageParam }) =>
listRows(baseId!, {
cursor: pageParam,
limit: 100,
filter: activeFilter,
sorts: activeSorts,
search: activeSearch,
}),
enabled: !!baseId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
lastPage.meta?.nextCursor ?? undefined,
staleTime: 5 * 60 * 1000,
});
}
export function flattenRows(
data: InfiniteData<IPagination<IBaseRow>> | 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<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", newRow.baseId] },
(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;
}),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create row"),
color: "red",
});
},
});
}
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.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(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 },
}
: row,
),
})),
};
},
);
return { snapshots };
},
onError: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to update row"),
color: "red",
});
},
onSuccess: (updatedRow) => {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", updatedRow.baseId] },
(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,
),
})),
};
},
);
},
});
}
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.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(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: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: 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.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
const removeSet = new Set(variables.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(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: (_, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: 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.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(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: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to reorder row"),
color: "red",
});
},
});
}
@@ -0,0 +1,137 @@
import { useMutation } from "@tanstack/react-query";
import {
createView,
updateView,
deleteView,
} from "@/features/base/services/base-service";
import {
IBase,
IBaseView,
CreateViewInput,
UpdateViewInput,
DeleteViewInput,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
export function useCreateViewMutation() {
const { t } = useTranslation();
return useMutation<IBaseView, Error, CreateViewInput>({
mutationFn: (data) => createView(data),
onSuccess: (newView) => {
queryClient.setQueryData<IBase>(
["bases", newView.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: [...old.views, newView],
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create view"),
color: "red",
});
},
});
}
export function useUpdateViewMutation() {
const { t } = useTranslation();
return useMutation<IBaseView, Error, UpdateViewInput, { previous: IBase | undefined }>({
mutationFn: (data) => updateView(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["bases", variables.baseId],
});
const previous = queryClient.getQueryData<IBase>([
"bases",
variables.baseId,
]);
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.map((v) =>
v.id === variables.viewId
? {
...v,
...(variables.name !== undefined && {
name: variables.name,
}),
...(variables.type !== undefined && {
type: variables.type,
}),
...(variables.config !== undefined && {
config: variables.config,
}),
}
: v,
),
};
},
);
return { previous };
},
onError: (_, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.baseId],
context.previous,
);
}
notifications.show({
message: t("Failed to update view"),
color: "red",
});
},
onSuccess: (updatedView) => {
queryClient.setQueryData<IBase>(
["bases", updatedView.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.map((v) =>
v.id === updatedView.id ? updatedView : v,
),
};
},
);
},
});
}
export function useDeleteViewMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteViewInput>({
mutationFn: (data) => deleteView(data),
onSuccess: (_, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.filter((v) => v.id !== variables.viewId),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to delete view"),
color: "red",
});
},
});
}
@@ -0,0 +1,173 @@
import api from "@/lib/api-client";
import { saveAs } from "file-saver";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
CreateBaseInput,
UpdateBaseInput,
CreatePropertyInput,
UpdatePropertyInput,
DeletePropertyInput,
ReorderPropertyInput,
CreateRowInput,
UpdateRowInput,
DeleteRowInput,
DeleteRowsInput,
ReorderRowInput,
CreateViewInput,
UpdateViewInput,
DeleteViewInput,
UpdatePropertyResult,
FilterNode,
SearchSpec,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { IPagination } from "@/lib/types";
// --- Bases ---
export async function createBase(data: CreateBaseInput): Promise<IBase> {
const req = await api.post<IBase>("/bases/create", data);
return req.data;
}
export async function getBaseInfo(baseId: string): Promise<IBase> {
const req = await api.post<IBase>("/bases/info", { baseId });
return req.data;
}
export async function updateBase(data: UpdateBaseInput): Promise<IBase> {
const req = await api.post<IBase>("/bases/update", data);
return req.data;
}
export async function deleteBase(baseId: string): Promise<void> {
await api.post("/bases/delete", { baseId });
}
export async function exportBaseToCsv(baseId: string): Promise<void> {
const req = await api.post(
"/bases/export-csv",
{ baseId },
{ responseType: "blob" },
);
const header = (req?.headers?.["content-disposition"] as string) ?? "";
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
const plainMatch = header.match(/filename="?([^";]+)"?/i);
let fileName = utf8Match?.[1] ?? plainMatch?.[1] ?? "base.csv";
try {
fileName = decodeURIComponent(fileName);
} catch {
// fallback to raw filename
}
saveAs(req.data, fileName);
}
export async function listBases(
spaceId: string,
params?: { cursor?: string; limit?: number },
): Promise<IPagination<IBase>> {
const req = await api.post("/bases", { spaceId, ...params });
return req.data;
}
// --- Properties ---
export async function createProperty(
data: CreatePropertyInput,
): Promise<IBaseProperty> {
const req = await api.post<IBaseProperty>("/bases/properties/create", data);
return req.data;
}
export async function updateProperty(
data: UpdatePropertyInput,
): Promise<UpdatePropertyResult> {
const req = await api.post<UpdatePropertyResult>(
"/bases/properties/update",
data,
);
return req.data;
}
export async function deleteProperty(data: DeletePropertyInput): Promise<void> {
await api.post("/bases/properties/delete", data);
}
export async function reorderProperty(
data: ReorderPropertyInput,
): Promise<void> {
await api.post("/bases/properties/reorder", data);
}
// --- Rows ---
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/create", data);
return req.data;
}
export async function getRowInfo(
rowId: string,
baseId: string,
): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/info", { rowId, baseId });
return req.data;
}
export async function updateRow(data: UpdateRowInput): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/update", data);
return req.data;
}
export async function deleteRow(data: DeleteRowInput): Promise<void> {
await api.post("/bases/rows/delete", data);
}
export async function deleteRows(data: DeleteRowsInput): Promise<void> {
await api.post("/bases/rows/delete-many", data);
}
export async function listRows(
baseId: string,
params?: {
viewId?: string;
cursor?: string;
limit?: number;
filter?: FilterNode;
sorts?: ViewSortConfig[];
search?: SearchSpec;
},
): Promise<IPagination<IBaseRow>> {
const req = await api.post("/bases/rows", { baseId, ...params });
return req.data;
}
export async function reorderRow(data: ReorderRowInput): Promise<void> {
await api.post("/bases/rows/reorder", data);
}
// --- Views ---
export async function createView(data: CreateViewInput): Promise<IBaseView> {
const req = await api.post<IBaseView>("/bases/views/create", data);
return req.data;
}
export async function updateView(data: UpdateViewInput): Promise<IBaseView> {
const req = await api.post<IBaseView>("/bases/views/update", data);
return req.data;
}
export async function deleteView(data: DeleteViewInput): Promise<void> {
await api.post("/bases/views/delete", data);
}
export async function listViews(baseId: string): Promise<IBaseView[]> {
const req = await api.post<IBaseView[]>("/bases/views", { baseId });
return req.data;
}
@@ -0,0 +1,47 @@
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
}
.toolbarTabs {
display: flex;
gap: 6px;
flex: 1;
}
.toolbarActions {
display: flex;
gap: var(--mantine-spacing-xs);
margin-left: auto;
}
.gridWrapper {
overflow: hidden;
flex: 1;
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.grid {
display: grid;
}
.cellInner {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 0 8px;
}
.headerCellInner {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
width: 100%;
padding: 0 8px;
}
@@ -0,0 +1,297 @@
.cellInput {
width: 100%;
height: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--mantine-font-size-sm);
font-family: inherit;
color: inherit;
padding: 0 8px;
}
.cellInput::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.numberValue {
text-align: right;
width: 100%;
}
.badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 12px;
font-size: var(--mantine-font-size-xs);
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.badgeGroup {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.overflowCount {
font-size: 11px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
flex-shrink: 0;
white-space: nowrap;
}
.checkboxCell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
cursor: pointer;
}
.urlLink {
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.urlLink:hover {
text-decoration: underline;
}
.emailLink {
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.emailLink:hover {
text-decoration: underline;
}
.dateValue {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.emptyValue {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
}
/* Person cell — read mode (vertical list like Notion) */
.personGroup {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.personRow {
display: flex;
align-items: center;
gap: 6px;
}
.personName {
font-size: var(--mantine-font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Person cell — edit mode (tag input + dropdown) */
.personTagArea {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 6px 8px;
min-height: 34px;
}
.personTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 2px;
border-radius: 3px;
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
font-size: var(--mantine-font-size-xs);
white-space: nowrap;
max-width: 160px;
}
.personTagName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.personTagRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 2px;
background: transparent;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
cursor: pointer;
flex-shrink: 0;
}
.personTagRemove:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}
.personTagInput {
flex: 1;
min-width: 60px;
border: none;
outline: none;
background: transparent;
font-size: var(--mantine-font-size-xs);
font-family: inherit;
color: inherit;
padding: 2px 0;
}
.personTagInput::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.personDropdownDivider {
height: 1px;
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.personDropdownHint {
padding: 6px 8px;
font-size: 11px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.personOptionName {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fileGroup {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.fileBadge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: var(--mantine-radius-sm);
font-size: 11px;
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.selectDropdown {
max-height: 240px;
overflow-y: auto;
}
.selectOption {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: var(--mantine-radius-sm);
transition: background-color 100ms ease;
}
.selectOption:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.selectOptionActive {
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-blue-9));
}
.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.selectOptionActive.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8));
}
.selectCategoryLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
padding: 8px 8px 4px;
letter-spacing: 0.5px;
}
.menuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
cursor: pointer;
transition: background-color 100ms ease;
}
.menuItem:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.addOptionRow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: var(--mantine-radius-sm);
transition: background-color 100ms ease;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: 4px;
}
.addOptionRow:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.addOptionLabel {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
flex-shrink: 0;
}
@@ -0,0 +1,466 @@
.gridWrapper {
position: relative;
overflow: auto;
overflow-anchor: none;
flex: 1;
min-height: 0;
padding-left: 6px;
}
.grid {
display: grid;
min-width: max-content;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
}
.headerRow {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.headerCell {
position: relative;
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 8px;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-right: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.headerCell:last-child {
border-right: none;
}
.headerCellPinned {
position: sticky;
z-index: 2;
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.headerCellContent {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.headerCellName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerTypeIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.headerConvertingSpinner {
flex-shrink: 0;
margin-left: auto;
}
.resizeHandle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 3;
}
.resizeHandle::after {
content: "";
position: absolute;
right: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background-color: transparent;
transition: background-color 150ms ease;
}
.resizeHandle:hover::after,
.resizeHandleActive::after {
background-color: var(--mantine-color-blue-5);
}
.rowContainer {
display: contents;
}
.row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
.cell {
display: flex;
align-items: center;
min-height: 36px;
padding: 0 8px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-right: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
overflow: hidden;
cursor: default;
outline: none;
}
.cell:last-child {
border-right: none;
}
.cellPinned {
position: sticky;
z-index: 1;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
}
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
.cellEditing {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
padding: 0;
}
.cellContent {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowNumberCell {
justify-content: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.rowNumberDraggable {
cursor: grab;
}
.rowNumberDraggable:active {
cursor: grabbing;
}
.rowDragging .cell {
opacity: 0.4;
}
.rowDropAbove .cell {
box-shadow: inset 0 2px 0 0 var(--mantine-color-blue-5);
}
.rowDropBelow .cell {
box-shadow: inset 0 -2px 0 0 var(--mantine-color-blue-5);
}
.addRowButton {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 8px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
border-top: 1px dashed
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
cursor: pointer;
user-select: none;
transition: background-color 150ms ease;
grid-column: 1 / -1;
}
.addRowButton:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.addColumnButton {
display: flex;
align-items: center;
justify-content: center;
height: 34px;
min-width: 40px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
cursor: pointer;
user-select: none;
transition: background-color 150ms ease;
}
.addColumnButton:hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
grid-column: 1 / -1;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
flex-wrap: wrap;
}
.toolbarRight {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
}
.primaryCell {
font-weight: 500;
}
.rowNumberCellInner {
position: relative;
width: 100%;
height: 100%;
}
.rowNumberIndex,
.rowNumberCheckbox {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
}
.rowNumberCheckbox {
display: none;
}
.rowNumberDragHandle {
position: absolute;
top: 50%;
left: -6px;
transform: translateY(-50%);
display: none;
align-items: center;
justify-content: center;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
cursor: grab;
}
.rowNumberDragHandle:active {
cursor: grabbing;
}
/* On row hover: swap index for checkbox, reveal drag handle */
.row:hover .rowNumberIndex {
display: none;
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
}
/* Selected row: checkbox always visible, drag handle still only on hover */
.rowSelected .rowNumberIndex {
display: none;
}
.rowSelected .rowNumberCheckbox {
display: inline-flex;
}
.rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
}
.rowNumberHeaderInner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.rowNumberHeaderHash {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs);
}
.rowNumberHeaderCheckbox {
display: none;
}
.headerCell:hover .rowNumberHeaderHash,
.hasSelection .rowNumberHeaderHash {
display: none;
}
.headerCell:hover .rowNumberHeaderCheckbox,
.hasSelection .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
justify-content: center;
pointer-events: none;
z-index: 200;
}
.selectionActionBar {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 14px;
border-radius: 999px;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(0, 0, 0, 0.18);
background: light-dark(
var(--mantine-color-dark-8),
var(--mantine-color-dark-5)
);
color: var(--mantine-color-white);
border: 1px solid light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-dark-4)
);
}
.selectionActionBarCount {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: var(--mantine-color-white);
padding-right: 10px;
margin-right: 2px;
border-right: 1px solid rgba(255, 255, 255, 0.15);
}
.selectionActionBarDelete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--mantine-color-red-4);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease;
}
.selectionActionBarDelete:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.selectionActionBarDelete:disabled {
opacity: 0.6;
cursor: default;
}
.selectionActionBarClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--mantine-color-gray-3);
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.selectionActionBarClose:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--mantine-color-white);
}
@@ -0,0 +1,297 @@
export type BasePropertyType =
| 'text'
| 'number'
| 'select'
| 'status'
| 'multiSelect'
| 'date'
| 'person'
| 'file'
| 'checkbox'
| 'url'
| 'email'
| 'createdAt'
| 'lastEditedAt'
| 'lastEditedBy';
export type Choice = {
id: string;
name: string;
color: string;
category?: 'todo' | 'inProgress' | 'complete';
};
export type SelectTypeOptions = {
choices: Choice[];
choiceOrder: string[];
disableColors?: boolean;
defaultValue?: string | string[] | null;
};
export type NumberTypeOptions = {
format?: 'plain' | 'currency' | 'percent' | 'progress';
precision?: number;
currencySymbol?: string;
defaultValue?: number | null;
};
export type DateTypeOptions = {
dateFormat?: string;
timeFormat?: '12h' | '24h';
includeTime?: boolean;
defaultValue?: string | null;
};
export type TextTypeOptions = {
richText?: boolean;
defaultValue?: string | null;
};
export type CheckboxTypeOptions = {
defaultValue?: boolean;
};
export type UrlTypeOptions = {
defaultValue?: string | null;
};
export type EmailTypeOptions = {
defaultValue?: string | null;
};
export type PersonTypeOptions = {
allowMultiple?: boolean;
};
export type TypeOptions =
| SelectTypeOptions
| NumberTypeOptions
| DateTypeOptions
| TextTypeOptions
| CheckboxTypeOptions
| UrlTypeOptions
| EmailTypeOptions
| PersonTypeOptions
| Record<string, unknown>;
export type IBaseProperty = {
id: string;
baseId: string;
name: string;
type: BasePropertyType;
position: string;
typeOptions: TypeOptions;
// Set while a background type-conversion job is rewriting cells. The
// live `type` stays on the old kind until the job commits, so cells
// render correctly; the column header shows a "Converting…" badge.
pendingType?: BasePropertyType | null;
pendingTypeOptions?: TypeOptions | null;
isPrimary: boolean;
workspaceId: string;
createdAt: string;
updatedAt: string;
};
export type IBaseRow = {
id: string;
baseId: string;
cells: Record<string, unknown>;
position: string;
creatorId: string;
lastUpdatedById: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
};
export type ViewSortConfig = {
propertyId: string;
direction: 'asc' | 'desc';
};
// Matches the server's engine operator set (core/base/engine/schema.zod.ts).
export type FilterOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'ncontains'
| 'startsWith'
| 'endsWith'
| 'isEmpty'
| 'isNotEmpty'
| 'before'
| 'after'
| 'onOrBefore'
| 'onOrAfter'
| 'any'
| 'none'
| 'all';
export type FilterCondition = {
propertyId: string;
op: FilterOperator;
value?: unknown;
};
export type FilterGroup = {
op: 'and' | 'or';
children: Array<FilterCondition | FilterGroup>;
};
export type FilterNode = FilterCondition | FilterGroup;
export type SearchSpec = {
query: string;
mode?: 'trgm' | 'fts';
};
export type ViewConfig = {
sorts?: ViewSortConfig[];
filter?: FilterGroup;
visiblePropertyIds?: string[];
hiddenPropertyIds?: string[];
propertyWidths?: Record<string, number>;
propertyOrder?: string[];
};
export type IBaseView = {
id: string;
baseId: string;
name: string;
type: 'table' | 'kanban' | 'calendar';
config: ViewConfig;
workspaceId: string;
creatorId: string;
createdAt: string;
updatedAt: string;
};
export type IBase = {
id: string;
name: string;
description?: string;
icon?: string;
pageId?: string;
spaceId: string;
workspaceId: string;
creatorId: string;
properties: IBaseProperty[];
views: IBaseView[];
createdAt: string;
updatedAt: string;
};
export type EditingCell = {
rowId: string;
propertyId: string;
} | null;
export type CreateBaseInput = {
name: string;
description?: string;
icon?: string;
pageId?: string;
spaceId: string;
};
export type UpdateBaseInput = {
baseId: string;
name?: string;
description?: string;
icon?: string;
};
export type CreatePropertyInput = {
baseId: string;
name: string;
type: BasePropertyType;
typeOptions?: TypeOptions;
requestId?: string;
};
export type UpdatePropertyInput = {
propertyId: string;
baseId: string;
name?: string;
typeOptions?: TypeOptions;
requestId?: string;
};
export type DeletePropertyInput = {
propertyId: string;
baseId: string;
requestId?: string;
};
export type ReorderPropertyInput = {
propertyId: string;
baseId: string;
position: string;
requestId?: string;
};
export type CreateRowInput = {
baseId: string;
cells?: Record<string, unknown>;
afterRowId?: string;
requestId?: string;
};
export type UpdateRowInput = {
rowId: string;
baseId: string;
cells: Record<string, unknown>;
requestId?: string;
};
export type DeleteRowInput = {
rowId: string;
baseId: string;
requestId?: string;
};
export type DeleteRowsInput = {
baseId: string;
rowIds: string[];
requestId?: string;
};
export type ReorderRowInput = {
rowId: string;
baseId: string;
position: string;
requestId?: string;
};
export type CreateViewInput = {
baseId: string;
name: string;
type?: 'table' | 'kanban' | 'calendar';
config?: ViewConfig;
};
export type UpdateViewInput = {
viewId: string;
baseId: string;
name?: string;
type?: 'table' | 'kanban' | 'calendar';
config?: ViewConfig;
};
export type DeleteViewInput = {
viewId: string;
baseId: string;
};
export type UpdatePropertyResult = {
property: IBaseProperty;
// Non-null when the property change kicked off a BullMQ type-conversion
// job (select/multiSelect/person/file → anything, or any → system type).
// Client can listen for `base:schema:bumped` on the base room to know
// when the job finished migrating cells.
jobId: string | null;
};
+8
View File
@@ -0,0 +1,8 @@
import "@tanstack/react-table";
import { IBaseProperty } from "@/features/base/types/base.types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
property?: IBaseProperty;
}
}
+5 -1
View File
@@ -10,7 +10,11 @@ const api: AxiosInstance = axios.create({
api.interceptors.response.use( api.interceptors.response.use(
(response) => { (response) => {
// we need the response headers for these endpoints // we need the response headers for these endpoints
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"]; const exemptEndpoints = [
"/api/pages/export",
"/api/spaces/export",
"/api/bases/export-csv",
];
if (response.request.responseURL) { if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname; const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) { if (path && exemptEndpoints.includes(path)) {
+32
View File
@@ -0,0 +1,32 @@
import { useParams } from "react-router-dom";
import { Container, Title, Text, Stack } from "@mantine/core";
import { BaseTable } from "@/features/base/components/base-table";
import { useBaseQuery } from "@/features/base/queries/base-query";
export default function BasePage() {
const { baseId } = useParams<{ baseId: string }>();
const { data: base } = useBaseQuery(baseId);
if (!baseId) {
return (
<Stack align="center" p="xl">
<Text c="dimmed">No base ID provided</Text>
</Stack>
);
}
return (
<Container
fluid
p="md"
style={{ height: "calc(100vh - 60px)", display: "flex", flexDirection: "column" }}
>
{base && (
<Title order={3} mb="xs">
{base.icon ? `${base.icon} ` : ""}{base.name}
</Title>
)}
<BaseTable baseId={baseId} />
</Container>
);
}
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.71.1", "version": "0.80.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -37,6 +37,7 @@
"@aws-sdk/lib-storage": "3.1014.0", "@aws-sdk/lib-storage": "3.1014.0",
"@aws-sdk/s3-request-presigner": "3.1014.0", "@aws-sdk/s3-request-presigner": "3.1014.0",
"@clickhouse/client": "^1.18.2", "@clickhouse/client": "^1.18.2",
"@duckdb/node-api": "1.5.2-r.1",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
@@ -75,6 +76,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"csv-stringify": "^6",
"fast-bm25": "0.0.5", "fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0", "fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4", "fs-extra": "^11.3.4",
@@ -15,4 +15,26 @@ export enum EventName {
WORKSPACE_CREATED = 'workspace.created', WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted', WORKSPACE_DELETED = 'workspace.deleted',
BASE_CREATED = 'base.created',
BASE_UPDATED = 'base.updated',
BASE_DELETED = 'base.deleted',
BASE_ROW_CREATED = 'base.row.created',
BASE_ROW_UPDATED = 'base.row.updated',
BASE_ROW_DELETED = 'base.row.deleted',
BASE_ROWS_DELETED = 'base.rows.deleted',
BASE_ROW_RESTORED = 'base.row.restored',
BASE_ROW_REORDERED = 'base.row.reordered',
BASE_PROPERTY_CREATED = 'base.property.created',
BASE_PROPERTY_UPDATED = 'base.property.updated',
BASE_PROPERTY_DELETED = 'base.property.deleted',
BASE_PROPERTY_REORDERED = 'base.property.reordered',
BASE_VIEW_CREATED = 'base.view.created',
BASE_VIEW_UPDATED = 'base.view.updated',
BASE_VIEW_DELETED = 'base.view.deleted',
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
} }
+1
View File
@@ -18,6 +18,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls', SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer', VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates', TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
} as const; } as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature]; export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -47,6 +47,7 @@ import {
} from '../casl/interfaces/workspace-ability.type'; } from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
@@ -71,6 +72,7 @@ export class AttachmentController {
private readonly workspaceAbility: WorkspaceAbilityFactory, private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly baseRepo: BaseRepo,
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
@@ -163,6 +165,87 @@ export class AttachmentController {
} }
} }
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('bases/files/upload')
@UseInterceptors(FileInterceptor)
async uploadBaseFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const baseId = file.fields?.baseId?.value;
if (!baseId) {
throw new BadRequestException('baseId is required');
}
if (!isValidUUID(baseId)) {
throw new BadRequestException('Invalid baseId');
}
const base = await this.baseRepo.findById(baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const spaceId = base.spaceId;
const spaceAbilityCheck = await this.spaceAbility.createForUser(
user,
spaceId,
);
if (
spaceAbilityCheck.cannot(
SpaceCaslAction.Edit,
SpaceCaslSubject.Base,
)
) {
throw new ForbiddenException();
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@@ -43,7 +43,7 @@ export class AttachmentService {
async uploadFile(opts: { async uploadFile(opts: {
filePromise: Promise<MultipartFile>; filePromise: Promise<MultipartFile>;
pageId: string; pageId?: string;
userId: string; userId: string;
spaceId: string; spaceId: string;
workspaceId: string; workspaceId: string;
@@ -5,6 +5,8 @@ export enum JwtType {
ATTACHMENT = 'attachment', ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token', MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key', API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
} }
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
@@ -45,3 +47,15 @@ export type JwtApiKeyPayload = {
apiKeyId: string; apiKeyId: string;
type: 'api_key'; type: 'api_key';
}; };
export type JwtPdfRenderPayload = {
pageId: string;
workspaceId: string;
type: 'pdf_render';
};
export type JwtPdfExportDownloadPayload = {
fileTaskId: string;
workspaceId: string;
type: 'pdf_export_download';
};
@@ -13,6 +13,8 @@ import {
JwtExchangePayload, JwtExchangePayload,
JwtMfaTokenPayload, JwtMfaTokenPayload,
JwtPayload, JwtPayload,
JwtPdfExportDownloadPayload,
JwtPdfRenderPayload,
JwtType, JwtType,
} from '../dto/jwt-payload'; } from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
@@ -115,6 +117,30 @@ export class TokenService {
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {}); return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
} }
async generatePdfRenderToken(
pageId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfRenderPayload = {
pageId,
workspaceId,
type: JwtType.PDF_RENDER,
};
return this.jwtService.sign(payload, { expiresIn: '60s' });
}
async generatePdfExportDownloadToken(
fileTaskId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfExportDownloadPayload = {
fileTaskId,
workspaceId,
type: JwtType.PDF_EXPORT_DOWNLOAD,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) { async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(), secret: this.environmentService.getAppSecret(),
+50
View File
@@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BaseController } from './controllers/base.controller';
import { BasePropertyController } from './controllers/base-property.controller';
import { BaseRowController } from './controllers/base-row.controller';
import { BaseViewController } from './controllers/base-view.controller';
import { BaseService } from './services/base.service';
import { BasePropertyService } from './services/base-property.service';
import { BaseRowService } from './services/base-row.service';
import { BaseViewService } from './services/base-view.service';
import { BaseCsvExportService } from './services/base-csv-export.service';
import { BaseQueueProcessor } from './processors/base-queue.processor';
import { BaseWsService } from './realtime/base-ws.service';
import { BaseWsConsumers } from './realtime/base-ws-consumers';
import { BasePresenceService } from './realtime/base-presence.service';
import { QueueName } from '../../integrations/queue/constants';
import { QueryCacheModule } from './query-cache/query-cache.module';
@Module({
imports: [
BullModule.registerQueue({ name: QueueName.BASE_QUEUE }),
QueryCacheModule,
],
controllers: [
BaseController,
BasePropertyController,
BaseRowController,
BaseViewController,
],
providers: [
BaseService,
BasePropertyService,
BaseRowService,
BaseViewService,
BaseCsvExportService,
BaseQueueProcessor,
BasePresenceService,
BaseWsService,
BaseWsConsumers,
],
exports: [
BaseService,
BasePropertyService,
BaseRowService,
BaseViewService,
BaseWsService,
BasePresenceService,
],
})
export class BaseModule {}
+381
View File
@@ -0,0 +1,381 @@
import { z } from 'zod';
export const BasePropertyType = {
TEXT: 'text',
NUMBER: 'number',
SELECT: 'select',
STATUS: 'status',
MULTI_SELECT: 'multiSelect',
DATE: 'date',
PERSON: 'person',
FILE: 'file',
CHECKBOX: 'checkbox',
URL: 'url',
EMAIL: 'email',
CREATED_AT: 'createdAt',
LAST_EDITED_AT: 'lastEditedAt',
LAST_EDITED_BY: 'lastEditedBy',
} as const;
const SYSTEM_PROPERTY_TYPES: Set<string> = new Set([
BasePropertyType.CREATED_AT,
BasePropertyType.LAST_EDITED_AT,
BasePropertyType.LAST_EDITED_BY,
]);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
export type BasePropertyTypeValue =
(typeof BasePropertyType)[keyof typeof BasePropertyType];
export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
export const choiceSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
color: z.string(),
category: z.enum(['todo', 'inProgress', 'complete']).optional(),
});
export const selectTypeOptionsSchema = z
.object({
choices: z.array(choiceSchema).default([]),
choiceOrder: z.array(z.uuid()).default([]),
disableColors: z.boolean().optional(),
defaultValue: z
.union([z.uuid(), z.array(z.uuid())])
.nullable()
.optional(),
})
.passthrough();
export const numberTypeOptionsSchema = z
.object({
format: z
.enum(['plain', 'currency', 'percent', 'progress'])
.optional()
.default('plain'),
precision: z.number().int().min(0).max(10).optional(),
currencySymbol: z.string().max(5).optional(),
defaultValue: z.number().nullable().optional(),
})
.passthrough();
export const dateTypeOptionsSchema = z
.object({
dateFormat: z.string().optional(),
timeFormat: z.enum(['12h', '24h']).optional(),
includeTime: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const textTypeOptionsSchema = z
.object({
richText: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const checkboxTypeOptionsSchema = z
.object({
defaultValue: z.boolean().optional(),
})
.passthrough();
export const urlTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const emailTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const personTypeOptionsSchema = z
.object({
allowMultiple: z.boolean().default(true),
})
.passthrough();
export const emptyTypeOptionsSchema = z.object({}).passthrough();
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
[BasePropertyType.TEXT]: textTypeOptionsSchema,
[BasePropertyType.NUMBER]: numberTypeOptionsSchema,
[BasePropertyType.SELECT]: selectTypeOptionsSchema,
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
[BasePropertyType.DATE]: dateTypeOptionsSchema,
[BasePropertyType.PERSON]: personTypeOptionsSchema,
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
[BasePropertyType.URL]: urlTypeOptionsSchema,
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
[BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
};
export function validateTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): z.ZodSafeParseResult<unknown> {
const schema = typeOptionsSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(typeOptions ?? {});
}
export function parseTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): unknown {
const result = validateTypeOptions(type, typeOptions);
if (!result.success) {
throw result.error;
}
return result.data;
}
const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
[BasePropertyType.TEXT]: z.string(),
[BasePropertyType.NUMBER]: z.number(),
[BasePropertyType.SELECT]: z.uuid(),
[BasePropertyType.STATUS]: z.uuid(),
[BasePropertyType.MULTI_SELECT]: z.array(z.uuid()),
[BasePropertyType.DATE]: z.string(),
[BasePropertyType.PERSON]: z.union([z.uuid(), z.array(z.uuid())]),
[BasePropertyType.FILE]: z.array(z.object({
id: z.uuid(),
fileName: z.string(),
mimeType: z.string().optional(),
fileSize: z.number().optional(),
filePath: z.string().optional(),
})),
[BasePropertyType.CHECKBOX]: z.boolean(),
[BasePropertyType.URL]: z.url(),
[BasePropertyType.EMAIL]: z.email(),
};
export function getCellValueSchema(
type: BasePropertyTypeValue,
): z.ZodType | undefined {
return cellValueSchemaMap[type];
}
export function validateCellValue(
type: BasePropertyTypeValue,
value: unknown,
): z.ZodSafeParseResult<unknown> {
const schema = cellValueSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(value);
}
/*
* Resolution context for conversions where the source type stores IDs
* (select / multiSelect: choice uuid; person: user uuid; file: attachment
* uuid). Callers must always supply this — the only invoker is the
* `BASE_TYPE_CONVERSION` BullMQ worker, which builds the context per
* chunk of rows (see `tasks/base-type-conversion.task.ts`).
*/
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
};
function resolveChoiceName(
typeOptions: unknown,
id: unknown,
): string | undefined {
if (!typeOptions || typeof typeOptions !== 'object') return undefined;
const choices = (typeOptions as any).choices;
if (!Array.isArray(choices)) return undefined;
const match = choices.find((c: any) => c?.id === String(id));
return typeof match?.name === 'string' ? match.name : undefined;
}
export function attemptCellConversion(
fromType: BasePropertyTypeValue,
toType: BasePropertyTypeValue,
value: unknown,
ctx: CellConversionContext,
): { converted: boolean; value: unknown } {
if (value === null || value === undefined) {
return { converted: true, value: null };
}
// Resolve IDs to display strings before any direct parse. `select → text`
// and `multiSelect → text` would otherwise short-circuit on z.string()
// parsing the UUID itself and return the raw UUID instead of the name.
if (toType === BasePropertyType.TEXT) {
if (
fromType === BasePropertyType.SELECT ||
fromType === BasePropertyType.STATUS
) {
const name = resolveChoiceName(ctx.fromTypeOptions, value);
return { converted: true, value: name ?? '' };
}
if (fromType === BasePropertyType.MULTI_SELECT && Array.isArray(value)) {
const parts = value
.map((v) => resolveChoiceName(ctx.fromTypeOptions, v))
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
if (fromType === BasePropertyType.PERSON && ctx.userNames) {
const ids = Array.isArray(value) ? value : [value];
const parts = ids
.map((v) => ctx.userNames!.get(String(v)))
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
if (fromType === BasePropertyType.FILE && Array.isArray(value)) {
const parts = value
.map((f: any) => {
if (f && typeof f === 'object') {
if (typeof f.fileName === 'string') return f.fileName;
if (typeof f.id === 'string' && ctx.attachmentNames) {
return ctx.attachmentNames.get(f.id);
}
}
if (typeof f === 'string' && ctx.attachmentNames) {
return ctx.attachmentNames.get(f);
}
return undefined;
})
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
}
const targetSchema = cellValueSchemaMap[toType];
if (!targetSchema) {
return { converted: false, value: null };
}
const directResult = targetSchema.safeParse(value);
if (directResult.success) {
return { converted: true, value: directResult.data };
}
if (toType === BasePropertyType.TEXT) {
return { converted: true, value: String(value) };
}
if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
const num = Number(value);
if (!isNaN(num)) {
return { converted: true, value: num };
}
}
if (toType === BasePropertyType.CHECKBOX) {
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true' || lower === '1' || lower === 'yes') {
return { converted: true, value: true };
}
if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
return { converted: true, value: false };
}
}
if (typeof value === 'number') {
return { converted: true, value: value !== 0 };
}
}
if (
toType === BasePropertyType.MULTI_SELECT &&
fromType === BasePropertyType.SELECT &&
typeof value === 'string'
) {
return { converted: true, value: [value] };
}
if (
toType === BasePropertyType.SELECT &&
fromType === BasePropertyType.MULTI_SELECT &&
Array.isArray(value) &&
value.length > 0
) {
return { converted: true, value: value[0] };
}
return { converted: false, value: null };
}
export const viewSortSchema = z.object({
propertyId: z.uuid(),
direction: z.enum(['asc', 'desc']),
});
/*
* View-stored filter shape matches the engine's predicate tree (see
* `core/base/engine/schema.zod.ts`). No legacy flat-array / operator-name
* variants are accepted — stored view configs use `op` (eq / neq / gt /
* lt / contains / ncontains / ...) and nested and/or groups.
*/
const viewFilterConditionSchema = z.object({
propertyId: z.uuid(),
op: z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'ncontains',
'startsWith',
'endsWith',
'isEmpty',
'isNotEmpty',
'before',
'after',
'onOrBefore',
'onOrAfter',
'any',
'none',
'all',
]),
value: z.unknown().optional(),
});
type ViewFilterCondition = z.infer<typeof viewFilterConditionSchema>;
type ViewFilterGroup = {
op: 'and' | 'or';
children: Array<ViewFilterCondition | ViewFilterGroup>;
};
const viewFilterNodeSchema: z.ZodType<ViewFilterCondition | ViewFilterGroup> =
z.lazy(() => z.union([viewFilterConditionSchema, viewFilterGroupSchema]));
const viewFilterGroupSchema: z.ZodType<ViewFilterGroup> = z.lazy(() =>
z.object({
op: z.enum(['and', 'or']),
children: z.array(viewFilterNodeSchema),
}),
);
export const viewConfigSchema = z
.object({
sorts: z.array(viewSortSchema).optional(),
filter: viewFilterGroupSchema.optional(),
visiblePropertyIds: z.array(z.uuid()).optional(),
hiddenPropertyIds: z.array(z.uuid()).optional(),
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
propertyOrder: z.array(z.uuid()).optional(),
})
.passthrough();
export type ViewConfig = z.infer<typeof viewConfigSchema>;
@@ -0,0 +1,117 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BasePropertyService } from '../services/base-property.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreatePropertyDto } from '../dto/create-property.dto';
import {
UpdatePropertyDto,
DeletePropertyDto,
ReorderPropertyDto,
} from '../dto/update-property.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/properties')
export class BasePropertyController {
constructor(
private readonly basePropertyService: BasePropertyService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreatePropertyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.basePropertyService.create(workspace.id, dto, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() dto: UpdatePropertyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.basePropertyService.update(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(
@Body() dto: DeletePropertyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.basePropertyService.delete(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('reorder')
async reorder(
@Body() dto: ReorderPropertyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.basePropertyService.reorder(dto, workspace.id, user.id);
}
}
@@ -0,0 +1,182 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseRowService } from '../services/base-row.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateRowDto } from '../dto/create-row.dto';
import {
UpdateRowDto,
DeleteRowDto,
DeleteRowsDto,
RowIdDto,
ListRowsDto,
ReorderRowDto,
} from '../dto/update-row.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/rows')
export class BaseRowController {
constructor(
private readonly baseRowService: BaseRowService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateRowDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getRow(
@Body() dto: RowIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.getRowInfo(dto.rowId, dto.baseId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() dto: UpdateRowDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.update(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(
@Body() dto: DeleteRowDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseRowService.delete(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete-many')
async deleteMany(
@Body() dto: DeleteRowsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseRowService.deleteMany(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post()
async list(
@Body() dto: ListRowsDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseRowService.list(dto, pagination, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('reorder')
async reorder(
@Body() dto: ReorderRowDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseRowService.reorder(dto, workspace.id, user.id);
}
}
@@ -0,0 +1,114 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { BaseViewService } from '../services/base-view.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateViewDto } from '../dto/create-view.dto';
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
import { BaseIdDto } from '../dto/base.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
@UseGuards(JwtAuthGuard)
@Controller('bases/views')
export class BaseViewController {
constructor(
private readonly baseViewService: BaseViewService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateViewDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() dto: UpdateViewDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.update(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(
@Body() dto: DeleteViewDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseViewService.delete(dto, workspace.id, user.id);
}
@HttpCode(HttpStatus.OK)
@Post()
async list(
@Body() dto: BaseIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseViewService.listByBaseId(dto.baseId, workspace.id);
}
}
@@ -0,0 +1,141 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { BaseService } from '../services/base.service';
import { BaseCsvExportService } from '../services/base-csv-export.service';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { CreateBaseDto } from '../dto/create-base.dto';
import { UpdateBaseDto } from '../dto/update-base.dto';
import { BaseIdDto } from '../dto/base.dto';
import { ExportBaseCsvDto } from '../dto/export-base.dto';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import { SpaceIdDto } from '../../space/dto/space-id.dto';
@UseGuards(JwtAuthGuard)
@Controller('bases')
export class BaseController {
constructor(
private readonly baseService: BaseService,
private readonly baseCsvExportService: BaseCsvExportService,
private readonly baseRepo: BaseRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateBaseDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.create(user.id, workspace.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async getBase(@Body() dto: BaseIdDto, @AuthUser() user: User) {
const base = await this.baseService.getBaseInfo(dto.baseId);
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return base;
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateBaseDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.update(dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() dto: BaseIdDto, @AuthUser() user: User) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseService.delete(dto.baseId);
}
@HttpCode(HttpStatus.OK)
@Post()
async list(
@Body() dto: SpaceIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
return this.baseService.listBySpaceId(dto.spaceId, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('export-csv')
async exportCsv(
@Body() dto: ExportBaseCsvDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Res() res: FastifyReply,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseCsvExportService.streamBaseAsCsv(
dto.baseId,
workspace.id,
res,
);
}
}
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class BaseIdDto {
@IsUUID()
baseId: string;
}
@@ -0,0 +1,22 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateBaseDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
icon?: string;
@IsOptional()
@IsUUID()
pageId?: string;
@IsUUID()
spaceId: string;
}
@@ -0,0 +1,25 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { BASE_PROPERTY_TYPES } from '../base.schemas';
export class CreatePropertyDto {
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
name: string;
@IsIn(BASE_PROPERTY_TYPES)
type: string;
@IsOptional()
@IsObject()
typeOptions?: Record<string, unknown>;
}
@@ -0,0 +1,20 @@
import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateRowDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsObject()
cells?: Record<string, unknown>;
@IsOptional()
@IsUUID()
afterRowId?: string;
// Echoed back in the socket event so the originating client can skip
// replaying its own write.
@IsOptional()
@IsString()
requestId?: string;
}
@@ -0,0 +1,25 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class CreateViewDto {
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsIn(['table', 'kanban', 'calendar'])
type?: string;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class ExportBaseCsvDto {
@IsUUID()
baseId: string;
}
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateBaseDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
icon?: string;
}
@@ -0,0 +1,67 @@
import {
IsEmpty,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UpdatePropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
/*
* Type changes are intentionally not exposed via the API in v1. The
* conversion engine in apps/server/src/core/base/engine/ and the
* worker in tasks/base-type-conversion.task.ts remain intact for
* a future v2 re-wire. Requests including `type` are rejected here
* so the service's type-change branches stay unreachable.
*/
@IsEmpty()
type?: string;
@IsOptional()
@IsObject()
typeOptions?: Record<string, unknown>;
@IsOptional()
@IsString()
requestId?: string;
}
export class DeletePropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
requestId?: string;
}
export class ReorderPropertyDto {
@IsUUID()
propertyId: string;
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
position: string;
@IsOptional()
@IsString()
requestId?: string;
}
@@ -0,0 +1,115 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
IsArray,
ArrayMinSize,
ArrayMaxSize,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
// `filter` / `search` shapes are validated by the engine's Zod schemas
// at the service boundary (`core/base/engine/schema.zod.ts`).
export class UpdateRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
@IsObject()
cells: Record<string, unknown>;
@IsOptional()
@IsString()
requestId?: string;
}
export class DeleteRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
requestId?: string;
}
export class RowIdDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
}
class SortDto {
@IsUUID()
propertyId: string;
@IsIn(['asc', 'desc'])
direction: 'asc' | 'desc';
}
export class ListRowsDto {
@IsUUID()
baseId: string;
@IsOptional()
@IsUUID()
viewId?: string;
// Compound filter tree. Shape validated by the engine's Zod schema at
// the service boundary.
@IsOptional()
@IsObject()
filter?: unknown;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SortDto)
sorts?: SortDto[];
// `{ query, mode? }` — Zod-validated at the service boundary.
@IsOptional()
@IsObject()
search?: unknown;
}
export class ReorderRowDto {
@IsUUID()
rowId: string;
@IsUUID()
baseId: string;
@IsString()
@IsNotEmpty()
position: string;
@IsOptional()
@IsString()
requestId?: string;
}
export class DeleteRowsDto {
@IsUUID()
baseId: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@IsUUID('all', { each: true })
rowIds: string[];
@IsOptional()
@IsString()
requestId?: string;
}
@@ -0,0 +1,37 @@
import {
IsIn,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class UpdateViewDto {
@IsUUID()
viewId: string;
@IsUUID()
baseId: string;
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsIn(['table', 'kanban', 'calendar'])
type?: string;
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
}
export class DeleteViewDto {
@IsUUID()
viewId: string;
@IsUUID()
baseId: string;
}
+111
View File
@@ -0,0 +1,111 @@
import { BadRequestException } from '@nestjs/common';
import { SortBuild, TailKey } from './sort';
type ValueType = 'numeric' | 'date' | 'bool' | 'text';
// Hard cap on decoded cursor size so a tampered cursor can't force a large
// JSON parse. Real cursors are <1KB (a handful of field values).
const MAX_CURSOR_DECODED_BYTES = 4096;
/*
* Null-safe cursor encoder. The previous encoder used a literal string
* sentinel `__null__` for NULLs, which could collide with real cell
* values. This encoder never sees NULL because sort expressions are
* sentinel-wrapped (see sort.ts). It also represents ±Infinity
* explicitly so JSON round-tripping is lossless.
*/
export function makeCursor(sorts: SortBuild[], tailKeys: TailKey[]) {
const types = new Map<string, ValueType>();
for (const s of sorts) types.set(s.key, s.valueType);
for (const k of tailKeys) types.set(k, 'text');
return {
encodeCursor(values: Array<[string, unknown]>): string {
const payload: Record<string, string> = {};
for (const [k, v] of values) {
payload[k] = encodeValue(v, types.get(k) ?? 'text');
}
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
},
decodeCursor(
cursor: string,
fieldNames: string[],
): Record<string, string> {
let parsed: Record<string, string>;
try {
parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
} catch {
throw new BadRequestException('Invalid cursor');
}
if (typeof parsed !== 'object' || parsed === null) {
throw new BadRequestException('Invalid cursor payload');
}
const out: Record<string, string> = {};
for (const name of fieldNames) {
if (!(name in parsed)) {
throw new BadRequestException(`Cursor missing field: ${name}`);
}
out[name] = parsed[name];
}
return out;
},
parseCursor(decoded: Record<string, string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, raw] of Object.entries(decoded)) {
out[k] = decodeValue(raw, types.get(k) ?? 'text');
}
return out;
},
};
}
function encodeValue(value: unknown, type: ValueType): string {
if (type === 'numeric') {
if (value === null || value === undefined) return '';
const n = typeof value === 'number' ? value : parseFloat(String(value));
if (n === Number.POSITIVE_INFINITY || String(value) === 'Infinity') {
return 'inf';
}
if (n === Number.NEGATIVE_INFINITY || String(value) === '-Infinity') {
return '-inf';
}
if (Number.isNaN(n)) return '';
return String(n);
}
if (type === 'date') {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString();
const s = String(value);
if (s === 'infinity') return 'inf';
if (s === '-infinity') return '-inf';
return s;
}
if (type === 'bool') {
return value ? '1' : '0';
}
return value == null ? '' : String(value);
}
function decodeValue(raw: string, type: ValueType): unknown {
if (type === 'numeric') {
if (raw === 'inf') return Number.POSITIVE_INFINITY;
if (raw === '-inf') return Number.NEGATIVE_INFINITY;
if (raw === '') return null;
return parseFloat(raw);
}
if (type === 'date') {
if (raw === 'inf') return 'infinity';
if (raw === '-inf') return '-infinity';
if (raw === '') return null;
return raw;
}
if (type === 'bool') {
return raw === '1';
}
return raw;
}
@@ -0,0 +1,86 @@
import { SelectQueryBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { BaseRow } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
CursorPaginationResult,
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { FilterNode, SearchSpec, SortSpec } from './schema.zod';
import { buildWhere, PropertySchema } from './predicate';
import { buildSorts, CURSOR_TAIL_KEYS, SortBuild } from './sort';
import { buildSearch } from './search';
import { makeCursor } from './cursor';
export type EngineListOpts = {
filter?: FilterNode;
sorts?: SortSpec[];
search?: SearchSpec;
schema: PropertySchema;
pagination: PaginationOptions;
};
/*
* Top-level orchestrator. Callers (repos, services) provide a base
* Kysely query already scoped to the target base + workspace + alive
* rows; this adds search/filter/sort clauses and runs cursor pagination.
*/
export async function runListQuery(
base: SelectQueryBuilder<DB, 'baseRows', any>,
opts: EngineListOpts,
): Promise<CursorPaginationResult<BaseRow>> {
let qb = base;
if (opts.search) {
const spec = opts.search;
qb = qb.where((eb) => buildSearch(eb, spec));
}
if (opts.filter) {
const filter = opts.filter;
qb = qb.where((eb) => buildWhere(eb, filter, opts.schema));
}
const sortBuilds: SortBuild[] =
opts.sorts && opts.sorts.length > 0
? buildSorts(opts.sorts, opts.schema)
: [];
for (const sb of sortBuilds) {
qb = qb.select(sb.expression.as(sb.key)) as SelectQueryBuilder<
DB,
'baseRows',
any
>;
}
const cursor = makeCursor(sortBuilds, CURSOR_TAIL_KEYS);
const fields = [
...sortBuilds.map((sb) => ({
expression: sb.expression,
direction: sb.direction,
key: sb.key,
})),
{
expression: 'position' as const,
direction: 'asc' as const,
key: 'position' as const,
},
{
expression: 'id' as const,
direction: 'asc' as const,
key: 'id' as const,
},
];
return executeWithCursorPagination(qb as any, {
perPage: opts.pagination.limit,
cursor: opts.pagination.cursor,
beforeCursor: opts.pagination.beforeCursor,
fields: fields as any,
encodeCursor: cursor.encodeCursor as any,
decodeCursor: cursor.decodeCursor as any,
parseCursor: cursor.parseCursor as any,
}) as unknown as Promise<CursorPaginationResult<BaseRow>>;
}

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