Compare commits

..

42 Commits

Author SHA1 Message Date
Philip Okugbe 70d2ff8685 fix: increase s3 client max socket limit (#2355) 2026-07-21 22:59:25 +01:00
Philipinho 0ba2d78660 chore: update pnpm and axios 2026-07-20 20:46:02 +01:00
Philip Okugbe 3ed505d2be fix: toolbar flicker on navigation (#2352) 2026-07-20 20:36:03 +01:00
Philip Okugbe a55057db37 feat: collab hocuspocus v4 upgrade (#2351)
* WIP 1

* complete v4 migration

* add flushDelay

* feat: multiplexing

* fix: survive hocuspocus v4 message timeout

* fix: searchTerm type error
2026-07-20 20:34:09 +01:00
Philip Okugbe ce8fbb86ff chore: upgrade to node 26 and pnpm 11 (#2342)
* chore: pnpm 11

* chore: set minimum age and allowedBuilds

* Node 26; pnpm 11

* prod package overrides

* dev package overides

* cleanup
2026-07-20 19:44:51 +01:00
Philipinho e28df6ad88 fix 2026-07-04 14:11:44 +01:00
Philip Okugbe 4132dd597c New Crowdin updates (#2323) 2026-07-03 11:57:07 +01:00
Philip Okugbe 8a2c457ab9 feat: kanban group editing (#2322)
* feat: support opening property menu directly on options panel

* refactor: expose group-by property from useKanbanColumns

* feat: rename kanban group by clicking column name

* feat: edit group-by property from kanban column menu

* fix: license-gate base and kanban inserts

* fix: add missing bases strings to en-US translation file
2026-07-03 11:35:26 +01:00
Philipinho 6ff7f69137 fix: file import page title 2026-06-30 02:45:07 +01:00
Philipinho 481aae5507 sync 2026-06-30 02:36:52 +01:00
Philipinho 12d68765d9 v0.95.0 2026-06-30 01:33:34 +01:00
Philipinho 27a80b57f8 sync 2026-06-30 01:32:19 +01:00
Philip Okugbe 15bdf680d9 feat: workspace default edit preference (#2319) 2026-06-30 01:10:51 +01:00
Philipinho 96faff7175 fix: reactive subpage list 2026-06-29 19:58:22 +01:00
Philipinho 2959504470 fix tree merge 2026-06-29 19:02:21 +01:00
Philip Okugbe 37a88d171a New Crowdin updates (#2318) 2026-06-29 19:01:02 +01:00
Philip Okugbe c86d556888 fix: templates ui fixes (#2316)
* fix: editor menus
* fix: templates creation UI gating
2026-06-29 12:32:09 +01:00
Philipinho e8efef235c fix: tooltip on focus 2026-06-29 00:22:59 +01:00
Philipinho 04110884b4 better zip handling 2026-06-28 23:45:38 +01:00
Philipinho b88833277d fix: default radius 2026-06-27 21:25:45 +01:00
Philipinho 186a0bf71b sync 2026-06-27 00:49:39 +01:00
Philipinho 303b6a048f fix: cleanup title 2026-06-27 00:40:19 +01:00
Philipinho 4aa4feb41f fix: validate dto object 2026-06-27 00:15:35 +01:00
Philip Okugbe 4cf47f4538 New Crowdin updates (#2309) 2026-06-26 12:00:29 +01:00
Philipinho ccf61f3f84 fix multi-select 2026-06-26 11:54:21 +01:00
Philipinho d469558c1e sync 2026-06-25 01:39:31 +01:00
Philipinho fca5619eb7 fix: adjust spacing 2026-06-22 00:50:38 +01:00
Philipinho b7f1dd3a6b fix: spacing 2026-06-22 00:50:09 +01:00
Philipinho 5658d30484 sync 2026-06-21 03:09:40 +01:00
Philip Okugbe 89f13c3fbc feat(ee): bases (#2295)
* feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension.

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

* feat(base): add focused-cell atom and cell coordinate types

* feat(base): add cell focus-ring style

* feat(base): add pure next-cell navigation helper

* feat(base): keyboard navigation controller and grid wiring

* update offerings

* feat(base): cell focus ring, click-to-focus, and gridcell ARIA

* feat(base): row ARIA index and selected state

* feat(base): seed editor value on type-to-edit for free-text cells

* feat(base): make column headers keyboard-focusable as tab stops

* fix(base): remove focus outline on grid container

* fix(base): show cell focus ring only while the grid is focused

* feat(base): keyboard-navigate the row-number column for selection

* fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order

* fix(base): tab from long-text editor moves to next cell instead of leaving the table

* fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop

* fix(base): show cell focus ring only while the grid body itself is focused

* fix(base): render view-tab rename as an inline pill so the tab band height stays put

* fix(base): refer to the feature as 'base' rather than 'database'

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

* fix: preserve sidebar title/icon on partial page updates

* fix: skip duplicate row fetch when opening new kanban card

* fix refetch

* fix focus

* fix spacing

* fix(base): select grid cell on mousedown to avoid stale focus ring flash

The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.

* fix: activate New row button via keyboard (Enter/Space)

The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.

* fix(base): keep add-property popover within viewport on mobile

Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.

* fix(base): enable grid cell editing on touch devices

Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.

* feat(editor): add base and kanban inserts to the toolbar

* feat(base): insert row below via Shift+Enter on the primary cell

* fix(base): place caret at end instead of selecting all when editing cells

* fix(base): prevent popover inputs from losing focus on mobile in row detail modal

* fix grid cells on mobile

* sync

* fix: read-only export

* feat(base): add prefixed nanoid id schemas and generators

* feat(base): enforce strict property/choice id validation

* feat(base): make property id varchar with per-base composite pk

* feat(base): pass property id as text to cell extractors

* feat(base): scope property lookups per base and generate property ids in repo

* feat(base): generate status template choice ids as nanoid

* feat(base): generate choice ids as nanoid on the client

* chore(base): seed choice ids with nanoid

* fix(base): mint kanban choice ids as nanoid

* sync

* sync

* sync
2026-06-21 03:07:14 +01:00
Philipinho e0fdc0c178 chore: update dot ignore files 2026-06-21 02:06:39 +01:00
Philipinho e44ce2714f fix: safe editor 2026-06-20 23:12:32 +01:00
Philipinho d99d321e4a update dependencies 2026-06-20 18:24:19 +01:00
Philip Okugbe 4f202e4ab5 feat: package updates (#2300)
* package updates
* update tiptap
* fix editor crash
* fix
* update list
* fix(theme): size badges to their content
* fix mantine avatar color regression
2026-06-20 18:11:14 +01:00
Philipinho d7fdcdec80 sync 2026-06-20 14:34:12 +01:00
Abhigyan Sinha 2dc35fa100 fix: resolve draw.io save failure on copied blocks (#2288) (#2297) 2026-06-20 14:31:16 +01:00
Philip Okugbe 4f2fbf22d4 chore: migrate to Mantine 9 and React 19 (#2293) 2026-06-20 14:28:31 +01:00
Philip Okugbe d68e241f45 feat(ee): personal spaces (#2298)
* feat(ee): personal spaces

* pref

* feat: on-demand only

* error notification
2026-06-20 14:27:41 +01:00
Philip Okugbe 510199cf04 feat(ee): docx word export (#2294)
* vendorize  prosemirror-docx - wip
* feat(ee): docx word export
* sync
2026-06-20 14:23:56 +01:00
Philip Okugbe aa5d52ad3e feat(editor): add /time slash command to insert current time (#2290) 2026-06-20 14:22:54 +01:00
Mayank-2-16 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
355 changed files with 30646 additions and 4066 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules
.git
dist
**/node_modules
**/.git
**/dist
/data
.env*
.nx
**/.env*
**/.nx
+1 -1
View File
@@ -3,7 +3,7 @@
.env.prod
data
# compiled output
/dist
dist
/node_modules
# Logs
-1
View File
@@ -1 +0,0 @@
shamefully-hoist = true
+4 -3
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
RUN npm install -g pnpm@11.15.1
FROM base AS builder
@@ -28,11 +28,12 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
# Copy packages
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
+21 -18
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.1",
"version": "0.95.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -18,30 +18,32 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
"@mantine/hooks": "9.3.2",
"@mantine/modals": "9.3.2",
"@mantine/notifications": "9.3.2",
"@mantine/spotlight": "9.3.2",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"axios": "1.18.1",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"file-saver": "2.0.5",
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai": "2.20.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
@@ -50,15 +52,16 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"nanoid": "3.3.8",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-dom": "19.2.7",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.13.1",
"react-router-dom": "7.18.0",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -73,8 +76,8 @@
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
@@ -89,7 +92,7 @@
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite": "8.0.16",
"vitest": "4.1.6"
}
}
@@ -41,6 +41,8 @@
"Dark": "Dunkel",
"Date": "Datum",
"Delete": "Löschen",
"Remove from page": "Von Seite entfernen",
"Base options": "Basisoptionen",
"Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung",
@@ -76,6 +78,24 @@
"Failed to import pages": "Import der Seiten fehlgeschlagen",
"Failed to load page. An error occurred.": "Seite konnte nicht geladen werden. Es ist ein Fehler aufgetreten.",
"Failed to update data": "Aktualisierung der Daten fehlgeschlagen",
"Failed to create base": "Basis konnte nicht erstellt werden",
"Failed to update base": "Basis konnte nicht aktualisiert werden",
"Failed to delete base": "Basis konnte nicht gelöscht werden",
"Failed to create property": "Eigenschaft konnte nicht erstellt werden",
"Failed to update property": "Eigenschaft konnte nicht aktualisiert werden",
"Failed to delete property": "Eigenschaft konnte nicht gelöscht werden",
"Failed to reorder property": "Eigenschaft konnte nicht neu angeordnet werden",
"Failed to create view": "Ansicht konnte nicht erstellt werden",
"Failed to update view": "Ansicht konnte nicht aktualisiert werden",
"Failed to delete view": "Ansicht konnte nicht gelöscht werden",
"Failed to create row": "Zeile konnte nicht erstellt werden",
"Failed to update row": "Zeile konnte nicht aktualisiert werden",
"Failed to delete row": "Zeile konnte nicht gelöscht werden",
"Failed to delete rows": "Zeilen konnten nicht gelöscht werden",
"Failed to reorder row": "Zeile konnte nicht neu angeordnet werden",
"Failed to move card": "Karte konnte nicht verschoben werden",
"Failed to add card": "Karte konnte nicht hinzugefügt werden",
"Failed to export CSV": "CSV konnte nicht exportiert werden",
"Favorite spaces": "Favorisierte Bereiche",
"Favorite spaces appear here": "Favorisierte Bereiche werden hier angezeigt",
"Favorites": "Favoriten",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid-Diagramm einfügen",
"Insert and design Drawio diagrams": "Drawio-Diagramme einfügen und gestalten",
"Insert current date": "Aktuelles Datum einfügen",
"Time": "Uhrzeit",
"Insert current time": "Aktuelle Uhrzeit einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrfach",
"Turn into": "In verwandeln",
@@ -424,6 +446,7 @@
"Names do not match": "Namen stimmen nicht überein",
"Today, {{time}}": "Heute, {{time}}",
"Yesterday, {{time}}": "Gestern, {{time}}",
"now": "jetzt",
"Space created successfully": "Bereich erfolgreich erstellt",
"Space updated successfully": "Bereich erfolgreich aktualisiert",
"Space deleted successfully": "Bereich erfolgreich gelöscht",
@@ -596,6 +619,8 @@
"Deleted by": "Gelöscht von",
"Deleted at": "Gelöscht am",
"Preview": "Vorschau",
"Base preview unavailable": "Basisvorschau nicht verfügbar",
"Restore this base to view its contents.": "Stelle diese Basis wieder her, um ihren Inhalt anzuzeigen.",
"Subpages": "Unterseiten",
"Failed to load subpages": "Unterseiten konnten nicht geladen werden",
"No subpages": "Keine Unterseiten",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Sind Sie sicher, dass Sie diese Vorlage löschen möchten?",
"Template scope updated": "Vorlagenbereich aktualisiert",
"Choose which space this template belongs to": "Wählen Sie den Bereich aus, zu dem diese Vorlage gehört",
"Only workspace admins can create global templates.": "Nur Workspace-Admins können globale Vorlagen erstellen.",
"You need edit access to this space to create templates.": "Sie benötigen Bearbeitungszugriff auf diesen Space, um Vorlagen zu erstellen.",
"Scope": "Bereich",
"Select scope": "Bereich auswählen",
"Title": "Titel",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Seiten und Bereiche suchen...",
"No results found": "Keine Ergebnisse gefunden",
"You don't have permission to create pages here": "Sie haben hier keine Berechtigung, Seiten zu erstellen",
"Chat menu": "Chatmenü",
"Chat menu for {{title}}": "Chat-Menü für {{title}}",
"API key menu": "API-Schlüssel-Menü",
"Jump to comment selection": "Zur Kommentarauswahl springen",
"Slash commands": "Slash-Befehle",
@@ -1063,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Seitentitel",
"Page content": "Seiteninhalt",
"Member actions": "Mitgliederaktionen",
"Member actions for {{name}}": "Mitgliederaktionen für {{name}}",
"Toggle password visibility": "Passwortsichtbarkeit umschalten",
"Send comment": "Kommentar senden",
"Token actions": "Token-Aktionen",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"Allow personal spaces": "Persönliche Bereiche erlauben",
"Members can create their own personal space.": "Mitglieder können ihren eigenen persönlichen Bereich erstellen.",
"Toggle allow personal spaces": "Persönliche Bereiche erlauben umschalten",
"Create personal space": "Persönlichen Bereich erstellen",
"Personal space": "Persönlicher Bereich",
"{{name}}'s space": "Bereich von {{name}}",
"Apply": "Anwenden",
"Cells that aren't already a page reference will be cleared.": "Zellen, die noch keine Seitenreferenz sind, werden geleert.",
"Cells that aren't a valid URL will be cleared.": "Zellen, die keine gültige URL sind, werden geleert.",
"Cells that aren't a valid email address will be cleared.": "Zellen, die keine gültige E-Mail-Adresse sind, werden geleert.",
"Cells that can't be parsed as a date will be cleared.": "Zellen, die nicht als Datum geparst werden können, werden geleert.",
"Cells that can't be parsed as a number will be cleared.": "Zellen, die nicht als Zahl geparst werden können, werden geleert.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Zellen werden umgewandelt (yes/true/1 werden als ausgewählt markiert; alles andere wird als nicht ausgewählt markiert oder geleert).",
"Cells will be reinterpreted under the new type.": "Zellen werden unter dem neuen Typ neu interpretiert.",
"Cells will be replaced with a comma-separated list of file names.": "Zellen werden durch eine kommagetrennte Liste von Dateinamen ersetzt.",
"Cells will be replaced with a comma-separated list of option names.": "Zellen werden durch eine kommagetrennte Liste von Optionsnamen ersetzt.",
"Cells will be replaced with the option name.": "Zellen werden durch den Optionsnamen ersetzt.",
"Cells will be replaced with the page title.": "Zellen werden durch den Seitentitel ersetzt.",
"Cells will be replaced with the person's name.": "Zellen werden durch den Namen der Person ersetzt.",
"Change type": "Typ ändern",
"Change type to {{label}}?": "Typ in {{label}} ändern?",
"Converting…": "Wird umgewandelt…",
"Existing values become single-item lists. No data is lost.": "Vorhandene Werte werden zu Einzellisten. Es gehen keine Daten verloren.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Nur das erste ausgewählte Element pro Zeile wird beibehalten; der Rest wird verworfen.",
"Previous record": "Vorheriger Datensatz",
"Next record": "Nächster Datensatz",
"Record actions": "Datensatzaktionen",
"Delete record": "Datensatz löschen",
"Delete record?": "Datensatz löschen?",
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
"to navigate": "zum Navigieren",
"to close": "zum Schließen",
"Expand row {{number}}": "Zeile {{number}} erweitern",
"Saving…": "Wird gespeichert…",
"Read-only": "Schreibgeschützt",
"Loading…": "Wird geladen…",
"Updated {{when}}": "Aktualisiert {{when}}",
"Add property": "Eigenschaft hinzufügen",
"Create property": "Eigenschaft erstellen",
"Hide properties": "Eigenschaften ausblenden",
"Find a property type": "Einen Eigenschaftstyp finden",
"Properties": "Eigenschaften",
"A property with this name already exists": "Eine Eigenschaft mit diesem Namen existiert bereits",
"Add card": "Karte hinzufügen",
"Add filter": "Filter hinzufügen",
"Add option": "Option hinzufügen",
"Add sort": "Sortierung hinzufügen",
"Add view": "Ansicht hinzufügen",
"All data in this column will be lost.": "Alle Daten in dieser Spalte gehen verloren.",
"Allow multiple people": "Mehrere Personen zulassen",
"Alphabetize": "Alphabetisch sortieren",
"Are you sure you want to delete": "Sind Sie sicher, dass Sie löschen möchten",
"Ascending": "Aufsteigend",
"Base deleted": "Base gelöscht",
"Base name": "Base-Name",
"Base table": "Base-Tabelle",
"Card properties": "Karteneigenschaften",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Zellen, die das Textlimit überschreiten, werden abgeschnitten und der zusätzliche Inhalt geht dauerhaft verloren.",
"Checkbox": "Kontrollkästchen",
"Checked by default": "Standardmäßig aktiviert",
"Choose a property": "Wählen Sie eine Eigenschaft",
"Clear selection": "Auswahl aufheben",
"Column options": "Spaltenoptionen",
"Comma, period": "Komma, Punkt",
"Complete": "Abgeschlossen",
"Contains": "Enthält",
"Copy link to view": "Link zur Ansicht kopieren",
"Create a status property": "Eine Statuseigenschaft erstellen",
"Created at": "Erstellt am",
"Currency": "Währung",
"Decimal places": "Dezimalstellen",
"Default value": "Standardwert",
"Delete property": "Eigenschaft löschen",
"Delete view": "Ansicht löschen",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Absteigend",
"Discard": "Verwerfen",
"Doesn't contain": "Enthält nicht",
"Done": "Fertig",
"Edit formula": "Formel bearbeiten",
"Edit property": "Eigenschaft bearbeiten",
"Export CSV": "CSV exportieren",
"Failed to load base": "Base konnte nicht geladen werden",
"False": "Falsch",
"File": "Datei",
"Filter and sort changes are visible only to you": "Änderungen an Filtern und Sortierungen sind nur für Sie sichtbar",
"Filter by": "Filtern nach",
"Formula": "Formel",
"Get started with": "Erste Schritte mit",
"Greater than": "Größer als",
"Group by": "Gruppieren nach",
"Group this board by a select or status property.": "Gruppieren Sie dieses Board nach einer Auswahl- oder Statuseigenschaft.",
"Hide all": "Alle ausblenden",
"Hide group": "Gruppe ausblenden",
"In Progress": "In Bearbeitung",
"In progress": "In Bearbeitung",
"Include time": "Uhrzeit einschließen",
"Insert a kanban board on this page": "Fügen Sie ein Kanban-Board auf dieser Seite ein",
"Insert an inline base on this page": "Fügen Sie eine Inline-Base auf dieser Seite ein",
"Is": "Ist",
"Is after": "Ist nach",
"Is any of": "Ist eines von",
"Is before": "Ist vor",
"Is empty": "Ist leer",
"Is none of": "Ist keines von",
"Is not": "Ist nicht",
"Is not empty": "Ist nicht leer",
"Is on or after": "Ist am oder nach",
"Is on or before": "Ist am oder vor",
"Is within": "Ist innerhalb von",
"Keep editing": "Weiter bearbeiten",
"Last edited at": "Zuletzt bearbeitet am",
"Last edited by": "Zuletzt bearbeitet von",
"Less than": "Kleiner als",
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
"Local": "Lokal",
"Long text": "Langer Text",
"Moved card to {{column}}": "Karte nach {{column}} verschoben",
"Moved column to {{column}}": "Spalte nach {{column}} verschoben",
"Multi-select": "Mehrfachauswahl",
"New row": "Neue Zeile",
"Next month": "Nächster Monat",
"Next week": "Nächste Woche",
"Next year": "Nächstes Jahr",
"No filters applied": "Keine Filter angewendet",
"No match": "Keine Übereinstimmung",
"No options for this property type": "Keine Optionen für diesen Eigenschaftstyp",
"No sorts applied": "Keine Sortierungen angewendet",
"No value": "Kein Wert",
"None": "Keine",
"Not started": "Nicht begonnen",
"Number": "Zahl",
"One month ago": "Vor einem Monat",
"One month from now": "In einem Monat",
"One week ago": "Vor einer Woche",
"One week from now": "In einer Woche",
"Open as page": "Als Seite öffnen",
"Options": "Optionen",
"Past month": "Letzter Monat",
"Past week": "Letzte Woche",
"Past year": "Letztes Jahr",
"Percent": "Prozent",
"Period, comma": "Punkt, Komma",
"Person": "Person",
"Please enter a valid email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"Progress": "Fortschritt",
"Property options": "Eigenschaftsoptionen",
"Relative": "Relativ",
"Rename property": "Eigenschaft umbenennen",
"Reset": "Zurücksetzen",
"Save for everyone": "Für alle speichern",
"Select": "Auswahl",
"Select a property": "Eigenschaft auswählen",
"Select all loaded rows": "Alle geladenen Zeilen auswählen",
"Show all": "Alle anzeigen",
"Sort": "Sortieren",
"Sort by": "Sortieren nach",
"Space, comma": "Leerzeichen, Komma",
"Space, period": "Leerzeichen, Punkt",
"This board has no grouping property yet.": "Dieses Board hat noch keine Gruppierungseigenschaft.",
"This month": "Diesen Monat",
"This year": "Dieses Jahr",
"Thousands and decimal separators": "Tausender- und Dezimaltrennzeichen",
"Time format": "Zeitformat",
"To Do": "Zu erledigen",
"Tomorrow": "Morgen",
"True": "Wahr",
"Unsaved changes": "Nicht gespeicherte Änderungen",
"Untitled base": "Unbenannte Base",
"URL": "URL",
"Value": "Wert",
"View updated for everyone": "Ansicht für alle aktualisiert",
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Failed to create base": "Failed to create base",
"Failed to update base": "Failed to update base",
"Failed to delete base": "Failed to delete base",
"Failed to create property": "Failed to create property",
"Failed to update property": "Failed to update property",
"Failed to delete property": "Failed to delete property",
"Failed to reorder property": "Failed to reorder property",
"Failed to create view": "Failed to create view",
"Failed to update view": "Failed to update view",
"Failed to delete view": "Failed to delete view",
"Failed to create row": "Failed to create row",
"Failed to update row": "Failed to update row",
"Failed to delete row": "Failed to delete row",
"Failed to delete rows": "Failed to delete rows",
"Failed to reorder row": "Failed to reorder row",
"Failed to move card": "Failed to move card",
"Failed to add card": "Failed to add card",
"Failed to export CSV": "Failed to export CSV",
"Favorite spaces": "Favorite spaces",
"Favorite spaces appear here": "Favorite spaces appear here",
"Favorites": "Favorites",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
@@ -597,6 +619,8 @@
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview",
"Base preview unavailable": "Base preview unavailable",
"Restore this base to view its contents.": "Restore this base to view its contents.",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
@@ -966,6 +990,8 @@
"Are you sure you want to delete this template?": "Are you sure you want to delete this template?",
"Template scope updated": "Template scope updated",
"Choose which space this template belongs to": "Choose which space this template belongs to",
"Only workspace admins can create global templates.": "Only workspace admins can create global templates.",
"You need edit access to this space to create templates.": "You need edit access to this space to create templates.",
"Scope": "Scope",
"Select scope": "Select scope",
"Title": "Title",
@@ -1085,5 +1111,183 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Allow personal spaces": "Allow personal spaces",
"Members can create their own personal space.": "Members can create their own personal space.",
"Toggle allow personal spaces": "Toggle allow personal spaces",
"Create personal space": "Create personal space",
"Personal space": "Personal space",
"{{name}}'s space": "{{name}}'s space",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.",
"Previous record": "Previous record",
"Next record": "Next record",
"Record actions": "Record actions",
"Delete record": "Delete record",
"Delete record?": "Delete record?",
"This action cannot be undone.": "This action cannot be undone.",
"to navigate": "to navigate",
"to close": "to close",
"Expand row {{number}}": "Expand row {{number}}",
"Saving…": "Saving…",
"Read-only": "Read-only",
"Loading…": "Loading…",
"Updated {{when}}": "Updated {{when}}",
"Add property": "Add property",
"Create property": "Create property",
"Hide properties": "Hide properties",
"Find a property type": "Find a property type",
"Properties": "Properties",
"A property with this name already exists": "A property with this name already exists",
"Add card": "Add card",
"Add filter": "Add filter",
"Add option": "Add option",
"Add sort": "Add sort",
"Add view": "Add view",
"All data in this column will be lost.": "All data in this column will be lost.",
"Allow multiple people": "Allow multiple people",
"Alphabetize": "Alphabetize",
"Are you sure you want to delete": "Are you sure you want to delete",
"Ascending": "Ascending",
"Base deleted": "Base deleted",
"Base name": "Base name",
"Base table": "Base table",
"Card properties": "Card properties",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
"Checkbox": "Checkbox",
"Checked by default": "Checked by default",
"Choose a property": "Choose a property",
"Clear selection": "Clear selection",
"Column options": "Column options",
"Comma, period": "Comma, period",
"Complete": "Complete",
"Contains": "Contains",
"Copy link to view": "Copy link to view",
"Create a status property": "Create a status property",
"Created at": "Created at",
"Currency": "Currency",
"Decimal places": "Decimal places",
"Default value": "Default value",
"Delete property": "Delete property",
"Delete view": "Delete view",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descending",
"Discard": "Discard",
"Doesn't contain": "Doesn't contain",
"Done": "Done",
"Edit formula": "Edit formula",
"Edit property": "Edit property",
"Export CSV": "Export CSV",
"Failed to load base": "Failed to load base",
"False": "False",
"File": "File",
"Filter and sort changes are visible only to you": "Filter and sort changes are visible only to you",
"Filter by": "Filter by",
"Formula": "Formula",
"Get started with": "Get started with",
"Greater than": "Greater than",
"Group by": "Group by",
"Group this board by a select or status property.": "Group this board by a select or status property.",
"Hide all": "Hide all",
"Hide group": "Hide group",
"In Progress": "In Progress",
"In progress": "In progress",
"Include time": "Include time",
"Insert a kanban board on this page": "Insert a kanban board on this page",
"Insert an inline base on this page": "Insert an inline base on this page",
"Is": "Is",
"Is after": "Is after",
"Is any of": "Is any of",
"Is before": "Is before",
"Is empty": "Is empty",
"Is none of": "Is none of",
"Is not": "Is not",
"Is not empty": "Is not empty",
"Is on or after": "Is on or after",
"Is on or before": "Is on or before",
"Is within": "Is within",
"Keep editing": "Keep editing",
"Last edited at": "Last edited at",
"Last edited by": "Last edited by",
"Less than": "Less than",
"Link copied to clipboard": "Link copied to clipboard",
"Local": "Local",
"Long text": "Long text",
"Moved card to {{column}}": "Moved card to {{column}}",
"Moved column to {{column}}": "Moved column to {{column}}",
"Multi-select": "Multi-select",
"New row": "New row",
"Next month": "Next month",
"Next week": "Next week",
"Next year": "Next year",
"No filters applied": "No filters applied",
"No match": "No match",
"No options for this property type": "No options for this property type",
"No sorts applied": "No sorts applied",
"No value": "No value",
"None": "None",
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
"One month from now": "One month from now",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
"Options": "Options",
"Past month": "Past month",
"Past week": "Past week",
"Past year": "Past year",
"Percent": "Percent",
"Period, comma": "Period, comma",
"Person": "Person",
"Please enter a valid email": "Please enter a valid email",
"Progress": "Progress",
"Property options": "Property options",
"Relative": "Relative",
"Rename property": "Rename property",
"Reset": "Reset",
"Save for everyone": "Save for everyone",
"Select": "Select",
"Select a property": "Select a property",
"Select all loaded rows": "Select all loaded rows",
"Show all": "Show all",
"Sort": "Sort",
"Sort by": "Sort by",
"Space, comma": "Space, comma",
"Space, period": "Space, period",
"This board has no grouping property yet.": "This board has no grouping property yet.",
"This month": "This month",
"This year": "This year",
"Thousands and decimal separators": "Thousands and decimal separators",
"Time format": "Time format",
"To Do": "To Do",
"Tomorrow": "Tomorrow",
"True": "True",
"Unsaved changes": "Unsaved changes",
"Untitled base": "Untitled base",
"URL": "URL",
"Value": "Value",
"View updated for everyone": "View updated for everyone",
"You have unsaved changes. Do you want to discard them?": "You have unsaved changes. Do you want to discard them?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Oscuro",
"Date": "Fecha",
"Delete": "Eliminar",
"Remove from page": "Quitar de la página",
"Base options": "Opciones de la base",
"Delete group": "Eliminar grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "¿Está seguro de que desea eliminar esta página? Esto eliminará sus dependientes y el historial de la página. Esta acción es irreversible.",
"Description": "Descripción",
@@ -76,6 +78,24 @@
"Failed to import pages": "No se pudieron importar las páginas",
"Failed to load page. An error occurred.": "Error al cargar la página. Se produjo un error.",
"Failed to update data": "No se pudo actualizar los datos",
"Failed to create base": "No se pudo crear la base",
"Failed to update base": "No se pudo actualizar la base",
"Failed to delete base": "No se pudo eliminar la base",
"Failed to create property": "No se pudo crear la propiedad",
"Failed to update property": "No se pudo actualizar la propiedad",
"Failed to delete property": "No se pudo eliminar la propiedad",
"Failed to reorder property": "No se pudo reordenar la propiedad",
"Failed to create view": "No se pudo crear la vista",
"Failed to update view": "No se pudo actualizar la vista",
"Failed to delete view": "No se pudo eliminar la vista",
"Failed to create row": "No se pudo crear la fila",
"Failed to update row": "No se pudo actualizar la fila",
"Failed to delete row": "No se pudo eliminar la fila",
"Failed to delete rows": "No se pudieron eliminar las filas",
"Failed to reorder row": "No se pudo reordenar la fila",
"Failed to move card": "No se pudo mover la tarjeta",
"Failed to add card": "No se pudo añadir la tarjeta",
"Failed to export CSV": "No se pudo exportar el CSV",
"Favorite spaces": "Espacios favoritos",
"Favorite spaces appear here": "Los espacios favoritos aparecen aquí",
"Favorites": "Favoritos",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insertar diagrama Mermaid",
"Insert and design Drawio diagrams": "Insertar y diseñar diagramas de Drawio",
"Insert current date": "Insertar fecha actual",
"Time": "Hora",
"Insert current time": "Insertar hora actual",
"Draw and sketch excalidraw diagrams": "Dibujar y crear bocetos de diagramas de Excalidraw",
"Multiple": "Múltiple",
"Turn into": "Convertir en",
@@ -424,6 +446,7 @@
"Names do not match": "Los nombres no coinciden",
"Today, {{time}}": "Hoy, {{time}}",
"Yesterday, {{time}}": "Ayer, {{time}}",
"now": "ahora",
"Space created successfully": "Espacio creado correctamente",
"Space updated successfully": "Espacio actualizado correctamente",
"Space deleted successfully": "Espacio eliminado correctamente",
@@ -596,6 +619,8 @@
"Deleted by": "Eliminado por",
"Deleted at": "Eliminado el",
"Preview": "Vista previa",
"Base preview unavailable": "Vista previa de la base no disponible",
"Restore this base to view its contents.": "Restaura esta base para ver su contenido.",
"Subpages": "Subpáginas",
"Failed to load subpages": "No se pudieron cargar las subpáginas",
"No subpages": "No hay subpáginas",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "¿Seguro que quieres eliminar esta plantilla?",
"Template scope updated": "Alcance de la plantilla actualizado",
"Choose which space this template belongs to": "Elige a qué espacio pertenece esta plantilla",
"Only workspace admins can create global templates.": "Solo los administradores del espacio de trabajo pueden crear plantillas globales.",
"You need edit access to this space to create templates.": "Necesitas acceso de edición a este espacio para crear plantillas.",
"Scope": "Alcance",
"Select scope": "Seleccionar alcance",
"Title": "Título",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Buscar páginas y espacios...",
"No results found": "No se encontraron resultados",
"You don't have permission to create pages here": "No tienes permiso para crear páginas aquí",
"Chat menu": "Menú del chat",
"Chat menu for {{title}}": "Menú del chat de {{title}}",
"API key menu": "Menú de la clave API",
"Jump to comment selection": "Ir a la selección de comentarios",
"Slash commands": "Comandos de barra",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrar",
"Page title": "Título de la página",
"Page content": "Contenido de la página",
"Member actions": "Acciones de miembro",
"Member actions for {{name}}": "Acciones de miembro para {{name}}",
"Toggle password visibility": "Alternar visibilidad de la contraseña",
"Send comment": "Enviar comentario",
"Token actions": "Acciones de token",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
"Page menu for {{name}}": "Menú de página para {{name}}",
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
"Create subpage of {{name}}": "Crear subpágina de {{name}}",
"Allow personal spaces": "Permitir espacios personales",
"Members can create their own personal space.": "Los miembros pueden crear su propio espacio personal.",
"Toggle allow personal spaces": "Alternar permitir espacios personales",
"Create personal space": "Crear espacio personal",
"Personal space": "Espacio personal",
"{{name}}'s space": "Espacio de {{name}}",
"Apply": "Aplicar",
"Cells that aren't already a page reference will be cleared.": "Las celdas que aún no sean una referencia de página se borrarán.",
"Cells that aren't a valid URL will be cleared.": "Las celdas que no sean una URL válida se borrarán.",
"Cells that aren't a valid email address will be cleared.": "Las celdas que no sean una dirección de correo electrónico válida se borrarán.",
"Cells that can't be parsed as a date will be cleared.": "Las celdas que no puedan interpretarse como una fecha se borrarán.",
"Cells that can't be parsed as a number will be cleared.": "Las celdas que no puedan interpretarse como un número se borrarán.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Las celdas se convertirán de forma forzada (yes/true/1 pasarán a estar marcadas; todo lo demás pasará a estar desmarcado o se borrará).",
"Cells will be reinterpreted under the new type.": "Las celdas se reinterpretarán según el nuevo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "Las celdas se reemplazarán por una lista de nombres de archivo separados por comas.",
"Cells will be replaced with a comma-separated list of option names.": "Las celdas se reemplazarán por una lista de nombres de opciones separados por comas.",
"Cells will be replaced with the option name.": "Las celdas se reemplazarán por el nombre de la opción.",
"Cells will be replaced with the page title.": "Las celdas se reemplazarán por el título de la página.",
"Cells will be replaced with the person's name.": "Las celdas se reemplazarán por el nombre de la persona.",
"Change type": "Cambiar tipo",
"Change type to {{label}}?": "¿Cambiar el tipo a {{label}}?",
"Converting…": "Convirtiendo…",
"Existing values become single-item lists. No data is lost.": "Los valores existentes se convierten en listas de un solo elemento. No se pierde ningún dato.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Solo se conservará el primer elemento seleccionado por fila; el resto se descartará.",
"Previous record": "Registro anterior",
"Next record": "Siguiente registro",
"Record actions": "Acciones del registro",
"Delete record": "Eliminar registro",
"Delete record?": "¿Eliminar registro?",
"This action cannot be undone.": "Esta acción no se puede deshacer.",
"to navigate": "para navegar",
"to close": "para cerrar",
"Expand row {{number}}": "Expandir fila {{number}}",
"Saving…": "Guardando…",
"Read-only": "Solo lectura",
"Loading…": "Cargando…",
"Updated {{when}}": "Actualizado {{when}}",
"Add property": "Añadir propiedad",
"Create property": "Crear propiedad",
"Hide properties": "Ocultar propiedades",
"Find a property type": "Buscar un tipo de propiedad",
"Properties": "Propiedades",
"A property with this name already exists": "Ya existe una propiedad con este nombre",
"Add card": "Agregar tarjeta",
"Add filter": "Agregar filtro",
"Add option": "Agregar opción",
"Add sort": "Agregar orden",
"Add view": "Agregar vista",
"All data in this column will be lost.": "Todos los datos de esta columna se perderán.",
"Allow multiple people": "Permitir varias personas",
"Alphabetize": "Ordenar alfabéticamente",
"Are you sure you want to delete": "¿Está seguro de que desea eliminar",
"Ascending": "Ascendente",
"Base deleted": "Base eliminada",
"Base name": "Nombre de la base",
"Base table": "Tabla base",
"Card properties": "Propiedades de la tarjeta",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Las celdas más largas que el límite de texto se truncarán y el contenido adicional se perderá permanentemente.",
"Checkbox": "Casilla de verificación",
"Checked by default": "Marcado por defecto",
"Choose a property": "Seleccione una propiedad",
"Clear selection": "Borrar selección",
"Column options": "Opciones de columna",
"Comma, period": "Coma, punto",
"Complete": "Completo",
"Contains": "Contiene",
"Copy link to view": "Copiar enlace a la vista",
"Create a status property": "Crear una propiedad de estado",
"Created at": "Creado el",
"Currency": "Moneda",
"Decimal places": "Decimales",
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
"Done": "Hecho",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propiedad",
"Export CSV": "Exportar CSV",
"Failed to load base": "No se pudo cargar la base",
"False": "Falso",
"File": "Archivo",
"Filter and sort changes are visible only to you": "Los cambios de filtro y ordenación solo son visibles para usted",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Comenzar con",
"Greater than": "Mayor que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este tablero por una propiedad de selección o estado.",
"Hide all": "Ocultar todo",
"Hide group": "Ocultar grupo",
"In Progress": "En curso",
"In progress": "En curso",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Insertar un tablero kanban en esta página",
"Insert an inline base on this page": "Insertar una base integrada en esta página",
"Is": "Es",
"Is after": "Es posterior a",
"Is any of": "Es cualquiera de",
"Is before": "Es anterior a",
"Is empty": "Está vacío",
"Is none of": "No es ninguno de",
"Is not": "No es",
"Is not empty": "No está vacío",
"Is on or after": "Es en o después de",
"Is on or before": "Es en o antes de",
"Is within": "Está dentro de",
"Keep editing": "Seguir editando",
"Last edited at": "Última edición el",
"Last edited by": "Última edición por",
"Less than": "Menor que",
"Link copied to clipboard": "Enlace copiado al portapapeles",
"Local": "Local",
"Long text": "Texto largo",
"Moved card to {{column}}": "Tarjeta movida a {{column}}",
"Moved column to {{column}}": "Columna movida a {{column}}",
"Multi-select": "Selección múltiple",
"New row": "Nueva fila",
"Next month": "Próximo mes",
"Next week": "Próxima semana",
"Next year": "Próximo año",
"No filters applied": "No hay filtros aplicados",
"No match": "Sin coincidencias",
"No options for this property type": "No hay opciones para este tipo de propiedad",
"No sorts applied": "No hay ordenaciones aplicadas",
"No value": "Sin valor",
"None": "Ninguno",
"Not started": "No iniciado",
"Number": "Número",
"One month ago": "Hace un mes",
"One month from now": "Dentro de un mes",
"One week ago": "Hace una semana",
"One week from now": "Dentro de una semana",
"Open as page": "Abrir como página",
"Options": "Opciones",
"Past month": "Último mes",
"Past week": "Última semana",
"Past year": "Último año",
"Percent": "Porcentaje",
"Period, comma": "Punto, coma",
"Person": "Persona",
"Please enter a valid email": "Por favor, introduzca un correo electrónico válido",
"Progress": "Progreso",
"Property options": "Opciones de propiedad",
"Relative": "Relativo",
"Rename property": "Renombrar propiedad",
"Reset": "Restablecer",
"Save for everyone": "Guardar para todos",
"Select": "Selección",
"Select a property": "Seleccionar una propiedad",
"Select all loaded rows": "Seleccionar todas las filas cargadas",
"Show all": "Mostrar todo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espacio, coma",
"Space, period": "Espacio, punto",
"This board has no grouping property yet.": "Este tablero aún no tiene una propiedad de agrupación.",
"This month": "Este mes",
"This year": "Este año",
"Thousands and decimal separators": "Separadores de miles y decimales",
"Time format": "Formato de hora",
"To Do": "Por hacer",
"Tomorrow": "Mañana",
"True": "Verdadero",
"Unsaved changes": "Cambios no guardados",
"Untitled base": "Base sin título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Sombre",
"Date": "Date",
"Delete": "Supprimer",
"Remove from page": "Supprimer de la page",
"Base options": "Options de la base",
"Delete group": "Supprimer groupe",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"Failed to import pages": "Échec de l'importation des pages",
"Failed to load page. An error occurred.": "Échec du chargement de la page. Une erreur s'est produite.",
"Failed to update data": "Échec de la mise à jour des données",
"Failed to create base": "Échec de la création de la base",
"Failed to update base": "Échec de la mise à jour de la base",
"Failed to delete base": "Échec de la suppression de la base",
"Failed to create property": "Échec de la création de la propriété",
"Failed to update property": "Échec de la mise à jour de la propriété",
"Failed to delete property": "Échec de la suppression de la propriété",
"Failed to reorder property": "Échec de la réorganisation de la propriété",
"Failed to create view": "Échec de la création de la vue",
"Failed to update view": "Échec de la mise à jour de la vue",
"Failed to delete view": "Échec de la suppression de la vue",
"Failed to create row": "Échec de la création de la ligne",
"Failed to update row": "Échec de la mise à jour de la ligne",
"Failed to delete row": "Échec de la suppression de la ligne",
"Failed to delete rows": "Échec de la suppression des lignes",
"Failed to reorder row": "Échec de la réorganisation de la ligne",
"Failed to move card": "Échec du déplacement de la carte",
"Failed to add card": "Échec de l'ajout de la carte",
"Failed to export CSV": "Échec de l'exportation du CSV",
"Favorite spaces": "Espaces favoris",
"Favorite spaces appear here": "Les espaces favoris apparaissent ici",
"Favorites": "Favoris",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insérer un diagramme Mermaid",
"Insert and design Drawio diagrams": "Insérer et concevoir des diagrammes Drawio",
"Insert current date": "Insérer la date actuelle",
"Time": "Heure",
"Insert current time": "Insérer l'heure actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple",
"Turn into": "Transformer en",
@@ -424,6 +446,7 @@
"Names do not match": "Les noms ne correspondent pas",
"Today, {{time}}": "Aujourdhui, {{time}}",
"Yesterday, {{time}}": "Hier, {{time}}",
"now": "maintenant",
"Space created successfully": "Espace créé avec succès",
"Space updated successfully": "Espace mis à jour avec succès",
"Space deleted successfully": "Espace supprimé avec succès",
@@ -596,6 +619,8 @@
"Deleted by": "Supprimé par",
"Deleted at": "Supprimé le",
"Preview": "Aperçu",
"Base preview unavailable": "Aperçu de la base indisponible",
"Restore this base to view its contents.": "Restaurez cette base pour afficher son contenu.",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"No subpages": "Aucune sous-page",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Êtes-vous sûr de vouloir supprimer ce modèle ?",
"Template scope updated": "Portée du modèle mise à jour",
"Choose which space this template belongs to": "Choisissez à quel espace appartient ce modèle",
"Only workspace admins can create global templates.": "Seuls les administrateurs de lespace de travail peuvent créer des modèles globaux.",
"You need edit access to this space to create templates.": "Vous devez disposer dun accès en modification à cet espace pour créer des modèles.",
"Scope": "Portée",
"Select scope": "Sélectionner la portée",
"Title": "Titre",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Rechercher des pages et des espaces...",
"No results found": "Aucun résultat trouvé",
"You don't have permission to create pages here": "Vous navez pas lautorisation de créer des pages ici",
"Chat menu": "Menu du chat",
"Chat menu for {{title}}": "Menu de discussion pour {{title}}",
"API key menu": "Menu de la clé API",
"Jump to comment selection": "Aller à la sélection de commentaires",
"Slash commands": "Commandes slash",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrer",
"Page title": "Titre de la page",
"Page content": "Contenu de la page",
"Member actions": "Actions des membres",
"Member actions for {{name}}": "Actions du membre pour {{name}}",
"Toggle password visibility": "Afficher/masquer le mot de passe",
"Send comment": "Envoyer le commentaire",
"Token actions": "Actions du jeton",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
"Page menu for {{name}}": "Menu de la page pour {{name}}",
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
"Create subpage of {{name}}": "Créer une sous-page de {{name}}",
"Allow personal spaces": "Autoriser les espaces personnels",
"Members can create their own personal space.": "Les membres peuvent créer leur propre espace personnel.",
"Toggle allow personal spaces": "Activer/désactiver l'autorisation des espaces personnels",
"Create personal space": "Créer un espace personnel",
"Personal space": "Espace personnel",
"{{name}}'s space": "Espace de {{name}}",
"Apply": "Appliquer",
"Cells that aren't already a page reference will be cleared.": "Les cellules qui ne sont pas déjà une référence de page seront effacées.",
"Cells that aren't a valid URL will be cleared.": "Les cellules qui ne sont pas une URL valide seront effacées.",
"Cells that aren't a valid email address will be cleared.": "Les cellules qui ne sont pas une adresse e-mail valide seront effacées.",
"Cells that can't be parsed as a date will be cleared.": "Les cellules qui ne peuvent pas être interprétées comme une date seront effacées.",
"Cells that can't be parsed as a number will be cleared.": "Les cellules qui ne peuvent pas être interprétées comme un nombre seront effacées.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Les cellules seront converties (yes/true/1 deviendront cochées ; tout le reste deviendra décoché ou sera effacé).",
"Cells will be reinterpreted under the new type.": "Les cellules seront réinterprétées selon le nouveau type.",
"Cells will be replaced with a comma-separated list of file names.": "Les cellules seront remplacées par une liste de noms de fichiers séparés par des virgules.",
"Cells will be replaced with a comma-separated list of option names.": "Les cellules seront remplacées par une liste de noms d'options séparés par des virgules.",
"Cells will be replaced with the option name.": "Les cellules seront remplacées par le nom de l'option.",
"Cells will be replaced with the page title.": "Les cellules seront remplacées par le titre de la page.",
"Cells will be replaced with the person's name.": "Les cellules seront remplacées par le nom de la personne.",
"Change type": "Changer le type",
"Change type to {{label}}?": "Changer le type en {{label}} ?",
"Converting…": "Conversion…",
"Existing values become single-item lists. No data is lost.": "Les valeurs existantes deviennent des listes à un seul élément. Aucune donnée n'est perdue.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Seul le premier élément sélectionné par ligne sera conservé ; les autres seront ignorés.",
"Previous record": "Enregistrement précédent",
"Next record": "Enregistrement suivant",
"Record actions": "Actions de l'enregistrement",
"Delete record": "Supprimer l'enregistrement",
"Delete record?": "Supprimer l'enregistrement ?",
"This action cannot be undone.": "Cette action ne peut pas être annulée.",
"to navigate": "pour naviguer",
"to close": "pour fermer",
"Expand row {{number}}": "Développer la ligne {{number}}",
"Saving…": "Enregistrement…",
"Read-only": "Lecture seule",
"Loading…": "Chargement…",
"Updated {{when}}": "Mis à jour {{when}}",
"Add property": "Ajouter une propriété",
"Create property": "Créer une propriété",
"Hide properties": "Masquer les propriétés",
"Find a property type": "Rechercher un type de propriété",
"Properties": "Propriétés",
"A property with this name already exists": "Une propriété portant ce nom existe déjà",
"Add card": "Ajouter une carte",
"Add filter": "Ajouter un filtre",
"Add option": "Ajouter une option",
"Add sort": "Ajouter un tri",
"Add view": "Ajouter une vue",
"All data in this column will be lost.": "Toutes les données de cette colonne seront perdues.",
"Allow multiple people": "Autoriser plusieurs personnes",
"Alphabetize": "Trier par ordre alphabétique",
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
"Ascending": "Croissant",
"Base deleted": "Base supprimée",
"Base name": "Nom de la base",
"Base table": "Table de base",
"Card properties": "Propriétés de la carte",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Les cellules dépassant la limite de texte seront tronquées et le contenu supplémentaire sera définitivement perdu.",
"Checkbox": "Case à cocher",
"Checked by default": "Coché par défaut",
"Choose a property": "Choisir une propriété",
"Clear selection": "Effacer la sélection",
"Column options": "Options de la colonne",
"Comma, period": "Virgule, point",
"Complete": "Terminé",
"Contains": "Contient",
"Copy link to view": "Copier le lien vers la vue",
"Create a status property": "Créer une propriété de statut",
"Created at": "Créé le",
"Currency": "Devise",
"Decimal places": "Décimales",
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
"Done": "Terminé",
"Edit formula": "Modifier la formule",
"Edit property": "Modifier la propriété",
"Export CSV": "Exporter en CSV",
"Failed to load base": "Échec du chargement de la base",
"False": "Faux",
"File": "Fichier",
"Filter and sort changes are visible only to you": "Les modifications de filtre et de tri ne sont visibles que par vous",
"Filter by": "Filtrer par",
"Formula": "Formule",
"Get started with": "Commencer avec",
"Greater than": "Supérieur à",
"Group by": "Grouper par",
"Group this board by a select or status property.": "Groupez ce tableau par une propriété de sélection ou de statut.",
"Hide all": "Tout masquer",
"Hide group": "Masquer le groupe",
"In Progress": "En cours",
"In progress": "En cours",
"Include time": "Inclure l'heure",
"Insert a kanban board on this page": "Insérer un tableau kanban sur cette page",
"Insert an inline base on this page": "Insérer une base intégrée sur cette page",
"Is": "Est",
"Is after": "Est après",
"Is any of": "Est l'un de",
"Is before": "Est avant",
"Is empty": "Est vide",
"Is none of": "N'est aucun de",
"Is not": "N'est pas",
"Is not empty": "N'est pas vide",
"Is on or after": "Est le ou après le",
"Is on or before": "Est le ou avant le",
"Is within": "Est dans la plage",
"Keep editing": "Continuer la modification",
"Last edited at": "Dernière modification le",
"Last edited by": "Dernière modification par",
"Less than": "Inférieur à",
"Link copied to clipboard": "Lien copié dans le presse-papiers",
"Local": "Local",
"Long text": "Texte long",
"Moved card to {{column}}": "Carte déplacée vers {{column}}",
"Moved column to {{column}}": "Colonne déplacée vers {{column}}",
"Multi-select": "Sélection multiple",
"New row": "Nouvelle ligne",
"Next month": "Le mois prochain",
"Next week": "La semaine prochaine",
"Next year": "L'année prochaine",
"No filters applied": "Aucun filtre appliqué",
"No match": "Aucune correspondance",
"No options for this property type": "Aucune option pour ce type de propriété",
"No sorts applied": "Aucun tri appliqué",
"No value": "Aucune valeur",
"None": "Aucun",
"Not started": "Non commencé",
"Number": "Nombre",
"One month ago": "Il y a un mois",
"One month from now": "Dans un mois",
"One week ago": "Il y a une semaine",
"One week from now": "Dans une semaine",
"Open as page": "Ouvrir comme page",
"Options": "Options",
"Past month": "Le mois dernier",
"Past week": "La semaine dernière",
"Past year": "L'année dernière",
"Percent": "Pourcentage",
"Period, comma": "Point, virgule",
"Person": "Personne",
"Please enter a valid email": "Veuillez entrer une adresse e-mail valide",
"Progress": "Progression",
"Property options": "Options de la propriété",
"Relative": "Relatif",
"Rename property": "Renommer la propriété",
"Reset": "Réinitialiser",
"Save for everyone": "Enregistrer pour tout le monde",
"Select": "Sélection",
"Select a property": "Sélectionner une propriété",
"Select all loaded rows": "Sélectionner toutes les lignes chargées",
"Show all": "Tout afficher",
"Sort": "Trier",
"Sort by": "Trier par",
"Space, comma": "Espace, virgule",
"Space, period": "Espace, point",
"This board has no grouping property yet.": "Ce tableau n'a pas encore de propriété de groupement.",
"This month": "Ce mois-ci",
"This year": "Cette année",
"Thousands and decimal separators": "Séparateurs des milliers et des décimales",
"Time format": "Format de l'heure",
"To Do": "À faire",
"Tomorrow": "Demain",
"True": "Vrai",
"Unsaved changes": "Modifications non enregistrées",
"Untitled base": "Base sans titre",
"URL": "URL",
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Scuro",
"Date": "Data",
"Delete": "Elimina",
"Remove from page": "Rimuovi dalla pagina",
"Base options": "Opzioni della base",
"Delete group": "Elimina gruppo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
"Description": "Descrizione",
@@ -76,6 +78,24 @@
"Failed to import pages": "Impossibile importare le pagine",
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
"Failed to update data": "Impossibile aggiornare i dati",
"Failed to create base": "Impossibile creare la base",
"Failed to update base": "Impossibile aggiornare la base",
"Failed to delete base": "Impossibile eliminare la base",
"Failed to create property": "Impossibile creare la proprietà",
"Failed to update property": "Impossibile aggiornare la proprietà",
"Failed to delete property": "Impossibile eliminare la proprietà",
"Failed to reorder property": "Impossibile riordinare la proprietà",
"Failed to create view": "Impossibile creare la vista",
"Failed to update view": "Impossibile aggiornare la vista",
"Failed to delete view": "Impossibile eliminare la vista",
"Failed to create row": "Impossibile creare la riga",
"Failed to update row": "Impossibile aggiornare la riga",
"Failed to delete row": "Impossibile eliminare la riga",
"Failed to delete rows": "Impossibile eliminare le righe",
"Failed to reorder row": "Impossibile riordinare la riga",
"Failed to move card": "Impossibile spostare la scheda",
"Failed to add card": "Impossibile aggiungere la scheda",
"Failed to export CSV": "Impossibile esportare il CSV",
"Favorite spaces": "Spazi preferiti",
"Favorite spaces appear here": "Gli spazi preferiti appariranno qui",
"Favorites": "Preferiti",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Inserisci diagramma Mermaid",
"Insert and design Drawio diagrams": "Inserisci e progetta diagrammi Drawio",
"Insert current date": "Inserisci data corrente",
"Time": "Ora",
"Insert current time": "Inserisci ora corrente",
"Draw and sketch excalidraw diagrams": "Disegna e abbozza diagrammi Excalidraw",
"Multiple": "Multiplo",
"Turn into": "Trasforma in",
@@ -424,6 +446,7 @@
"Names do not match": "I nomi non corrispondono",
"Today, {{time}}": "Oggi, {{time}}",
"Yesterday, {{time}}": "Ieri, {{time}}",
"now": "ora",
"Space created successfully": "Spazio creato con successo",
"Space updated successfully": "Spazio aggiornato con successo",
"Space deleted successfully": "Spazio eliminato con successo",
@@ -596,6 +619,8 @@
"Deleted by": "Eliminato da",
"Deleted at": "Eliminato il",
"Preview": "Anteprima",
"Base preview unavailable": "Anteprima della base non disponibile",
"Restore this base to view its contents.": "Ripristina questa base per visualizzarne i contenuti.",
"Subpages": "Sottopagine",
"Failed to load subpages": "Caricamento delle sottopagine non riuscito",
"No subpages": "Nessuna sottopagina",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Sei sicuro di voler eliminare questo modello?",
"Template scope updated": "Ambito del modello aggiornato",
"Choose which space this template belongs to": "Scegli a quale spazio appartiene questo modello",
"Only workspace admins can create global templates.": "Solo gli amministratori dell'area di lavoro possono creare modelli globali.",
"You need edit access to this space to create templates.": "Hai bisogno dell'accesso in modifica a questo spazio per creare modelli.",
"Scope": "Ambito",
"Select scope": "Seleziona ambito",
"Title": "Titolo",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Cerca pagine e spazi...",
"No results found": "Nessun risultato trovato",
"You don't have permission to create pages here": "Non hai l'autorizzazione per creare pagine qui",
"Chat menu": "Menu della chat",
"Chat menu for {{title}}": "Menu chat per {{title}}",
"API key menu": "Menu della chiave API",
"Jump to comment selection": "Vai alla selezione dei commenti",
"Slash commands": "Comandi slash",
@@ -1063,7 +1090,7 @@
"Filter": "Filtro",
"Page title": "Titolo pagina",
"Page content": "Contenuto della pagina",
"Member actions": "Azioni membro",
"Member actions for {{name}}": "Azioni membro per {{name}}",
"Toggle password visibility": "Attiva/disattiva visibilità password",
"Send comment": "Invia commento",
"Token actions": "Azioni token",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
"Page menu for {{name}}": "Menu della pagina per {{name}}",
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
"Allow personal spaces": "Consenti spazi personali",
"Members can create their own personal space.": "I membri possono creare il proprio spazio personale.",
"Toggle allow personal spaces": "Attiva/disattiva spazi personali",
"Create personal space": "Crea spazio personale",
"Personal space": "Spazio personale",
"{{name}}'s space": "Spazio di {{name}}",
"Apply": "Applica",
"Cells that aren't already a page reference will be cleared.": "Le celle che non sono già un riferimento a una pagina verranno svuotate.",
"Cells that aren't a valid URL will be cleared.": "Le celle che non contengono un URL valido verranno svuotate.",
"Cells that aren't a valid email address will be cleared.": "Le celle che non contengono un indirizzo email valido verranno svuotate.",
"Cells that can't be parsed as a date will be cleared.": "Le celle che non possono essere interpretate come una data verranno svuotate.",
"Cells that can't be parsed as a number will be cleared.": "Le celle che non possono essere interpretate come un numero verranno svuotate.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Le celle verranno convertite forzatamente (yes/true/1 diventeranno selezionate; tutto il resto diventerà non selezionato o verrà svuotato).",
"Cells will be reinterpreted under the new type.": "Le celle verranno reinterpretate con il nuovo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "Le celle verranno sostituite con un elenco di nomi file separati da virgole.",
"Cells will be replaced with a comma-separated list of option names.": "Le celle verranno sostituite con un elenco di nomi di opzioni separati da virgole.",
"Cells will be replaced with the option name.": "Le celle verranno sostituite con il nome dell'opzione.",
"Cells will be replaced with the page title.": "Le celle verranno sostituite con il titolo della pagina.",
"Cells will be replaced with the person's name.": "Le celle verranno sostituite con il nome della persona.",
"Change type": "Cambia tipo",
"Change type to {{label}}?": "Cambiare tipo in {{label}}?",
"Converting…": "Conversione…",
"Existing values become single-item lists. No data is lost.": "I valori esistenti diventano elenchi con un solo elemento. Nessun dato andrà perso.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Verrà mantenuto solo il primo elemento selezionato per riga; gli altri verranno scartati.",
"Previous record": "Record precedente",
"Next record": "Record successivo",
"Record actions": "Azioni record",
"Delete record": "Elimina record",
"Delete record?": "Eliminare il record?",
"This action cannot be undone.": "Questa azione non può essere annullata.",
"to navigate": "per navigare",
"to close": "per chiudere",
"Expand row {{number}}": "Espandi riga {{number}}",
"Saving…": "Salvataggio…",
"Read-only": "Sola lettura",
"Loading…": "Caricamento…",
"Updated {{when}}": "Aggiornato {{when}}",
"Add property": "Aggiungi proprietà",
"Create property": "Crea proprietà",
"Hide properties": "Nascondi proprietà",
"Find a property type": "Trova un tipo di proprietà",
"Properties": "Proprietà",
"A property with this name already exists": "Esiste già una proprietà con questo nome",
"Add card": "Aggiungi scheda",
"Add filter": "Aggiungi filtro",
"Add option": "Aggiungi opzione",
"Add sort": "Aggiungi ordinamento",
"Add view": "Aggiungi vista",
"All data in this column will be lost.": "Tutti i dati in questa colonna andranno persi.",
"Allow multiple people": "Consenti più persone",
"Alphabetize": "Ordina alfabeticamente",
"Are you sure you want to delete": "Sei sicuro di voler eliminare",
"Ascending": "Crescente",
"Base deleted": "Base eliminata",
"Base name": "Nome della base",
"Base table": "Tabella della base",
"Card properties": "Proprietà della scheda",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Le celle più lunghe del limite del testo verranno troncate e il contenuto aggiuntivo andrà perso in modo permanente.",
"Checkbox": "Casella di controllo",
"Checked by default": "Selezionata per impostazione predefinita",
"Choose a property": "Scegli una proprietà",
"Clear selection": "Cancella selezione",
"Column options": "Opzioni della colonna",
"Comma, period": "Virgola, punto",
"Complete": "Completato",
"Contains": "Contiene",
"Copy link to view": "Copia link alla vista",
"Create a status property": "Crea una proprietà di stato",
"Created at": "Creato il",
"Currency": "Valuta",
"Decimal places": "Posizioni decimali",
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
"Done": "Fatto",
"Edit formula": "Modifica formula",
"Edit property": "Modifica proprietà",
"Export CSV": "Esporta CSV",
"Failed to load base": "Impossibile caricare la base",
"False": "Falso",
"File": "File",
"Filter and sort changes are visible only to you": "Le modifiche a filtri e ordinamenti sono visibili solo a te",
"Filter by": "Filtra per",
"Formula": "Formula",
"Get started with": "Inizia con",
"Greater than": "Maggiore di",
"Group by": "Raggruppa per",
"Group this board by a select or status property.": "Raggruppa questa bacheca per una proprietà di selezione o di stato.",
"Hide all": "Nascondi tutto",
"Hide group": "Nascondi gruppo",
"In Progress": "In corso",
"In progress": "In corso",
"Include time": "Includi ora",
"Insert a kanban board on this page": "Inserisci una bacheca kanban in questa pagina",
"Insert an inline base on this page": "Inserisci una base in linea in questa pagina",
"Is": "È",
"Is after": "È dopo",
"Is any of": "È uno tra",
"Is before": "È prima",
"Is empty": "È vuoto",
"Is none of": "Non è nessuno di",
"Is not": "Non è",
"Is not empty": "Non è vuoto",
"Is on or after": "È il o dopo il",
"Is on or before": "È il o prima del",
"Is within": "È entro",
"Keep editing": "Continua a modificare",
"Last edited at": "Ultima modifica il",
"Last edited by": "Ultima modifica di",
"Less than": "Minore di",
"Link copied to clipboard": "Link copiato negli appunti",
"Local": "Locale",
"Long text": "Testo lungo",
"Moved card to {{column}}": "Scheda spostata in {{column}}",
"Moved column to {{column}}": "Colonna spostata in {{column}}",
"Multi-select": "Selezione multipla",
"New row": "Nuova riga",
"Next month": "Prossimo mese",
"Next week": "Prossima settimana",
"Next year": "Prossimo anno",
"No filters applied": "Nessun filtro applicato",
"No match": "Nessuna corrispondenza",
"No options for this property type": "Nessuna opzione per questo tipo di proprietà",
"No sorts applied": "Nessun ordinamento applicato",
"No value": "Nessun valore",
"None": "Nessuno",
"Not started": "Non iniziato",
"Number": "Numero",
"One month ago": "Un mese fa",
"One month from now": "Tra un mese",
"One week ago": "Una settimana fa",
"One week from now": "Tra una settimana",
"Open as page": "Apri come pagina",
"Options": "Opzioni",
"Past month": "Mese scorso",
"Past week": "Settimana scorsa",
"Past year": "Anno scorso",
"Percent": "Percentuale",
"Period, comma": "Punto, virgola",
"Person": "Persona",
"Please enter a valid email": "Inserisci un indirizzo email valido",
"Progress": "Avanzamento",
"Property options": "Opzioni della proprietà",
"Relative": "Relativo",
"Rename property": "Rinomina proprietà",
"Reset": "Reimposta",
"Save for everyone": "Salva per tutti",
"Select": "Seleziona",
"Select a property": "Seleziona una proprietà",
"Select all loaded rows": "Seleziona tutte le righe caricate",
"Show all": "Mostra tutto",
"Sort": "Ordina",
"Sort by": "Ordina per",
"Space, comma": "Spazio, virgola",
"Space, period": "Spazio, punto",
"This board has no grouping property yet.": "Questa bacheca non ha ancora una proprietà di raggruppamento.",
"This month": "Questo mese",
"This year": "Quest'anno",
"Thousands and decimal separators": "Separatori delle migliaia e dei decimali",
"Time format": "Formato ora",
"To Do": "Da fare",
"Tomorrow": "Domani",
"True": "Vero",
"Unsaved changes": "Modifiche non salvate",
"Untitled base": "Base senza titolo",
"URL": "URL",
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "ダーク",
"Date": "日付",
"Delete": "削除",
"Remove from page": "ページから削除",
"Base options": "ベースのオプション",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページとページ履歴も削除されます。この操作は取り消せません。",
"Description": "説明",
@@ -76,6 +78,24 @@
"Failed to import pages": "ページのインポートに失敗しました",
"Failed to load page. An error occurred.": "ページの読み込みに失敗しました。エラーが発生しました。",
"Failed to update data": "データの更新に失敗しました",
"Failed to create base": "ベースの作成に失敗しました",
"Failed to update base": "ベースの更新に失敗しました",
"Failed to delete base": "ベースの削除に失敗しました",
"Failed to create property": "プロパティの作成に失敗しました",
"Failed to update property": "プロパティの更新に失敗しました",
"Failed to delete property": "プロパティの削除に失敗しました",
"Failed to reorder property": "プロパティの並べ替えに失敗しました",
"Failed to create view": "ビューの作成に失敗しました",
"Failed to update view": "ビューの更新に失敗しました",
"Failed to delete view": "ビューの削除に失敗しました",
"Failed to create row": "行の作成に失敗しました",
"Failed to update row": "行の更新に失敗しました",
"Failed to delete row": "行の削除に失敗しました",
"Failed to delete rows": "行の削除に失敗しました",
"Failed to reorder row": "行の並べ替えに失敗しました",
"Failed to move card": "カードの移動に失敗しました",
"Failed to add card": "カードの追加に失敗しました",
"Failed to export CSV": "CSV のエクスポートに失敗しました",
"Favorite spaces": "お気に入りのスペース",
"Favorite spaces appear here": "お気に入りのスペースがここに表示されます",
"Favorites": "お気に入り",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入",
"Insert and design Drawio diagrams": "Drawio ダイアグラムを挿入して作成",
"Insert current date": "現在の日付を挿入",
"Time": "時刻",
"Insert current time": "現在の時刻を挿入",
"Draw and sketch excalidraw diagrams": "Excalidraw ダイアグラムを描画・スケッチ",
"Multiple": "複数",
"Turn into": "変換する",
@@ -424,6 +446,7 @@
"Names do not match": "名前が一致しません",
"Today, {{time}}": "今日 {{time}}",
"Yesterday, {{time}}": "昨日 {{time}}",
"now": "今すぐ",
"Space created successfully": "スペースが正常に作成されました",
"Space updated successfully": "スペースが正常に更新されました",
"Space deleted successfully": "スペースが正常に削除されました",
@@ -596,6 +619,8 @@
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
"Base preview unavailable": "ベースのプレビューを利用できません",
"Restore this base to view its contents.": "内容を表示するには、このベースを復元してください。",
"Subpages": "サブページ",
"Failed to load subpages": "サブページの読み込みに失敗しました",
"No subpages": "サブページはありません",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "このテンプレートを削除してもよろしいですか?",
"Template scope updated": "テンプレートのスコープを更新しました",
"Choose which space this template belongs to": "このテンプレートを所属させるスペースを選択",
"Only workspace admins can create global templates.": "グローバルテンプレートを作成できるのは、ワークスペース管理者のみです。",
"You need edit access to this space to create templates.": "テンプレートを作成するには、このスペースへの編集アクセス権が必要です。",
"Scope": "スコープ",
"Select scope": "スコープを選択",
"Title": "タイトル",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "ページとスペースを検索…",
"No results found": "結果が見つかりません",
"You don't have permission to create pages here": "ここにページを作成する権限がありません",
"Chat menu": "チャットメニュー",
"Chat menu for {{title}}": "{{title}} のチャットメニュー",
"API key menu": "API キーメニュー",
"Jump to comment selection": "コメント選択に移動",
"Slash commands": "スラッシュコマンド",
@@ -1063,7 +1090,7 @@
"Filter": "フィルター",
"Page title": "ページタイトル",
"Page content": "ページ内容",
"Member actions": "メンバー操作",
"Member actions for {{name}}": "{{name}} のメンバー操作",
"Toggle password visibility": "パスワードの表示を切り替え",
"Send comment": "コメントを送信",
"Token actions": "トークンの操作",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成"
"Create subpage of {{name}}": "{{name}} のサブページを作成",
"Allow personal spaces": "パーソナルスペースを許可",
"Members can create their own personal space.": "メンバーは自分専用のパーソナルスペースを作成できます。",
"Toggle allow personal spaces": "パーソナルスペースの許可を切り替える",
"Create personal space": "パーソナルスペースを作成",
"Personal space": "パーソナルスペース",
"{{name}}'s space": "{{name}} のスペース",
"Apply": "適用",
"Cells that aren't already a page reference will be cleared.": "すでにページ参照になっているセル以外は消去されます。",
"Cells that aren't a valid URL will be cleared.": "有効な URL ではないセルは消去されます。",
"Cells that aren't a valid email address will be cleared.": "有効なメールアドレスではないセルは消去されます。",
"Cells that can't be parsed as a date will be cleared.": "日付として解析できないセルは消去されます。",
"Cells that can't be parsed as a number will be cleared.": "数値として解析できないセルは消去されます。",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "セルは強制変換されます(yes/true/1 はチェック済みになり、それ以外は未チェックまたは空になります)。",
"Cells will be reinterpreted under the new type.": "セルは新しい型で再解釈されます。",
"Cells will be replaced with a comma-separated list of file names.": "セルは、ファイル名のカンマ区切りリストに置き換えられます。",
"Cells will be replaced with a comma-separated list of option names.": "セルは、オプション名のカンマ区切りリストに置き換えられます。",
"Cells will be replaced with the option name.": "セルはオプション名に置き換えられます。",
"Cells will be replaced with the page title.": "セルはページタイトルに置き換えられます。",
"Cells will be replaced with the person's name.": "セルはその人の名前に置き換えられます。",
"Change type": "型を変更",
"Change type to {{label}}?": "型を {{label}} に変更しますか?",
"Converting…": "変換中…",
"Existing values become single-item lists. No data is lost.": "既存の値は単一項目のリストになります。データは失われません。",
"Only the first selected item per row will be kept; the rest will be discarded.": "各行で最初に選択された項目のみ保持され、残りは破棄されます。",
"Previous record": "前のレコード",
"Next record": "次のレコード",
"Record actions": "レコード操作",
"Delete record": "レコードを削除",
"Delete record?": "レコードを削除しますか?",
"This action cannot be undone.": "この操作は元に戻せません。",
"to navigate": "移動するには",
"to close": "閉じるには",
"Expand row {{number}}": "行 {{number}} を展開",
"Saving…": "保存中…",
"Read-only": "読み取り専用",
"Loading…": "読み込み中…",
"Updated {{when}}": "{{when}} に更新",
"Add property": "プロパティを追加",
"Create property": "プロパティを作成",
"Hide properties": "プロパティを非表示",
"Find a property type": "プロパティの種類を検索",
"Properties": "プロパティ",
"A property with this name already exists": "この名前のプロパティはすでに存在します",
"Add card": "カードを追加",
"Add filter": "フィルターを追加",
"Add option": "オプションを追加",
"Add sort": "並べ替えを追加",
"Add view": "ビューを追加",
"All data in this column will be lost.": "この列内のすべてのデータは失われます。",
"Allow multiple people": "複数のユーザーを許可",
"Alphabetize": "アルファベット順",
"Are you sure you want to delete": "削除してもよろしいですか",
"Ascending": "昇順",
"Base deleted": "ベースを削除しました",
"Base name": "ベース名",
"Base table": "ベーステーブル",
"Card properties": "カードのプロパティ",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "テキストの制限を超える長さのセルは切り詰められ、超過分の内容は完全に失われます。",
"Checkbox": "チェックボックス",
"Checked by default": "デフォルトでチェックを入れる",
"Choose a property": "プロパティを選択",
"Clear selection": "選択をクリア",
"Column options": "列のオプション",
"Comma, period": "カンマ、小数点",
"Complete": "完了",
"Contains": "含む",
"Copy link to view": "ビューへのリンクをコピー",
"Create a status property": "ステータスプロパティを作成",
"Created at": "作成日時",
"Currency": "通貨",
"Decimal places": "小数点以下の桁数",
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
"Doesn't contain": "含まない",
"Done": "完了",
"Edit formula": "数式を編集",
"Edit property": "プロパティを編集",
"Export CSV": "CSVをエクスポート",
"Failed to load base": "ベースの読み込みに失敗しました",
"False": "偽",
"File": "ファイル",
"Filter and sort changes are visible only to you": "フィルターと並べ替えの変更は自分にのみ表示されます",
"Filter by": "条件",
"Formula": "数式",
"Get started with": "次から始める",
"Greater than": "より大きい",
"Group by": "グループ化",
"Group this board by a select or status property.": "このボードをセレクトまたはステータスのプロパティでグループ化します。",
"Hide all": "すべて非表示",
"Hide group": "グループを非表示",
"In Progress": "進行中",
"In progress": "進行中",
"Include time": "時刻を含める",
"Insert a kanban board on this page": "このページにカンバンボードを挿入",
"Insert an inline base on this page": "このページにインラインベースを挿入",
"Is": "次に等しい",
"Is after": "次より後",
"Is any of": "次のいずれか",
"Is before": "次より前",
"Is empty": "空である",
"Is none of": "次のいずれでもない",
"Is not": "次に等しくない",
"Is not empty": "空ではない",
"Is on or after": "次以降",
"Is on or before": "次以前",
"Is within": "次の期間内",
"Keep editing": "編集を続ける",
"Last edited at": "最終編集日時",
"Last edited by": "最終編集者",
"Less than": "より小さい",
"Link copied to clipboard": "リンクをクリップボードにコピーしました",
"Local": "ローカル",
"Long text": "長いテキスト",
"Moved card to {{column}}": "カードを{{column}}に移動しました",
"Moved column to {{column}}": "列を{{column}}に移動しました",
"Multi-select": "複数選択",
"New row": "新規行",
"Next month": "来月",
"Next week": "来週",
"Next year": "来年",
"No filters applied": "フィルターは適用されていません",
"No match": "一致なし",
"No options for this property type": "このプロパティタイプにはオプションがありません",
"No sorts applied": "並べ替えは適用されていません",
"No value": "値なし",
"None": "なし",
"Not started": "未着手",
"Number": "数値",
"One month ago": "1か月前",
"One month from now": "1か月後",
"One week ago": "1週間前",
"One week from now": "1週間後",
"Open as page": "ページとして開く",
"Options": "オプション",
"Past month": "先月",
"Past week": "先週",
"Past year": "昨年",
"Percent": "パーセント",
"Period, comma": "小数点、カンマ",
"Person": "ユーザー",
"Please enter a valid email": "有効なメールアドレスを入力してください",
"Progress": "進捗",
"Property options": "プロパティのオプション",
"Relative": "相対",
"Rename property": "プロパティ名を変更",
"Reset": "リセット",
"Save for everyone": "全員に保存",
"Select": "選択",
"Select a property": "プロパティを選択",
"Select all loaded rows": "読み込まれているすべての行を選択",
"Show all": "すべて表示",
"Sort": "並べ替え",
"Sort by": "並べ替え条件",
"Space, comma": "スペース、カンマ",
"Space, period": "スペース、小数点",
"This board has no grouping property yet.": "このボードにはまだグループ化プロパティがありません。",
"This month": "今月",
"This year": "今年",
"Thousands and decimal separators": "桁区切りと小数点区切り",
"Time format": "時刻形式",
"To Do": "To Do",
"Tomorrow": "明日",
"True": "真",
"Unsaved changes": "未保存の変更",
"Untitled base": "無題のベース",
"URL": "URL",
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "어두운",
"Date": "날짜",
"Delete": "삭제",
"Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션",
"Delete group": "팀 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
@@ -76,6 +78,24 @@
"Failed to import pages": "페이지 가져오기 실패",
"Failed to load page. An error occurred.": "페이지 불러오기 실패. 오류가 발생했습니다.",
"Failed to update data": "데이터 갱신 실패",
"Failed to create base": "베이스를 만들지 못했습니다",
"Failed to update base": "베이스를 업데이트하지 못했습니다",
"Failed to delete base": "베이스를 삭제하지 못했습니다",
"Failed to create property": "속성을 만들지 못했습니다",
"Failed to update property": "속성을 업데이트하지 못했습니다",
"Failed to delete property": "속성을 삭제하지 못했습니다",
"Failed to reorder property": "속성 순서를 변경하지 못했습니다",
"Failed to create view": "보기를 만들지 못했습니다",
"Failed to update view": "보기를 업데이트하지 못했습니다",
"Failed to delete view": "보기를 삭제하지 못했습니다",
"Failed to create row": "행을 만들지 못했습니다",
"Failed to update row": "행을 업데이트하지 못했습니다",
"Failed to delete row": "행을 삭제하지 못했습니다",
"Failed to delete rows": "행을 삭제하지 못했습니다",
"Failed to reorder row": "행 순서를 변경하지 못했습니다",
"Failed to move card": "카드를 이동하지 못했습니다",
"Failed to add card": "카드를 추가하지 못했습니다",
"Failed to export CSV": "CSV를 내보내지 못했습니다",
"Favorite spaces": "즐겨찾는 스페이스",
"Favorite spaces appear here": "즐겨찾는 스페이스가 여기에 표시됩니다",
"Favorites": "즐겨찾기",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid 다이어그램 삽입",
"Insert and design Drawio diagrams": "Drawio 다이어그램 삽입 및 편집",
"Insert current date": "현재 날짜 삽입",
"Time": "시간",
"Insert current time": "현재 시간 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw 다이어그램 그리기 및 스케치",
"Multiple": "다중",
"Turn into": "변경하기",
@@ -424,6 +446,7 @@
"Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"now": "지금",
"Space created successfully": "스페이스가 성공적으로 생성되었습니다",
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
@@ -596,6 +619,8 @@
"Deleted by": "삭제한 사람",
"Deleted at": "삭제 시간",
"Preview": "미리보기",
"Base preview unavailable": "베이스 미리보기를 사용할 수 없음",
"Restore this base to view its contents.": "이 베이스를 복원하여 내용을 보세요.",
"Subpages": "하위 페이지",
"Failed to load subpages": "하위 페이지를 불러오지 못했습니다",
"No subpages": "하위 페이지가 없습니다",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "이 템플릿을 삭제하시겠습니까?",
"Template scope updated": "템플릿 범위가 업데이트되었습니다",
"Choose which space this template belongs to": "이 템플릿이 속할 스페이스를 선택하세요",
"Only workspace admins can create global templates.": "전역 템플릿은 워크스페이스 관리자만 만들 수 있습니다.",
"You need edit access to this space to create templates.": "템플릿을 만들려면 이 스페이스에 대한 편집 권한이 있어야 합니다.",
"Scope": "범위",
"Select scope": "범위 선택",
"Title": "제목",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "페이지와 스페이스 검색...",
"No results found": "결과를 찾을 수 없습니다",
"You don't have permission to create pages here": "여기에서 페이지를 만들 권한이 없습니다",
"Chat menu": "채팅 메뉴",
"Chat menu for {{title}}": "{{title}}의 채팅 메뉴",
"API key menu": "API 키 메뉴",
"Jump to comment selection": "댓글 선택으로 이동",
"Slash commands": "슬래시 명령어",
@@ -1063,7 +1090,7 @@
"Filter": "필터",
"Page title": "페이지 제목",
"Page content": "페이지 내용",
"Member actions": "멤버 작업",
"Member actions for {{name}}": "{{name}}에 대한 멤버 작업",
"Toggle password visibility": "비밀번호 표시 전환",
"Send comment": "댓글 보내기",
"Token actions": "토큰 작업",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
"Allow personal spaces": "개인 공간 허용",
"Members can create their own personal space.": "멤버는 자신만의 개인 공간을 만들 수 있습니다.",
"Toggle allow personal spaces": "개인 공간 허용 전환",
"Create personal space": "개인 공간 만들기",
"Personal space": "개인 공간",
"{{name}}'s space": "{{name}}의 공간",
"Apply": "적용",
"Cells that aren't already a page reference will be cleared.": "이미 페이지 참조인 셀이 아닌 경우 지워집니다.",
"Cells that aren't a valid URL will be cleared.": "유효한 URL이 아닌 셀은 지워집니다.",
"Cells that aren't a valid email address will be cleared.": "유효한 이메일 주소가 아닌 셀은 지워집니다.",
"Cells that can't be parsed as a date will be cleared.": "날짜로 해석할 수 없는 셀은 지워집니다.",
"Cells that can't be parsed as a number will be cleared.": "숫자로 해석할 수 없는 셀은 지워집니다.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "셀 값은 강제로 변환됩니다(yes/true/1은 선택됨이 되고, 그 외의 모든 값은 선택 해제되거나 지워집니다).",
"Cells will be reinterpreted under the new type.": "셀은 새 유형에 따라 다시 해석됩니다.",
"Cells will be replaced with a comma-separated list of file names.": "셀은 쉼표로 구분된 파일 이름 목록으로 대체됩니다.",
"Cells will be replaced with a comma-separated list of option names.": "셀은 쉼표로 구분된 옵션 이름 목록으로 대체됩니다.",
"Cells will be replaced with the option name.": "셀은 옵션 이름으로 대체됩니다.",
"Cells will be replaced with the page title.": "셀은 페이지 제목으로 대체됩니다.",
"Cells will be replaced with the person's name.": "셀은 사람 이름으로 대체됩니다.",
"Change type": "유형 변경",
"Change type to {{label}}?": "유형을 {{label}}로 변경할까요?",
"Converting…": "변환 중…",
"Existing values become single-item lists. No data is lost.": "기존 값은 단일 항목 목록이 됩니다. 데이터는 손실되지 않습니다.",
"Only the first selected item per row will be kept; the rest will be discarded.": "각 행에서 처음 선택된 항목만 유지되며 나머지는 삭제됩니다.",
"Previous record": "이전 레코드",
"Next record": "다음 레코드",
"Record actions": "레코드 작업",
"Delete record": "레코드 삭제",
"Delete record?": "레코드를 삭제할까요?",
"This action cannot be undone.": "이 작업은 되돌릴 수 없습니다.",
"to navigate": "탐색하려면",
"to close": "닫으려면",
"Expand row {{number}}": "행 {{number}} 펼치기",
"Saving…": "저장 중…",
"Read-only": "읽기 전용",
"Loading…": "불러오는 중…",
"Updated {{when}}": "{{when}}에 업데이트됨",
"Add property": "속성 추가",
"Create property": "속성 만들기",
"Hide properties": "속성 숨기기",
"Find a property type": "속성 유형 찾기",
"Properties": "속성",
"A property with this name already exists": "이 이름의 속성이 이미 있습니다",
"Add card": "카드 추가",
"Add filter": "필터 추가",
"Add option": "옵션 추가",
"Add sort": "정렬 추가",
"Add view": "보기 추가",
"All data in this column will be lost.": "이 열의 모든 데이터가 손실됩니다.",
"Allow multiple people": "여러 사람 허용",
"Alphabetize": "가나다순",
"Are you sure you want to delete": "삭제하시겠습니까",
"Ascending": "오름차순",
"Base deleted": "베이스가 삭제됨",
"Base name": "베이스 이름",
"Base table": "베이스 테이블",
"Card properties": "카드 속성",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "텍스트 제한보다 긴 셀은 잘리며, 초과된 콘텐츠는 영구적으로 손실됩니다.",
"Checkbox": "체크박스",
"Checked by default": "기본적으로 선택됨",
"Choose a property": "속성 선택",
"Clear selection": "선택 지우기",
"Column options": "열 옵션",
"Comma, period": "쉼표, 마침표",
"Complete": "완료",
"Contains": "포함",
"Copy link to view": "보기 링크 복사",
"Create a status property": "상태 속성 만들기",
"Created at": "작성 시간",
"Currency": "통화",
"Decimal places": "소수 자릿수",
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
"Done": "완료",
"Edit formula": "수식 편집",
"Edit property": "속성 편집",
"Export CSV": "CSV 내보내기",
"Failed to load base": "베이스를 불러오지 못했습니다",
"False": "거짓",
"File": "파일",
"Filter and sort changes are visible only to you": "필터 및 정렬 변경 사항은 나에게만 표시됩니다",
"Filter by": "필터 기준",
"Formula": "수식",
"Get started with": "시작하기",
"Greater than": "보다 큼",
"Group by": "그룹화 기준",
"Group this board by a select or status property.": "선택 또는 상태 속성으로 이 보드를 그룹화하세요.",
"Hide all": "모두 숨기기",
"Hide group": "그룹 숨기기",
"In Progress": "진행 중",
"In progress": "진행 중",
"Include time": "시간 포함",
"Insert a kanban board on this page": "이 페이지에 칸반 보드 삽입",
"Insert an inline base on this page": "이 페이지에 인라인 베이스 삽입",
"Is": "같음",
"Is after": "이후",
"Is any of": "다음 중 하나와 같음",
"Is before": "이전",
"Is empty": "비어 있음",
"Is none of": "다음 중 어느 것과도 같지 않음",
"Is not": "같지 않음",
"Is not empty": "비어 있지 않음",
"Is on or after": "해당 날짜 또는 이후",
"Is on or before": "해당 날짜 또는 이전",
"Is within": "다음 범위 내",
"Keep editing": "계속 편집",
"Last edited at": "마지막 편집 시간",
"Last edited by": "마지막 편집자",
"Less than": "보다 작음",
"Link copied to clipboard": "링크가 클립보드에 복사됨",
"Local": "로컬",
"Long text": "긴 텍스트",
"Moved card to {{column}}": "카드를 {{column}}(으)로 이동함",
"Moved column to {{column}}": "열을 {{column}}(으)로 이동함",
"Multi-select": "다중 선택",
"New row": "새 행",
"Next month": "다음 달",
"Next week": "다음 주",
"Next year": "내년",
"No filters applied": "적용된 필터 없음",
"No match": "일치하는 항목 없음",
"No options for this property type": "이 속성 유형에는 옵션이 없습니다",
"No sorts applied": "적용된 정렬 없음",
"No value": "값 없음",
"None": "없음",
"Not started": "시작 전",
"Number": "숫자",
"One month ago": "한 달 전",
"One month from now": "한 달 후",
"One week ago": "일주일 전",
"One week from now": "일주일 후",
"Open as page": "페이지로 열기",
"Options": "옵션",
"Past month": "지난달",
"Past week": "지난주",
"Past year": "작년",
"Percent": "백분율",
"Period, comma": "마침표, 쉼표",
"Person": "사람",
"Please enter a valid email": "유효한 이메일을 입력하세요",
"Progress": "진행률",
"Property options": "속성 옵션",
"Relative": "상대값",
"Rename property": "속성 이름 변경",
"Reset": "재설정",
"Save for everyone": "모든 사용자에게 저장",
"Select": "선택",
"Select a property": "속성 선택",
"Select all loaded rows": "불러온 모든 행 선택",
"Show all": "모두 보기",
"Sort": "정렬",
"Sort by": "정렬 기준",
"Space, comma": "공백, 쉼표",
"Space, period": "공백, 마침표",
"This board has no grouping property yet.": "이 보드에는 아직 그룹화 속성이 없습니다.",
"This month": "이번 달",
"This year": "올해",
"Thousands and decimal separators": "천 단위 및 소수 구분 기호",
"Time format": "시간 형식",
"To Do": "할 일",
"Tomorrow": "내일",
"True": "참",
"Unsaved changes": "저장되지 않은 변경 사항",
"Untitled base": "제목 없는 베이스",
"URL": "URL",
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Donker",
"Date": "Datum",
"Delete": "Verwijderen",
"Remove from page": "Van pagina verwijderen",
"Base options": "Basisopties",
"Delete group": "Groep verwijderen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Weet u zeker dat u deze pagina wilt verwijderen? Dit zal de subpagina's en paginageschiedenis verwijderen. Deze actie kan niet ongedaan gemaakt worden.",
"Description": "Beschrijving",
@@ -76,6 +78,24 @@
"Failed to import pages": "Pagina's importeren mislukt",
"Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.",
"Failed to update data": "Bijwerken van gegevens mislukt",
"Failed to create base": "Kan basis niet aanmaken",
"Failed to update base": "Kan basis niet bijwerken",
"Failed to delete base": "Kan basis niet verwijderen",
"Failed to create property": "Kan eigenschap niet aanmaken",
"Failed to update property": "Kan eigenschap niet bijwerken",
"Failed to delete property": "Kan eigenschap niet verwijderen",
"Failed to reorder property": "Kan eigenschap niet opnieuw ordenen",
"Failed to create view": "Kan weergave niet aanmaken",
"Failed to update view": "Kan weergave niet bijwerken",
"Failed to delete view": "Kan weergave niet verwijderen",
"Failed to create row": "Kan rij niet aanmaken",
"Failed to update row": "Kan rij niet bijwerken",
"Failed to delete row": "Kan rij niet verwijderen",
"Failed to delete rows": "Kan rijen niet verwijderen",
"Failed to reorder row": "Kan rij niet opnieuw ordenen",
"Failed to move card": "Kan kaart niet verplaatsen",
"Failed to add card": "Kan kaart niet toevoegen",
"Failed to export CSV": "Kan CSV niet exporteren",
"Favorite spaces": "Favoriete ruimtes",
"Favorite spaces appear here": "Favoriete ruimtes verschijnen hier",
"Favorites": "Favorieten",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Mermaid-diagram invoegen",
"Insert and design Drawio diagrams": "Drawio-diagrammen invoegen en ontwerpen",
"Insert current date": "Huidige datum invoegen",
"Time": "Tijd",
"Insert current time": "Huidige tijd invoegen",
"Draw and sketch excalidraw diagrams": "Excalidraw-diagrammen tekenen en schetsen",
"Multiple": "Meerdere",
"Turn into": "Omzetten naar",
@@ -424,6 +446,7 @@
"Names do not match": "Namen komen niet overeen",
"Today, {{time}}": "Vandaag, {{time}}",
"Yesterday, {{time}}": "Gisteren, {{time}}",
"now": "nu",
"Space created successfully": "Ruimte succesvol aangemaakt",
"Space updated successfully": "Ruimte succesvol bijgewerkt",
"Space deleted successfully": "Ruimte succesvol verwijderd",
@@ -596,6 +619,8 @@
"Deleted by": "Verwijderd door",
"Deleted at": "Verwijderd op",
"Preview": "Voorbeeld",
"Base preview unavailable": "Voorbeeld van basis niet beschikbaar",
"Restore this base to view its contents.": "Herstel deze basis om de inhoud te bekijken.",
"Subpages": "Subpagina's",
"Failed to load subpages": "Subpagina's laden mislukt",
"No subpages": "Geen subpagina's",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Weet je zeker dat je dit sjabloon wilt verwijderen?",
"Template scope updated": "Bereik van sjabloon bijgewerkt",
"Choose which space this template belongs to": "Kies bij welke werkruimte dit sjabloon hoort",
"Only workspace admins can create global templates.": "Alleen workspace-beheerders kunnen globale sjablonen maken.",
"You need edit access to this space to create templates.": "Je hebt bewerkingstoegang tot deze ruimte nodig om sjablonen te maken.",
"Scope": "Bereik",
"Select scope": "Bereik selecteren",
"Title": "Titel",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Pagina's en werkruimtes zoeken...",
"No results found": "Geen resultaten gevonden",
"You don't have permission to create pages here": "Je hebt geen toestemming om hier pagina's te maken",
"Chat menu": "Chatmenu",
"Chat menu for {{title}}": "Chatmenu voor {{title}}",
"API key menu": "API-sleutelmenu",
"Jump to comment selection": "Naar reactieselectie springen",
"Slash commands": "Slash-opdrachten",
@@ -1063,7 +1090,7 @@
"Filter": "Filter",
"Page title": "Paginatitel",
"Page content": "Pagina-inhoud",
"Member actions": "Lidacties",
"Member actions for {{name}}": "Lidacties voor {{name}}",
"Toggle password visibility": "Wachtwoordzichtbaarheid in-/uitschakelen",
"Send comment": "Reactie verzenden",
"Token actions": "Tokenacties",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
"Allow personal spaces": "Persoonlijke ruimtes toestaan",
"Members can create their own personal space.": "Leden kunnen hun eigen persoonlijke ruimte aanmaken.",
"Toggle allow personal spaces": "Persoonlijke ruimtes toestaan in- of uitschakelen",
"Create personal space": "Persoonlijke ruimte aanmaken",
"Personal space": "Persoonlijke ruimte",
"{{name}}'s space": "ruimte van {{name}}",
"Apply": "Toepassen",
"Cells that aren't already a page reference will be cleared.": "Cellen die nog geen paginaverwijzing zijn, worden leeggemaakt.",
"Cells that aren't a valid URL will be cleared.": "Cellen die geen geldige URL zijn, worden leeggemaakt.",
"Cells that aren't a valid email address will be cleared.": "Cellen die geen geldig e-mailadres zijn, worden leeggemaakt.",
"Cells that can't be parsed as a date will be cleared.": "Cellen die niet als een datum kunnen worden geïnterpreteerd, worden leeggemaakt.",
"Cells that can't be parsed as a number will be cleared.": "Cellen die niet als een getal kunnen worden geïnterpreteerd, worden leeggemaakt.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cellen worden geconverteerd (yes/true/1 worden aangevinkt; al het andere wordt uitgevinkt of leeggemaakt).",
"Cells will be reinterpreted under the new type.": "Cellen worden opnieuw geïnterpreteerd volgens het nieuwe type.",
"Cells will be replaced with a comma-separated list of file names.": "Cellen worden vervangen door een door komma's gescheiden lijst met bestandsnamen.",
"Cells will be replaced with a comma-separated list of option names.": "Cellen worden vervangen door een door komma's gescheiden lijst met optienamen.",
"Cells will be replaced with the option name.": "Cellen worden vervangen door de optienaam.",
"Cells will be replaced with the page title.": "Cellen worden vervangen door de paginatitel.",
"Cells will be replaced with the person's name.": "Cellen worden vervangen door de naam van de persoon.",
"Change type": "Type wijzigen",
"Change type to {{label}}?": "Type wijzigen naar {{label}}?",
"Converting…": "Converteren…",
"Existing values become single-item lists. No data is lost.": "Bestaande waarden worden lijsten met één item. Er gaan geen gegevens verloren.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Alleen het eerste geselecteerde item per rij blijft behouden; de rest wordt verwijderd.",
"Previous record": "Vorige record",
"Next record": "Volgende record",
"Record actions": "Recordacties",
"Delete record": "Record verwijderen",
"Delete record?": "Record verwijderen?",
"This action cannot be undone.": "Deze actie kan niet ongedaan worden gemaakt.",
"to navigate": "om te navigeren",
"to close": "om te sluiten",
"Expand row {{number}}": "Rij {{number}} uitvouwen",
"Saving…": "Opslaan…",
"Read-only": "Alleen-lezen",
"Loading…": "Laden…",
"Updated {{when}}": "Bijgewerkt {{when}}",
"Add property": "Eigenschap toevoegen",
"Create property": "Eigenschap aanmaken",
"Hide properties": "Eigenschappen verbergen",
"Find a property type": "Een eigenschapstype zoeken",
"Properties": "Eigenschappen",
"A property with this name already exists": "Er bestaat al een eigenschap met deze naam",
"Add card": "Kaart toevoegen",
"Add filter": "Filter toevoegen",
"Add option": "Optie toevoegen",
"Add sort": "Sortering toevoegen",
"Add view": "Weergave toevoegen",
"All data in this column will be lost.": "Alle gegevens in deze kolom gaan verloren.",
"Allow multiple people": "Meerdere personen toestaan",
"Alphabetize": "Alfabetisch sorteren",
"Are you sure you want to delete": "Weet je zeker dat je wilt verwijderen",
"Ascending": "Oplopend",
"Base deleted": "Base verwijderd",
"Base name": "Basenaam",
"Base table": "Basistabel",
"Card properties": "Kaarteigenschappen",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cellen die langer zijn dan de tekstlimiet worden afgekapt en de extra inhoud gaat permanent verloren.",
"Checkbox": "Selectievakje",
"Checked by default": "Standaard aangevinkt",
"Choose a property": "Kies een eigenschap",
"Clear selection": "Selectie wissen",
"Column options": "Kolomopties",
"Comma, period": "Komma, punt",
"Complete": "Voltooid",
"Contains": "Bevat",
"Copy link to view": "Link naar weergave kopiëren",
"Create a status property": "Een statuseigenschap maken",
"Created at": "Aangemaakt op",
"Currency": "Valuta",
"Decimal places": "Decimalen",
"Default value": "Standaardwaarde",
"Delete property": "Eigenschap verwijderen",
"Delete view": "Weergave verwijderen",
"Delete {{count}} rows?_one": "1 rij verwijderen?",
"Delete {{count}} rows?_other": "{{count}} rijen verwijderen?",
"Descending": "Aflopend",
"Discard": "Negeren",
"Doesn't contain": "Bevat niet",
"Done": "Klaar",
"Edit formula": "Formule bewerken",
"Edit property": "Eigenschap bewerken",
"Export CSV": "CSV exporteren",
"Failed to load base": "Base laden mislukt",
"False": "Onwaar",
"File": "Bestand",
"Filter and sort changes are visible only to you": "Wijzigingen in filters en sortering zijn alleen zichtbaar voor jou",
"Filter by": "Filteren op",
"Formula": "Formule",
"Get started with": "Aan de slag met",
"Greater than": "Groter dan",
"Group by": "Groeperen op",
"Group this board by a select or status property.": "Groepeer dit bord op een selectie- of statuseigenschap.",
"Hide all": "Alles verbergen",
"Hide group": "Groep verbergen",
"In Progress": "In uitvoering",
"In progress": "In uitvoering",
"Include time": "Tijd opnemen",
"Insert a kanban board on this page": "Voeg een kanbanbord in op deze pagina",
"Insert an inline base on this page": "Voeg een inline base in op deze pagina",
"Is": "Is",
"Is after": "Is na",
"Is any of": "Is een van",
"Is before": "Is vóór",
"Is empty": "Is leeg",
"Is none of": "Is geen van",
"Is not": "Is niet",
"Is not empty": "Is niet leeg",
"Is on or after": "Is op of na",
"Is on or before": "Is op of vóór",
"Is within": "Is binnen",
"Keep editing": "Doorgaan met bewerken",
"Last edited at": "Laatst bewerkt op",
"Last edited by": "Laatst bewerkt door",
"Less than": "Kleiner dan",
"Link copied to clipboard": "Link gekopieerd naar klembord",
"Local": "Lokaal",
"Long text": "Lange tekst",
"Moved card to {{column}}": "Kaart verplaatst naar {{column}}",
"Moved column to {{column}}": "Kolom verplaatst naar {{column}}",
"Multi-select": "Meervoudige selectie",
"New row": "Nieuwe rij",
"Next month": "Volgende maand",
"Next week": "Volgende week",
"Next year": "Volgend jaar",
"No filters applied": "Geen filters toegepast",
"No match": "Geen overeenkomst",
"No options for this property type": "Geen opties voor dit eigenschapstype",
"No sorts applied": "Geen sorteringen toegepast",
"No value": "Geen waarde",
"None": "Geen",
"Not started": "Niet gestart",
"Number": "Getal",
"One month ago": "Een maand geleden",
"One month from now": "Over een maand",
"One week ago": "Een week geleden",
"One week from now": "Over een week",
"Open as page": "Openen als pagina",
"Options": "Opties",
"Past month": "Afgelopen maand",
"Past week": "Afgelopen week",
"Past year": "Afgelopen jaar",
"Percent": "Procent",
"Period, comma": "Punt, komma",
"Person": "Persoon",
"Please enter a valid email": "Voer een geldig e-mailadres in",
"Progress": "Voortgang",
"Property options": "Eigenschapopties",
"Relative": "Relatief",
"Rename property": "Eigenschap hernoemen",
"Reset": "Opnieuw instellen",
"Save for everyone": "Opslaan voor iedereen",
"Select": "Selecteren",
"Select a property": "Selecteer een eigenschap",
"Select all loaded rows": "Alle geladen rijen selecteren",
"Show all": "Alles tonen",
"Sort": "Sorteren",
"Sort by": "Sorteren op",
"Space, comma": "Spatie, komma",
"Space, period": "Spatie, punt",
"This board has no grouping property yet.": "Dit bord heeft nog geen groeperingseigenschap.",
"This month": "Deze maand",
"This year": "Dit jaar",
"Thousands and decimal separators": "Scheidingstekens voor duizendtallen en decimalen",
"Time format": "Tijdnotatie",
"To Do": "Te doen",
"Tomorrow": "Morgen",
"True": "Waar",
"Unsaved changes": "Niet-opgeslagen wijzigingen",
"Untitled base": "Base zonder titel",
"URL": "URL",
"Value": "Waarde",
"View updated for everyone": "Weergave bijgewerkt voor iedereen",
"You have unsaved changes. Do you want to discard them?": "Je hebt niet-opgeslagen wijzigingen. Wil je deze negeren?",
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd"
}
@@ -41,6 +41,8 @@
"Dark": "Escuro",
"Date": "Data",
"Delete": "Excluir",
"Remove from page": "Remover da página",
"Base options": "Opções da base",
"Delete group": "Excluir grupo",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Você tem certeza que quer deletar essa página? Isso irá deletar todas as páginas filhas e to o histórico. Esta ação é irreversível.",
"Description": "Descrição",
@@ -76,6 +78,24 @@
"Failed to import pages": "Falha ao importar páginas",
"Failed to load page. An error occurred.": "Falha ao carregar página. Ocorreu um erro.",
"Failed to update data": "Falha ao atualizar dados",
"Failed to create base": "Falha ao criar base",
"Failed to update base": "Falha ao atualizar base",
"Failed to delete base": "Falha ao excluir base",
"Failed to create property": "Falha ao criar propriedade",
"Failed to update property": "Falha ao atualizar propriedade",
"Failed to delete property": "Falha ao excluir propriedade",
"Failed to reorder property": "Falha ao reordenar propriedade",
"Failed to create view": "Falha ao criar visualização",
"Failed to update view": "Falha ao atualizar visualização",
"Failed to delete view": "Falha ao excluir visualização",
"Failed to create row": "Falha ao criar linha",
"Failed to update row": "Falha ao atualizar linha",
"Failed to delete row": "Falha ao excluir linha",
"Failed to delete rows": "Falha ao excluir linhas",
"Failed to reorder row": "Falha ao reordenar linha",
"Failed to move card": "Falha ao mover cartão",
"Failed to add card": "Falha ao adicionar cartão",
"Failed to export CSV": "Falha ao exportar CSV",
"Favorite spaces": "Espaços favoritos",
"Favorite spaces appear here": "Os espaços favoritos aparecem aqui",
"Favorites": "Favoritos",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Inserir diagrama Mermaid",
"Insert and design Drawio diagrams": "Inserir e criar diagramas Drawio",
"Insert current date": "Inserir data atual",
"Time": "Horário",
"Insert current time": "Inserir horário atual",
"Draw and sketch excalidraw diagrams": "Desenhar e esboçar diagramas Excalidraw",
"Multiple": "Múltiplo",
"Turn into": "Transformar em",
@@ -424,6 +446,7 @@
"Names do not match": "Os nomes não correspondem",
"Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}",
"now": "agora",
"Space created successfully": "Espaço criado com sucesso",
"Space updated successfully": "Espaço atualizado com sucesso",
"Space deleted successfully": "Espaço excluído com sucesso",
@@ -596,6 +619,8 @@
"Deleted by": "Excluído por",
"Deleted at": "Excluído em",
"Preview": "Visualização",
"Base preview unavailable": "Pré-visualização da base indisponível",
"Restore this base to view its contents.": "Restaure esta base para visualizar seu conteúdo.",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar as subpáginas",
"No subpages": "Nenhuma subpágina",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Tem certeza de que deseja excluir este modelo?",
"Template scope updated": "Escopo do modelo atualizado",
"Choose which space this template belongs to": "Escolha a qual espaço este modelo pertence",
"Only workspace admins can create global templates.": "Somente administradores do espaço de trabalho podem criar modelos globais.",
"You need edit access to this space to create templates.": "Você precisa de acesso de edição a este espaço para criar modelos.",
"Scope": "Escopo",
"Select scope": "Selecionar escopo",
"Title": "Título",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Pesquisar páginas e espaços...",
"No results found": "Nenhum resultado encontrado",
"You don't have permission to create pages here": "Você não tem permissão para criar páginas aqui",
"Chat menu": "Menu do chat",
"Chat menu for {{title}}": "Menu do chat para {{title}}",
"API key menu": "Menu da chave de API",
"Jump to comment selection": "Ir para a seleção de comentários",
"Slash commands": "Comandos de barra",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrar",
"Page title": "Título da página",
"Page content": "Conteúdo da página",
"Member actions": "Ações do membro",
"Member actions for {{name}}": "Ações do membro para {{name}}",
"Toggle password visibility": "Alternar visibilidade da senha",
"Send comment": "Enviar comentário",
"Token actions": "Ações do token",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
"Page menu for {{name}}": "Menu da página de {{name}}",
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
"Allow personal spaces": "Permitir espaços pessoais",
"Members can create their own personal space.": "Os membros podem criar seu próprio espaço pessoal.",
"Toggle allow personal spaces": "Ativar/desativar permissão para espaços pessoais",
"Create personal space": "Criar espaço pessoal",
"Personal space": "Espaço pessoal",
"{{name}}'s space": "Espaço de {{name}}",
"Apply": "Aplicar",
"Cells that aren't already a page reference will be cleared.": "As células que ainda não forem uma referência de página serão limpas.",
"Cells that aren't a valid URL will be cleared.": "As células que não forem uma URL válida serão limpas.",
"Cells that aren't a valid email address will be cleared.": "As células que não forem um endereço de e-mail válido serão limpas.",
"Cells that can't be parsed as a date will be cleared.": "As células que não puderem ser interpretadas como uma data serão limpas.",
"Cells that can't be parsed as a number will be cleared.": "As células que não puderem ser interpretadas como um número serão limpas.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "As células serão convertidas (yes/true/1 se tornam marcadas; todo o resto se torna desmarcado ou limpo).",
"Cells will be reinterpreted under the new type.": "As células serão reinterpretadas com o novo tipo.",
"Cells will be replaced with a comma-separated list of file names.": "As células serão substituídas por uma lista de nomes de arquivos separados por vírgula.",
"Cells will be replaced with a comma-separated list of option names.": "As células serão substituídas por uma lista de nomes de opções separados por vírgula.",
"Cells will be replaced with the option name.": "As células serão substituídas pelo nome da opção.",
"Cells will be replaced with the page title.": "As células serão substituídas pelo título da página.",
"Cells will be replaced with the person's name.": "As células serão substituídas pelo nome da pessoa.",
"Change type": "Alterar tipo",
"Change type to {{label}}?": "Alterar tipo para {{label}}?",
"Converting…": "Convertendo…",
"Existing values become single-item lists. No data is lost.": "Os valores existentes se tornam listas de item único. Nenhum dado é perdido.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Apenas o primeiro item selecionado por linha será mantido; o restante será descartado.",
"Previous record": "Registro anterior",
"Next record": "Próximo registro",
"Record actions": "Ações do registro",
"Delete record": "Excluir registro",
"Delete record?": "Excluir registro?",
"This action cannot be undone.": "Esta ação não pode ser desfeita.",
"to navigate": "para navegar",
"to close": "para fechar",
"Expand row {{number}}": "Expandir linha {{number}}",
"Saving…": "Salvando…",
"Read-only": "Somente leitura",
"Loading…": "Carregando…",
"Updated {{when}}": "Atualizado {{when}}",
"Add property": "Adicionar propriedade",
"Create property": "Criar propriedade",
"Hide properties": "Ocultar propriedades",
"Find a property type": "Encontrar um tipo de propriedade",
"Properties": "Propriedades",
"A property with this name already exists": "Já existe uma propriedade com este nome",
"Add card": "Adicionar cartão",
"Add filter": "Adicionar filtro",
"Add option": "Adicionar opção",
"Add sort": "Adicionar ordenação",
"Add view": "Adicionar visualização",
"All data in this column will be lost.": "Todos os dados nesta coluna serão perdidos.",
"Allow multiple people": "Permitir várias pessoas",
"Alphabetize": "Ordenar alfabeticamente",
"Are you sure you want to delete": "Tem certeza de que deseja excluir",
"Ascending": "Crescente",
"Base deleted": "Base excluída",
"Base name": "Nome da base",
"Base table": "Tabela da base",
"Card properties": "Propriedades do cartão",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Células maiores do que o limite de Texto serão truncadas e o conteúdo extra será perdido permanentemente.",
"Checkbox": "Caixa de seleção",
"Checked by default": "Marcado por padrão",
"Choose a property": "Escolha uma propriedade",
"Clear selection": "Limpar seleção",
"Column options": "Opções da coluna",
"Comma, period": "Vírgula, ponto",
"Complete": "Concluído",
"Contains": "Contém",
"Copy link to view": "Copiar link da visualização",
"Create a status property": "Criar uma propriedade de status",
"Created at": "Criado em",
"Currency": "Moeda",
"Decimal places": "Casas decimais",
"Default value": "Valor padrão",
"Delete property": "Excluir propriedade",
"Delete view": "Excluir visualização",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "Decrescente",
"Discard": "Descartar",
"Doesn't contain": "Não contém",
"Done": "Concluído",
"Edit formula": "Editar fórmula",
"Edit property": "Editar propriedade",
"Export CSV": "Exportar CSV",
"Failed to load base": "Falha ao carregar a base",
"False": "Falso",
"File": "Arquivo",
"Filter and sort changes are visible only to you": "As alterações de filtro e ordenação são visíveis somente para você",
"Filter by": "Filtrar por",
"Formula": "Fórmula",
"Get started with": "Começar com",
"Greater than": "Maior que",
"Group by": "Agrupar por",
"Group this board by a select or status property.": "Agrupe este quadro por uma propriedade de seleção ou status.",
"Hide all": "Ocultar tudo",
"Hide group": "Ocultar grupo",
"In Progress": "Em andamento",
"In progress": "Em andamento",
"Include time": "Incluir hora",
"Insert a kanban board on this page": "Inserir um quadro kanban nesta página",
"Insert an inline base on this page": "Inserir uma base inline nesta página",
"Is": "É",
"Is after": "É depois de",
"Is any of": "É qualquer um de",
"Is before": "É antes de",
"Is empty": "Está vazio",
"Is none of": "Não é nenhum de",
"Is not": "Não é",
"Is not empty": "Não está vazio",
"Is on or after": "É em ou depois de",
"Is on or before": "É em ou antes de",
"Is within": "Está dentro de",
"Keep editing": "Continuar editando",
"Last edited at": "Última edição em",
"Last edited by": "Última edição por",
"Less than": "Menor que",
"Link copied to clipboard": "Link copiado para a área de transferência",
"Local": "Local",
"Long text": "Texto longo",
"Moved card to {{column}}": "Cartão movido para {{column}}",
"Moved column to {{column}}": "Coluna movida para {{column}}",
"Multi-select": "Seleção múltipla",
"New row": "Nova linha",
"Next month": "Próximo mês",
"Next week": "Próxima semana",
"Next year": "Próximo ano",
"No filters applied": "Nenhum filtro aplicado",
"No match": "Sem correspondência",
"No options for this property type": "Sem opções para este tipo de propriedade",
"No sorts applied": "Nenhuma ordenação aplicada",
"No value": "Sem valor",
"None": "Nenhum",
"Not started": "Não iniciado",
"Number": "Número",
"One month ago": "Há um mês",
"One month from now": "Daqui a um mês",
"One week ago": "Há uma semana",
"One week from now": "Daqui a uma semana",
"Open as page": "Abrir como página",
"Options": "Opções",
"Past month": "Mês passado",
"Past week": "Semana passada",
"Past year": "Ano passado",
"Percent": "Porcentagem",
"Period, comma": "Ponto, vírgula",
"Person": "Pessoa",
"Please enter a valid email": "Por favor, insira um e-mail válido",
"Progress": "Progresso",
"Property options": "Opções da propriedade",
"Relative": "Relativo",
"Rename property": "Renomear propriedade",
"Reset": "Redefinir",
"Save for everyone": "Salvar para todos",
"Select": "Seleção",
"Select a property": "Selecione uma propriedade",
"Select all loaded rows": "Selecionar todas as linhas carregadas",
"Show all": "Mostrar tudo",
"Sort": "Ordenar",
"Sort by": "Ordenar por",
"Space, comma": "Espaço, vírgula",
"Space, period": "Espaço, ponto",
"This board has no grouping property yet.": "Este quadro ainda não tem uma propriedade de agrupamento.",
"This month": "Este mês",
"This year": "Este ano",
"Thousands and decimal separators": "Separadores de milhares e decimais",
"Time format": "Formato de hora",
"To Do": "A fazer",
"Tomorrow": "Amanhã",
"True": "Verdadeiro",
"Unsaved changes": "Alterações não salvas",
"Untitled base": "Base sem título",
"URL": "URL",
"Value": "Valor",
"View updated for everyone": "Visualização atualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Você tem alterações não salvas. Deseja descartá-las?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Темная",
"Date": "Дата",
"Delete": "Удалить",
"Remove from page": "Удалить со страницы",
"Base options": "Параметры базы",
"Delete group": "Удалить группу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
"Description": "Описание",
@@ -76,6 +78,24 @@
"Failed to import pages": "Не удалось импортировать страницы",
"Failed to load page. An error occurred.": "Не удалось загрузить страницу. Произошла ошибка.",
"Failed to update data": "Не удалось обновить данные",
"Failed to create base": "Не удалось создать базу",
"Failed to update base": "Не удалось обновить базу",
"Failed to delete base": "Не удалось удалить базу",
"Failed to create property": "Не удалось создать свойство",
"Failed to update property": "Не удалось обновить свойство",
"Failed to delete property": "Не удалось удалить свойство",
"Failed to reorder property": "Не удалось изменить порядок свойств",
"Failed to create view": "Не удалось создать представление",
"Failed to update view": "Не удалось обновить представление",
"Failed to delete view": "Не удалось удалить представление",
"Failed to create row": "Не удалось создать строку",
"Failed to update row": "Не удалось обновить строку",
"Failed to delete row": "Не удалось удалить строку",
"Failed to delete rows": "Не удалось удалить строки",
"Failed to reorder row": "Не удалось изменить порядок строки",
"Failed to move card": "Не удалось переместить карточку",
"Failed to add card": "Не удалось добавить карточку",
"Failed to export CSV": "Не удалось экспортировать CSV",
"Favorite spaces": "Избранные пространства",
"Favorite spaces appear here": "Здесь отображаются избранные пространства",
"Favorites": "Избранное",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
"Insert and design Drawio diagrams": "Вставляйте и редактируйте диаграммы Drawio",
"Insert current date": "Вставить текущую дату",
"Time": "Время",
"Insert current time": "Вставить текущее время",
"Draw and sketch excalidraw diagrams": "Рисуйте и создавайте диаграммы Excalidraw",
"Multiple": "Несколько",
"Turn into": "Преобразовать в",
@@ -424,6 +446,7 @@
"Names do not match": "Названия не совпадают",
"Today, {{time}}": "Сегодня, {{time}}",
"Yesterday, {{time}}": "Вчера, {{time}}",
"now": "сейчас",
"Space created successfully": "Пространство успешно создано",
"Space updated successfully": "Пространство успешно обновлено",
"Space deleted successfully": "Пространство успешно удалено",
@@ -596,6 +619,8 @@
"Deleted by": "Удалено пользователем",
"Deleted at": "Удалено",
"Preview": "Предпросмотр",
"Base preview unavailable": "Предпросмотр базы недоступен",
"Restore this base to view its contents.": "Восстановите эту базу, чтобы просмотреть её содержимое.",
"Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Вы уверены, что хотите удалить этот шаблон?",
"Template scope updated": "Область действия шаблона обновлена",
"Choose which space this template belongs to": "Выберите, к какому пространству относится этот шаблон",
"Only workspace admins can create global templates.": "Только администраторы рабочего пространства могут создавать глобальные шаблоны.",
"You need edit access to this space to create templates.": "Чтобы создавать шаблоны, вам нужен доступ на редактирование этого пространства.",
"Scope": "Область действия",
"Select scope": "Выберите область действия",
"Title": "Заголовок",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Поиск страниц и пространств...",
"No results found": "Результаты не найдены",
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
"Chat menu": "Меню чата",
"Chat menu for {{title}}": "Меню чата для {{title}}",
"API key menu": "Меню API-ключа",
"Jump to comment selection": "Перейти к выбору комментария",
"Slash commands": "Команды со слешем",
@@ -1063,7 +1090,7 @@
"Filter": "Фильтр",
"Page title": "Заголовок страницы",
"Page content": "Содержимое страницы",
"Member actions": "Действия с участником",
"Member actions for {{name}}": "Действия с участниками для {{name}}",
"Toggle password visibility": "Переключить видимость пароля",
"Send comment": "Отправить комментарий",
"Token actions": "Действия с токеном",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Allow personal spaces": "Разрешить личные пространства",
"Members can create their own personal space.": "Участники могут создавать свои собственные личные пространства.",
"Toggle allow personal spaces": "Переключить разрешение личных пространств",
"Create personal space": "Создать личное пространство",
"Personal space": "Личное пространство",
"{{name}}'s space": "Пространство {{name}}",
"Apply": "Применить",
"Cells that aren't already a page reference will be cleared.": "Ячейки, которые ещё не являются ссылкой на страницу, будут очищены.",
"Cells that aren't a valid URL will be cleared.": "Ячейки, которые не содержат допустимый URL, будут очищены.",
"Cells that aren't a valid email address will be cleared.": "Ячейки, которые не содержат допустимый адрес электронной почты, будут очищены.",
"Cells that can't be parsed as a date will be cleared.": "Ячейки, которые нельзя распознать как дату, будут очищены.",
"Cells that can't be parsed as a number will be cleared.": "Ячейки, которые нельзя распознать как число, будут очищены.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Значения в ячейках будут приведены к типу (yes/true/1 станут отмеченными; всё остальное станет неотмеченным или будет очищено).",
"Cells will be reinterpreted under the new type.": "Значения в ячейках будут заново интерпретированы в соответствии с новым типом.",
"Cells will be replaced with a comma-separated list of file names.": "Ячейки будут заменены списком имён файлов, разделённых запятыми.",
"Cells will be replaced with a comma-separated list of option names.": "Ячейки будут заменены списком названий параметров, разделённых запятыми.",
"Cells will be replaced with the option name.": "Ячейки будут заменены названием параметра.",
"Cells will be replaced with the page title.": "Ячейки будут заменены заголовком страницы.",
"Cells will be replaced with the person's name.": "Ячейки будут заменены именем человека.",
"Change type": "Изменить тип",
"Change type to {{label}}?": "Изменить тип на {{label}}?",
"Converting…": "Преобразование…",
"Existing values become single-item lists. No data is lost.": "Существующие значения станут одноэлементными списками. Данные не будут потеряны.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Для каждой строки будет сохранён только первый выбранный элемент; остальные будут отброшены.",
"Previous record": "Предыдущая запись",
"Next record": "Следующая запись",
"Record actions": "Действия с записью",
"Delete record": "Удалить запись",
"Delete record?": "Удалить запись?",
"This action cannot be undone.": "Это действие нельзя отменить.",
"to navigate": "для навигации",
"to close": "для закрытия",
"Expand row {{number}}": "Развернуть строку {{number}}",
"Saving…": "Сохранение…",
"Read-only": "Только для чтения",
"Loading…": "Загрузка…",
"Updated {{when}}": "Обновлено {{when}}",
"Add property": "Добавить свойство",
"Create property": "Создать свойство",
"Hide properties": "Скрыть свойства",
"Find a property type": "Найти тип свойства",
"Properties": "Свойства",
"A property with this name already exists": "Свойство с таким именем уже существует",
"Add card": "Добавить карточку",
"Add filter": "Добавить фильтр",
"Add option": "Добавить вариант",
"Add sort": "Добавить сортировку",
"Add view": "Добавить представление",
"All data in this column will be lost.": "Все данные в этом столбце будут потеряны.",
"Allow multiple people": "Разрешить нескольких пользователей",
"Alphabetize": "По алфавиту",
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
"Ascending": "По возрастанию",
"Base deleted": "База удалена",
"Base name": "Название базы",
"Base table": "Таблица базы",
"Card properties": "Свойства карточки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Ячейки, длина которых превышает ограничение для текста, будут обрезаны, а лишнее содержимое будет безвозвратно потеряно.",
"Checkbox": "Флажок",
"Checked by default": "Отмечено по умолчанию",
"Choose a property": "Выберите свойство",
"Clear selection": "Очистить выбор",
"Column options": "Параметры столбца",
"Comma, period": "Запятая, точка",
"Complete": "Завершено",
"Contains": "Содержит",
"Copy link to view": "Скопировать ссылку на представление",
"Create a status property": "Создать свойство статуса",
"Created at": "Создано",
"Currency": "Валюта",
"Decimal places": "Десятичные знаки",
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
"Done": "Готово",
"Edit formula": "Редактировать формулу",
"Edit property": "Редактировать свойство",
"Export CSV": "Экспорт CSV",
"Failed to load base": "Не удалось загрузить базу",
"False": "Ложь",
"File": "Файл",
"Filter and sort changes are visible only to you": "Изменения фильтра и сортировки видны только вам",
"Filter by": "Фильтр по",
"Formula": "Формула",
"Get started with": "Начать работу с",
"Greater than": "Больше чем",
"Group by": "Группировать по",
"Group this board by a select or status property.": "Сгруппируйте эту доску по свойству выбора или статуса.",
"Hide all": "Скрыть все",
"Hide group": "Скрыть группу",
"In Progress": "В процессе",
"In progress": "В процессе",
"Include time": "Включить время",
"Insert a kanban board on this page": "Вставить канбан-доску на эту страницу",
"Insert an inline base on this page": "Вставить встроенную базу на эту страницу",
"Is": "Равно",
"Is after": "После",
"Is any of": "Совпадает с любым из",
"Is before": "До",
"Is empty": "Пусто",
"Is none of": "Не совпадает ни с одним из",
"Is not": "Не равно",
"Is not empty": "Не пусто",
"Is on or after": "В этот день или позже",
"Is on or before": "В этот день или раньше",
"Is within": "Находится в пределах",
"Keep editing": "Продолжить редактирование",
"Last edited at": "Последнее изменение",
"Last edited by": "Последний изменивший",
"Less than": "Меньше чем",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Local": "Локальный",
"Long text": "Длинный текст",
"Moved card to {{column}}": "Карточка перемещена в {{column}}",
"Moved column to {{column}}": "Столбец перемещён в {{column}}",
"Multi-select": "Множественный выбор",
"New row": "Новая строка",
"Next month": "Следующий месяц",
"Next week": "Следующая неделя",
"Next year": "Следующий год",
"No filters applied": "Фильтры не применены",
"No match": "Нет совпадений",
"No options for this property type": "Для этого типа свойства нет параметров",
"No sorts applied": "Сортировка не применена",
"No value": "Нет значения",
"None": "Нет",
"Not started": "Не начато",
"Number": "Число",
"One month ago": "Месяц назад",
"One month from now": "Через месяц",
"One week ago": "Неделю назад",
"One week from now": "Через неделю",
"Open as page": "Открыть как страницу",
"Options": "Параметры",
"Past month": "Прошлый месяц",
"Past week": "Прошлая неделя",
"Past year": "Прошлый год",
"Percent": "Процент",
"Period, comma": "Точка, запятая",
"Person": "Пользователь",
"Please enter a valid email": "Пожалуйста, введите корректный адрес электронной почты",
"Progress": "Прогресс",
"Property options": "Параметры свойства",
"Relative": "Относительный",
"Rename property": "Переименовать свойство",
"Reset": "Сбросить",
"Save for everyone": "Сохранить для всех",
"Select": "Выбор",
"Select a property": "Выберите свойство",
"Select all loaded rows": "Выбрать все загруженные строки",
"Show all": "Показать все",
"Sort": "Сортировка",
"Sort by": "Сортировать по",
"Space, comma": "Пробел, запятая",
"Space, period": "Пробел, точка",
"This board has no grouping property yet.": "У этой доски пока нет свойства для группировки.",
"This month": "Этот месяц",
"This year": "Этот год",
"Thousands and decimal separators": "Разделители тысяч и десятичных знаков",
"Time format": "Формат времени",
"To Do": "К выполнению",
"Tomorrow": "Завтра",
"True": "Истина",
"Unsaved changes": "Несохранённые изменения",
"Untitled base": "База без названия",
"URL": "URL",
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "Темна",
"Date": "Дата",
"Delete": "Видалити",
"Remove from page": "Видалити зі сторінки",
"Base options": "Параметри бази",
"Delete group": "Видалити групу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
"Description": "Опис",
@@ -76,6 +78,24 @@
"Failed to import pages": "Не вдалося імпортувати сторінки",
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
"Failed to update data": "Не вдалося оновити дані",
"Failed to create base": "Не вдалося створити базу",
"Failed to update base": "Не вдалося оновити базу",
"Failed to delete base": "Не вдалося видалити базу",
"Failed to create property": "Не вдалося створити властивість",
"Failed to update property": "Не вдалося оновити властивість",
"Failed to delete property": "Не вдалося видалити властивість",
"Failed to reorder property": "Не вдалося змінити порядок властивості",
"Failed to create view": "Не вдалося створити подання",
"Failed to update view": "Не вдалося оновити подання",
"Failed to delete view": "Не вдалося видалити подання",
"Failed to create row": "Не вдалося створити рядок",
"Failed to update row": "Не вдалося оновити рядок",
"Failed to delete row": "Не вдалося видалити рядок",
"Failed to delete rows": "Не вдалося видалити рядки",
"Failed to reorder row": "Не вдалося змінити порядок рядка",
"Failed to move card": "Не вдалося перемістити картку",
"Failed to add card": "Не вдалося додати картку",
"Failed to export CSV": "Не вдалося експортувати CSV",
"Favorite spaces": "Обрані простори",
"Favorite spaces appear here": "Тут відображаються обрані простори",
"Favorites": "Обране",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Вставити діаграму Mermaid",
"Insert and design Drawio diagrams": "Вставляйте та створюйте діаграми Drawio",
"Insert current date": "Вставити поточну дату",
"Time": "Час",
"Insert current time": "Вставити поточний час",
"Draw and sketch excalidraw diagrams": "Створюйте та кресліть діаграми Excalidraw",
"Multiple": "Кілька",
"Turn into": "Перетворити",
@@ -424,6 +446,7 @@
"Names do not match": "Назви не збігаються",
"Today, {{time}}": "Сьогодні, {{time}}",
"Yesterday, {{time}}": "Вчора, {{time}}",
"now": "зараз",
"Space created successfully": "Простір успішно створено",
"Space updated successfully": "Простір успішно оновлено",
"Space deleted successfully": "Простір успішно видалено",
@@ -596,6 +619,8 @@
"Deleted by": "Видалив",
"Deleted at": "Видалено",
"Preview": "Попередній перегляд",
"Base preview unavailable": "Попередній перегляд бази недоступний",
"Restore this base to view its contents.": "Відновіть цю базу, щоб переглянути її вміст.",
"Subpages": "Підсторінки",
"Failed to load subpages": "Не вдалося завантажити підсторінки",
"No subpages": "Немає підсторінок",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Ви впевнені, що хочете видалити цей шаблон?",
"Template scope updated": "Область дії шаблону оновлено",
"Choose which space this template belongs to": "Виберіть, до якого простору належить цей шаблон",
"Only workspace admins can create global templates.": "Лише адміністратори робочого простору можуть створювати глобальні шаблони.",
"You need edit access to this space to create templates.": "Щоб створювати шаблони, вам потрібен доступ до редагування цього простору.",
"Scope": "Область дії",
"Select scope": "Вибрати область дії",
"Title": "Назва",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Шукати сторінки та простори...",
"No results found": "Результати не знайдено",
"You don't have permission to create pages here": "У вас немає дозволу на створення сторінок тут",
"Chat menu": "Меню чату",
"Chat menu for {{title}}": "Меню чату для {{title}}",
"API key menu": "Меню ключа API",
"Jump to comment selection": "Перейти до вибору коментаря",
"Slash commands": "Слеш-команди",
@@ -1063,7 +1090,7 @@
"Filter": "Фільтр",
"Page title": "Назва сторінки",
"Page content": "Вміст сторінки",
"Member actions": "Дії з учасником",
"Member actions for {{name}}": "Дії учасника для {{name}}",
"Toggle password visibility": "Перемкнути видимість пароля",
"Send comment": "Надіслати коментар",
"Token actions": "Дії з токеном",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "{{name}} додано до обраного",
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
"Allow personal spaces": "Дозволити особисті простори",
"Members can create their own personal space.": "Учасники можуть створювати власний особистий простір.",
"Toggle allow personal spaces": "Перемкнути дозвіл на особисті простори",
"Create personal space": "Створити особистий простір",
"Personal space": "Особистий простір",
"{{name}}'s space": "Простір {{name}}",
"Apply": "Застосувати",
"Cells that aren't already a page reference will be cleared.": "Клітинки, які ще не є посиланням на сторінку, буде очищено.",
"Cells that aren't a valid URL will be cleared.": "Клітинки, які не є дійсною URL-адресою, буде очищено.",
"Cells that aren't a valid email address will be cleared.": "Клітинки, які не є дійсною електронною адресою, буде очищено.",
"Cells that can't be parsed as a date will be cleared.": "Клітинки, які не вдається розпізнати як дату, буде очищено.",
"Cells that can't be parsed as a number will be cleared.": "Клітинки, які не вдається розпізнати як число, буде очищено.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Клітинки буде приведено до потрібного формату (yes/true/1 стануть позначеними; усе інше стане непозначеним або буде очищено).",
"Cells will be reinterpreted under the new type.": "Клітинки буде повторно інтерпретовано відповідно до нового типу.",
"Cells will be replaced with a comma-separated list of file names.": "Клітинки буде замінено списком імен файлів, розділених комами.",
"Cells will be replaced with a comma-separated list of option names.": "Клітинки буде замінено списком назв параметрів, розділених комами.",
"Cells will be replaced with the option name.": "Клітинки буде замінено назвою параметра.",
"Cells will be replaced with the page title.": "Клітинки буде замінено заголовком сторінки.",
"Cells will be replaced with the person's name.": "Клітинки буде замінено іменем особи.",
"Change type": "Змінити тип",
"Change type to {{label}}?": "Змінити тип на {{label}}?",
"Converting…": "Перетворення…",
"Existing values become single-item lists. No data is lost.": "Наявні значення стануть одноелементними списками. Дані не буде втрачено.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Для кожного рядка буде збережено лише перший вибраний елемент; решту буде відкинуто.",
"Previous record": "Попередній запис",
"Next record": "Наступний запис",
"Record actions": "Дії із записом",
"Delete record": "Видалити запис",
"Delete record?": "Видалити запис?",
"This action cannot be undone.": "Цю дію неможливо скасувати.",
"to navigate": "для навігації",
"to close": "для закриття",
"Expand row {{number}}": "Розгорнути рядок {{number}}",
"Saving…": "Збереження…",
"Read-only": "Лише читання",
"Loading…": "Завантаження…",
"Updated {{when}}": "Оновлено {{when}}",
"Add property": "Додати властивість",
"Create property": "Створити властивість",
"Hide properties": "Приховати властивості",
"Find a property type": "Знайти тип властивості",
"Properties": "Властивості",
"A property with this name already exists": "Властивість із такою назвою вже існує",
"Add card": "Додати картку",
"Add filter": "Додати фільтр",
"Add option": "Додати опцію",
"Add sort": "Додати сортування",
"Add view": "Додати подання",
"All data in this column will be lost.": "Усі дані в цьому стовпці буде втрачено.",
"Allow multiple people": "Дозволити кількох людей",
"Alphabetize": "За алфавітом",
"Are you sure you want to delete": "Ви впевнені, що хочете видалити",
"Ascending": "За зростанням",
"Base deleted": "Базу видалено",
"Base name": "Назва бази",
"Base table": "Таблиця бази",
"Card properties": "Властивості картки",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Клітинки, довші за обмеження для тексту, буде обрізано, а зайвий вміст буде безповоротно втрачено.",
"Checkbox": "Прапорець",
"Checked by default": "Позначено за замовчуванням",
"Choose a property": "Оберіть властивість",
"Clear selection": "Очистити вибір",
"Column options": "Параметри стовпця",
"Comma, period": "Кома, крапка",
"Complete": "Завершено",
"Contains": "Містить",
"Copy link to view": "Скопіювати посилання на подання",
"Create a status property": "Створити властивість статусу",
"Created at": "Створено",
"Currency": "Валюта",
"Decimal places": "Десяткові знаки",
"Default value": "Значення за замовчуванням",
"Delete property": "Видалити властивість",
"Delete view": "Видалити подання",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "За спаданням",
"Discard": "Скасувати",
"Doesn't contain": "Не містить",
"Done": "Готово",
"Edit formula": "Редагувати формулу",
"Edit property": "Редагувати властивість",
"Export CSV": "Експорт CSV",
"Failed to load base": "Не вдалося завантажити базу",
"False": "Хибність",
"File": "Файл",
"Filter and sort changes are visible only to you": "Зміни фільтрів і сортування видимі лише вам",
"Filter by": "Фільтрувати за",
"Formula": "Формула",
"Get started with": "Почніть із",
"Greater than": "Більше ніж",
"Group by": "Групувати за",
"Group this board by a select or status property.": "Згрупуйте цю дошку за властивістю вибору або статусу.",
"Hide all": "Приховати все",
"Hide group": "Приховати групу",
"In Progress": "У процесі",
"In progress": "У процесі",
"Include time": "Включити час",
"Insert a kanban board on this page": "Вставити канбан-дошку на цю сторінку",
"Insert an inline base on this page": "Вставити вбудовану базу на цю сторінку",
"Is": "Є",
"Is after": "Після",
"Is any of": "Є одним із",
"Is before": "До",
"Is empty": "Порожнє",
"Is none of": "Не є жодним із",
"Is not": "Не є",
"Is not empty": "Не порожнє",
"Is on or after": "У цей день або пізніше",
"Is on or before": "У цей день або раніше",
"Is within": "У межах",
"Keep editing": "Продовжити редагування",
"Last edited at": "Востаннє відредаговано",
"Last edited by": "Востаннє відредагував",
"Less than": "Менше ніж",
"Link copied to clipboard": "Посилання скопійовано в буфер обміну",
"Local": "Локальний",
"Long text": "Довгий текст",
"Moved card to {{column}}": "Картку переміщено до {{column}}",
"Moved column to {{column}}": "Стовпець переміщено до {{column}}",
"Multi-select": "Мультивибір",
"New row": "Новий рядок",
"Next month": "Наступного місяця",
"Next week": "Наступного тижня",
"Next year": "Наступного року",
"No filters applied": "Фільтри не застосовано",
"No match": "Немає збігів",
"No options for this property type": "Немає опцій для цього типу властивості",
"No sorts applied": "Сортування не застосовано",
"No value": "Немає значення",
"None": "Немає",
"Not started": "Не розпочато",
"Number": "Число",
"One month ago": "Місяць тому",
"One month from now": "Через місяць",
"One week ago": "Тиждень тому",
"One week from now": "Через тиждень",
"Open as page": "Відкрити як сторінку",
"Options": "Опції",
"Past month": "За минулий місяць",
"Past week": "За минулий тиждень",
"Past year": "За минулий рік",
"Percent": "Відсоток",
"Period, comma": "Крапка, кома",
"Person": "Особа",
"Please enter a valid email": "Будь ласка, введіть коректну електронну адресу",
"Progress": "Прогрес",
"Property options": "Параметри властивості",
"Relative": "Відносний",
"Rename property": "Перейменувати властивість",
"Reset": "Скинути",
"Save for everyone": "Зберегти для всіх",
"Select": "Вибір",
"Select a property": "Виберіть властивість",
"Select all loaded rows": "Вибрати всі завантажені рядки",
"Show all": "Показати все",
"Sort": "Сортування",
"Sort by": "Сортувати за",
"Space, comma": "Пробіл, кома",
"Space, period": "Пробіл, крапка",
"This board has no grouping property yet.": "Ця дошка ще не має властивості групування.",
"This month": "Цього місяця",
"This year": "Цього року",
"Thousands and decimal separators": "Роздільники тисяч і десяткових знаків",
"Time format": "Формат часу",
"To Do": "До виконання",
"Tomorrow": "Завтра",
"True": "Істина",
"Unsaved changes": "Незбережені зміни",
"Untitled base": "База без назви",
"URL": "URL",
"Value": "Значення",
"View updated for everyone": "Подання оновлено для всіх",
"You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected"
}
@@ -41,6 +41,8 @@
"Dark": "深色",
"Date": "日期",
"Delete": "删除",
"Remove from page": "从页面中移除",
"Base options": "数据库选项",
"Delete group": "删除群组",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "您确定要删除这个页面吗?这将删除其子页面和页面历史记录。此操作不可逆。",
"Description": "描述",
@@ -76,6 +78,24 @@
"Failed to import pages": "导入页面失败",
"Failed to load page. An error occurred.": "页面加载失败。发生了一个错误。",
"Failed to update data": "数据更新失败",
"Failed to create base": "创建数据库失败",
"Failed to update base": "更新数据库失败",
"Failed to delete base": "删除数据库失败",
"Failed to create property": "创建属性失败",
"Failed to update property": "更新属性失败",
"Failed to delete property": "删除属性失败",
"Failed to reorder property": "重新排序属性失败",
"Failed to create view": "创建视图失败",
"Failed to update view": "更新视图失败",
"Failed to delete view": "删除视图失败",
"Failed to create row": "创建记录失败",
"Failed to update row": "更新记录失败",
"Failed to delete row": "删除记录失败",
"Failed to delete rows": "删除记录失败",
"Failed to reorder row": "重新排序记录失败",
"Failed to move card": "移动卡片失败",
"Failed to add card": "添加卡片失败",
"Failed to export CSV": "导出 CSV 失败",
"Favorite spaces": "收藏的空间",
"Favorite spaces appear here": "收藏的空间会显示在这里",
"Favorites": "收藏",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "插入 Mermaid 图表",
"Insert and design Drawio diagrams": "插入并设计 Drawio 图表",
"Insert current date": "插入当前日期",
"Time": "时间",
"Insert current time": "插入当前时间",
"Draw and sketch excalidraw diagrams": "绘制和草绘 Excalidraw 图表",
"Multiple": "多个",
"Turn into": "变成",
@@ -424,6 +446,7 @@
"Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}",
"now": "现在",
"Space created successfully": "空间创建成功",
"Space updated successfully": "空间更新成功",
"Space deleted successfully": "空间删除成功",
@@ -596,6 +619,8 @@
"Deleted by": "删除者",
"Deleted at": "删除于",
"Preview": "预览",
"Base preview unavailable": "无法预览数据库",
"Restore this base to view its contents.": "恢复此数据库以查看其内容。",
"Subpages": "子页面",
"Failed to load subpages": "加载子页面失败",
"No subpages": "没有子页面",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "你确定要删除此模板吗?",
"Template scope updated": "模板范围已更新",
"Choose which space this template belongs to": "选择此模板所属的空间",
"Only workspace admins can create global templates.": "只有工作区管理员才能创建全局模板。",
"You need edit access to this space to create templates.": "您需要拥有此空间的编辑权限才能创建模板。",
"Scope": "范围",
"Select scope": "选择范围",
"Title": "标题",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "搜索页面和空间……",
"No results found": "未找到结果",
"You don't have permission to create pages here": "你无权在此处创建页面",
"Chat menu": "聊天菜单",
"Chat menu for {{title}}": "{{title}} 的聊天菜单",
"API key menu": "API 密钥菜单",
"Jump to comment selection": "跳转到评论选择",
"Slash commands": "斜杠命令",
@@ -1063,7 +1090,7 @@
"Filter": "筛选",
"Page title": "页面标题",
"Page content": "页面内容",
"Member actions": "成员操作",
"Member actions for {{name}}": "{{name}} 的成员操作",
"Toggle password visibility": "切换密码可见性",
"Send comment": "发送评论",
"Token actions": "令牌操作",
@@ -1084,5 +1111,183 @@
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
"Allow personal spaces": "允许个人空间",
"Members can create their own personal space.": "成员可以创建自己的个人空间。",
"Toggle allow personal spaces": "切换是否允许个人空间",
"Create personal space": "创建个人空间",
"Personal space": "个人空间",
"{{name}}'s space": "{{name}} 的空间",
"Apply": "应用",
"Cells that aren't already a page reference will be cleared.": "尚未是页面引用的单元格将被清空。",
"Cells that aren't a valid URL will be cleared.": "不是有效 URL 的单元格将被清空。",
"Cells that aren't a valid email address will be cleared.": "不是有效电子邮件地址的单元格将被清空。",
"Cells that can't be parsed as a date will be cleared.": "无法解析为日期的单元格将被清空。",
"Cells that can't be parsed as a number will be cleared.": "无法解析为数字的单元格将被清空。",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "单元格将被强制转换(yes/true/1 将变为选中;其他所有值将变为未选中或被清空)。",
"Cells will be reinterpreted under the new type.": "单元格将在新类型下重新解释。",
"Cells will be replaced with a comma-separated list of file names.": "单元格将被替换为以逗号分隔的文件名列表。",
"Cells will be replaced with a comma-separated list of option names.": "单元格将被替换为以逗号分隔的选项名称列表。",
"Cells will be replaced with the option name.": "单元格将被替换为选项名称。",
"Cells will be replaced with the page title.": "单元格将被替换为页面标题。",
"Cells will be replaced with the person's name.": "单元格将被替换为此人的姓名。",
"Change type": "更改类型",
"Change type to {{label}}?": "将类型更改为 {{label}}",
"Converting…": "正在转换…",
"Existing values become single-item lists. No data is lost.": "现有值将变为只包含一项的列表。不会丢失数据。",
"Only the first selected item per row will be kept; the rest will be discarded.": "每行仅保留第一个已选项目;其余项目将被丢弃。",
"Previous record": "上一条记录",
"Next record": "下一条记录",
"Record actions": "记录操作",
"Delete record": "删除记录",
"Delete record?": "删除记录?",
"This action cannot be undone.": "此操作无法撤销。",
"to navigate": "用于导航",
"to close": "用于关闭",
"Expand row {{number}}": "展开第 {{number}} 行",
"Saving…": "正在保存…",
"Read-only": "只读",
"Loading…": "正在加载…",
"Updated {{when}}": "更新于 {{when}}",
"Add property": "添加属性",
"Create property": "创建属性",
"Hide properties": "隐藏属性",
"Find a property type": "查找属性类型",
"Properties": "属性",
"A property with this name already exists": "已存在同名属性",
"Add card": "添加卡片",
"Add filter": "添加筛选",
"Add option": "添加选项",
"Add sort": "添加排序",
"Add view": "添加视图",
"All data in this column will be lost.": "此列中的所有数据都将丢失。",
"Allow multiple people": "允许多人",
"Alphabetize": "按字母顺序",
"Are you sure you want to delete": "您确定要删除",
"Ascending": "升序",
"Base deleted": "资料库已删除",
"Base name": "资料库名称",
"Base table": "资料库表",
"Card properties": "卡片属性",
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "超出文本长度限制的单元格内容将被截断,额外内容将永久丢失。",
"Checkbox": "复选框",
"Checked by default": "默认选中",
"Choose a property": "选择一个属性",
"Clear selection": "清除选择",
"Column options": "列选项",
"Comma, period": "逗号、句点",
"Complete": "完成",
"Contains": "包含",
"Copy link to view": "复制视图链接",
"Create a status property": "创建状态属性",
"Created at": "创建于",
"Currency": "货币",
"Decimal places": "小数位数",
"Default value": "默认值",
"Delete property": "删除属性",
"Delete view": "删除视图",
"Delete {{count}} rows?_one": "删除 1 行?",
"Delete {{count}} rows?_other": "删除 {{count}} 行?",
"Descending": "降序",
"Discard": "放弃",
"Doesn't contain": "不包含",
"Done": "完成",
"Edit formula": "编辑公式",
"Edit property": "编辑属性",
"Export CSV": "导出 CSV",
"Failed to load base": "加载资料库失败",
"False": "假",
"File": "文件",
"Filter and sort changes are visible only to you": "筛选和排序更改仅对您可见",
"Filter by": "筛选条件",
"Formula": "公式",
"Get started with": "开始使用",
"Greater than": "大于",
"Group by": "分组依据",
"Group this board by a select or status property.": "按选择或状态属性对此看板进行分组。",
"Hide all": "全部隐藏",
"Hide group": "隐藏分组",
"In Progress": "进行中",
"In progress": "进行中",
"Include time": "包含时间",
"Insert a kanban board on this page": "在此页面插入一个看板",
"Insert an inline base on this page": "在此页面插入一个内联资料库",
"Is": "是",
"Is after": "在此之后",
"Is any of": "是其中之一",
"Is before": "在此之前",
"Is empty": "为空",
"Is none of": "都不是",
"Is not": "不是",
"Is not empty": "不为空",
"Is on or after": "在此日期或之后",
"Is on or before": "在此日期或之前",
"Is within": "在范围内",
"Keep editing": "继续编辑",
"Last edited at": "最后编辑于",
"Last edited by": "最后编辑者",
"Less than": "小于",
"Link copied to clipboard": "链接已复制到剪贴板",
"Local": "本地",
"Long text": "长文本",
"Moved card to {{column}}": "已将卡片移动到 {{column}}",
"Moved column to {{column}}": "已将列移动到 {{column}}",
"Multi-select": "多选",
"New row": "新行",
"Next month": "下个月",
"Next week": "下周",
"Next year": "明年",
"No filters applied": "未应用筛选",
"No match": "无匹配项",
"No options for this property type": "此属性类型没有可用选项",
"No sorts applied": "未应用排序",
"No value": "无值",
"None": "无",
"Not started": "未开始",
"Number": "数字",
"One month ago": "一个月前",
"One month from now": "从现在起一个月后",
"One week ago": "一周前",
"One week from now": "从现在起一周后",
"Open as page": "作为页面打开",
"Options": "选项",
"Past month": "过去一个月",
"Past week": "过去一周",
"Past year": "过去一年",
"Percent": "百分比",
"Period, comma": "句点、逗号",
"Person": "人员",
"Please enter a valid email": "请输入有效的电子邮件地址",
"Progress": "进度",
"Property options": "属性选项",
"Relative": "相对",
"Rename property": "重命名属性",
"Reset": "重置",
"Save for everyone": "保存给所有人",
"Select": "选择",
"Select a property": "选择一个属性",
"Select all loaded rows": "选择所有已加载的行",
"Show all": "显示全部",
"Sort": "排序",
"Sort by": "排序依据",
"Space, comma": "空格、逗号",
"Space, period": "空格、句点",
"This board has no grouping property yet.": "此看板尚无分组属性。",
"This month": "本月",
"This year": "今年",
"Thousands and decimal separators": "千位分隔符和小数分隔符",
"Time format": "时间格式",
"To Do": "待办",
"Tomorrow": "明天",
"True": "真",
"Unsaved changes": "未保存的更改",
"Untitled base": "未命名资料库",
"URL": "网址",
"Value": "值",
"View updated for everyone": "已为所有人更新视图",
"You have unsaved changes. Do you want to discard them?": "您有未保存的更改。要放弃这些更改吗?",
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
"{{count}} selected_other": "已选择 {{count}} 项"
}
+3
View File
@@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
@@ -106,6 +107,8 @@ export default function App() {
element={<Page />}
/>
<Route path={"/base/:pageId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
@@ -0,0 +1,18 @@
import { ThemeIcon } from "@mantine/core";
import { IconFileDescription, IconTable } from "@tabler/icons-react";
type Props = {
icon?: string | null;
isBase?: boolean;
};
export function PageListIcon({ icon, isBase }: Props) {
if (icon) {
return <>{icon}</>;
}
return (
<ThemeIcon variant="transparent" color="gray" size={18}>
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
</ThemeIcon>
);
}
@@ -4,15 +4,15 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
@@ -50,14 +50,10 @@ export default function RecentChanges({ spaceId }: Props) {
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<PageListIcon icon={page.icon} isBase={page.isBase} />
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</Text>
</Group>
</UnstyledButton>
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
IconUser,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { getSpaceUrl } from "@/lib/config.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
const [
createOpened,
{ open: openCreate, close: closeCreate },
] = useDisclosure(false);
if (!user || !workspace) {
return <></>;
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
@@ -115,6 +131,26 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
{personalSpace ? (
<Menu.Item
component={Link}
to={getSpaceUrl(personalSpace.slug)}
leftSection={<IconUser size={16} />}
>
{t("Personal space")}
</Menu.Item>
) : (
hasPersonalSpaces &&
settingEnabled && (
<Menu.Item
onClick={openCreate}
leftSection={<IconUser size={16} />}
>
{t("Create personal space")}
</Menu.Item>
)
)}
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
@@ -160,5 +196,8 @@ export default function TopMenu() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}
@@ -54,14 +54,17 @@ export const CustomAvatar = React.forwardRef<
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const isInitials = !color || color === "initials";
const resolvedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const hue = pickedColor.split(".")[0];
const initialsSource = sanitizeInitialsSource(name ?? "");
const resolvedColor = variant === "filled" ? pickedColor : hue;
const placeholderStyles =
isInitials && variant !== "filled"
? {
placeholder: {
color: `var(--mantine-color-${resolvedColor.split(".")[0]}-9)`,
color: `var(--mantine-color-${hue}-9)`,
},
}
: undefined;
@@ -3,6 +3,7 @@ import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { getPageTitle } from "@/features/page/page.utils";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
@@ -95,7 +96,7 @@ export function PageRow({
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</div>
</div>
+39 -1
View File
@@ -1 +1,39 @@
Files in this directory are subject to the Docmost Enterprise Edition license.
Files in this directory are subject to the Docmost Enterprise Edition license.
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -177,7 +177,7 @@ export default function ChatInput({
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
@@ -264,7 +264,7 @@ export default function ChatInput({
});
useEffect(() => {
if (editor && autofocus) {
if (editor && !editor.isDestroyed && autofocus) {
editor.commands.focus();
}
}, [editor]);
@@ -1,7 +1,14 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
@@ -14,7 +21,7 @@ import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
if (!isEditorReady(editor) || !showAiMenu) return;
const { view } = editor;
const { from, to } = editor.state.selection;
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
if (!isEditorReady(editor) || isLoading) return;
let command: CommandItem | null = item || null;
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
editor
.chain()
.focus()
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
useEffect(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Collapse expanded={isExpanded}>
<Box
px="md"
py="sm"
@@ -0,0 +1,55 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
// the same page don't share UI state.
export const activeViewIdAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const editingCellAtomFamily = atomFamily((_pageId: string) =>
atom<EditingCell>(null),
);
export type FormulaEditorTarget = {
propertyId: string;
rowId: string | null;
} | null;
export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) =>
atom<FormulaEditorTarget>(null),
);
export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) =>
atom<boolean>(false),
);
export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) =>
atom<number>(0),
);
export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
atom<Set<string>>(new Set<string>()),
);
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
export const focusedCellAtomFamily = atomFamily((_pageId: string) =>
atom<FocusedCell>(null),
);
export type PendingTypeInsert = {
rowId: string;
propertyId: string;
char: string;
} | null;
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const formulaRecomputeAtom = atom<Record<string, string[]>>({});
@@ -0,0 +1,20 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import type { RowReferences } from "@/ee/base/types/base.types";
// Per-base normalized store of resolved reference entities, hydrated from each
// rows-page `references`. Keyed by pageId, matching base-atoms.ts.
export const referenceStoreAtomFamily = atomFamily((_pageId: string) =>
atom<RowReferences>({ users: {}, pages: {} }),
);
export function mergeReferences(
prev: RowReferences,
next: RowReferences | undefined,
): RowReferences {
if (!next) return prev;
return {
users: { ...prev.users, ...next.users },
pages: { ...prev.pages, ...next.pages },
};
}
@@ -0,0 +1,21 @@
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/ee/base/types/base.types";
export type ViewDraftKey = {
userId: string;
pageId: string;
viewId: string;
};
export const viewDraftStorageKey = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`;
// atomWithStorage handles JSON serialization and cross-tab sync. The custom
// comparator ensures the same userId/pageId/viewId triple resolves to the
// same atom instance, so Jotai's identity-equality cache hits still work.
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(viewDraftStorageKey(k), null),
(a, b) =>
a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId,
);
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebouncedCallback } from "@mantine/hooks";
import {
usePageQuery,
useUpdateTitlePageMutation,
updatePageData,
} from "@/features/page/queries/page-query";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter";
import classes from "@/ee/base/styles/grid.module.css";
// Editable base name for the inline embed. Follows the TitleEditor convention
// (updatePageData + localEmitter + websocket emit) so the sidebar and other
// clients stay in sync. Standalone pages use the page TitleEditor instead.
export function BaseEmbedTitle({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId });
const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation();
const emit = useQueryEmit();
const [value, setValue] = useState("");
const focusedRef = useRef(false);
// Keep in sync with the persisted title but never clobber active user input.
useEffect(() => {
if (!focusedRef.current) setValue(page?.title ?? "");
}, [page?.title]);
const commit = useCallback(() => {
const trimmed = value.trim();
if (!page || trimmed === (page.title ?? "")) return;
updateTitleAsync({ pageId, title: trimmed }).then((updated) => {
if (updated.title !== trimmed) return;
const event: UpdateEvent = {
operation: "updateOne",
spaceId: updated.spaceId,
entity: ["pages"],
id: updated.id,
payload: {
title: updated.title,
slugId: updated.slugId,
parentPageId: updated.parentPageId,
icon: updated.icon,
},
};
updatePageData(updated);
localEmitter.emit("message", event);
emit(event);
});
}, [value, page, pageId, updateTitleAsync, emit]);
const debouncedCommit = useDebouncedCallback(commit, 500);
// Force-save any pending edit on unmount (e.g. navigating away mid-type).
const commitRef = useRef(commit);
useEffect(() => {
commitRef.current = commit;
}, [commit]);
useEffect(() => () => commitRef.current(), []);
return (
<input
className={classes.embedTitleInput}
value={value}
placeholder={t("Untitled base")}
aria-label={t("Base name")}
onChange={(e) => {
setValue(e.currentTarget.value);
debouncedCommit();
}}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
commit();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
if (e.key === "Escape") {
setValue(page?.title ?? "");
e.currentTarget.blur();
}
}}
/>
);
}
@@ -0,0 +1,92 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/ee/base/styles/grid.module.css";
import classes from "@/ee/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const DEFAULT_COLUMN_COUNT = 6;
const DEFAULT_ROW_COUNT = 10;
// Deterministic widths prevent flicker between renders.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
type BaseTableSkeletonProps = {
// Match the eventual content shape to avoid a jarring size jump on swap.
rows?: number;
columns?: number;
};
export function BaseTableSkeleton({
rows = DEFAULT_ROW_COUNT,
columns = DEFAULT_COLUMN_COUNT,
}: BaseTableSkeletonProps = {}) {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div className={classes.root}>
<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: columns }).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 % HEADER_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: rows }).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: columns }).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,70 @@
import { GridContainer } from "@/ee/base/components/grid/grid-container";
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/ee/base/types/base.types";
type BaseTableProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
isFiltered: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode;
};
export function BaseTable({
base,
rows: _rows,
table,
pageId,
embedded,
isFiltered,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
onCellUpdate,
onAddRow,
onColumnReorder,
onResizeEnd,
onRowReorder,
scrollportRef,
aboveBand,
}: BaseTableProps) {
return (
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={onCellUpdate}
onAddRow={onAddRow}
pageId={pageId}
isFiltered={isFiltered}
onColumnReorder={onColumnReorder}
onResizeEnd={onResizeEnd}
onRowReorder={onRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
scrollElement={embedded ? window : scrollportRef.current}
aboveBand={aboveBand ?? null}
/>
);
}
@@ -0,0 +1,295 @@
import { useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
IconArrowsDiagonal,
IconLayoutColumns,
IconAdjustments,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { getApiErrorMessage } from "@/lib/api-error";
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker";
import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css";
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
type BaseToolbarProps = {
base: IBase;
activeView: IBaseView | undefined;
views: IBaseView[];
table?: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
canAddView?: boolean;
onPersistViewConfig: () => void;
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
onExpand?: () => void;
getViewShareUrl?: (viewId: string) => string | null;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
canAddView,
onPersistViewConfig,
onDraftSortsChange,
onDraftFiltersChange,
onExpand,
getViewShareUrl,
}: BaseToolbarProps) {
const { t } = useTranslation();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false);
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const isKanban = activeView?.type === "kanban";
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: getApiErrorMessage(err, t("Failed to export CSV")),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setPropertiesOpened(panel === "properties" ? (v) => !v : false);
}, []);
const sorts = activeView?.config?.sorts ?? [];
const conditions = useMemo<FilterCondition[]>(() => {
const filter = activeView?.config?.filter;
if (!filter || filter.op !== "and") return [];
return filter.children.filter(
(c): c is FilterCondition => !("children" in c),
);
}, [activeView?.config?.filter]);
const hiddenPropertyCount = useMemo(() => {
if (!table) return 0;
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table?.getState().columnVisibility]);
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
},
[onDraftSortsChange],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
onDraftFiltersChange(filter);
},
[onDraftFiltersChange],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
pageId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
base={base}
canAddView={canAddView}
getViewShareUrl={getViewShareUrl}
/>
<div className={classes.toolbarRight}>
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
{isKanban && activeView && (
<>
<KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
<Tooltip label={t("Group by")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
>
<IconLayoutColumns size={16} />
</ActionIcon>
</Tooltip>
</KanbanGroupByPicker>
<KanbanCardProperties
opened={cardPropertiesOpened}
onClose={() => setCardPropertiesOpened(false)}
base={base}
view={activeView}
pageId={base.id}
>
<Tooltip label={t("Card properties")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() => setCardPropertiesOpened((v) => !v)}
>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
</KanbanCardProperties>
</>
)}
{!isKanban && (
<>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
{table && (
<ViewPropertyVisibility
opened={propertiesOpened}
onClose={() => setPropertiesOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
>
<IconEye size={16} />
{hiddenPropertyCount > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{hiddenPropertyCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewPropertyVisibility>
)}
</>
)}
{onExpand && (
<Tooltip label={t("Open as page")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onExpand}
>
<IconArrowsDiagonal size={16} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { Group, Button, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({
isDirty,
canSave,
onReset,
onSave,
saving,
}: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Group justify="flex-end" gap="xs" px="md" py={6} wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>
{t("Reset")}
</Button>
{canSave && (
<Tooltip
label={t("Filter and sort changes are visible only to you")}
position="top"
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={onSave}
loading={saving}
>
{t("Save for everyone")}
</Button>
</Tooltip>
)}
</Group>
);
}
@@ -0,0 +1,541 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconTable } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
EditingCell,
FocusedCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
useBaseRowsQuery,
flattenRows,
useCreateRowMutation,
useUpdateRowMutation,
useReorderRowMutation,
} from "@/ee/base/queries/base-row-query";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
focusedCellAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store";
import { useViewDraft } from "@/ee/base/hooks/use-view-draft";
import { BaseToolbar } from "@/ee/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner";
import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { ViewRenderer } from "@/ee/base/components/views/view-renderer";
import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { BaseEditableProvider } from "@/ee/base/context/base-editable";
import { RowExpandProvider } from "@/ee/base/context/row-expand";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { getAppUrl } from "@/lib/config.ts";
import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css";
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
type BaseViewProps = {
pageId: string;
embedded?: boolean;
/** False makes the view read-only. Standalone passes page.permissions.canEdit;
* embedded ANDs that with the host editor's editability. */
editable?: boolean;
titleSlot?: React.ReactNode;
};
export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) {
const { t } = useTranslation();
// Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } =
useBaseQuery(pageId);
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId });
const handleExpand = useCallback(() => {
if (!page) return;
navigate(buildPageUrl(page.space?.slug, page.slugId, page.title));
}, [navigate, page]);
// Share URL for a specific view; always points at the standalone page where ?view= is honored.
const getViewShareUrl = useCallback(
(viewId: string) =>
page
? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}`
: null,
[page],
);
const [activeViewId, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const [, setEditingCell] = useAtom(
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setFocusedCell] = useAtom(
focusedCellAtomFamily(pageId),
) as unknown as [FocusedCell, (val: FocusedCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[base?.views],
);
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const { data: currentUser } = useCurrentUser();
useHydrateCurrentUser(pageId);
const {
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
pageId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Baseline merged with local draft. Used for table state and toolbar badge counts.
// The real activeView remains the auto-persist baseline so drafts can't leak into layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
const canSave = editable;
// Gate on base to avoid a "bland" list request before the active view's
// config resolves, which would double network traffic for sorted/filtered views.
const isKanban = activeView?.type === "kanban";
const {
data: rowsData,
isLoading: rowsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
// Deep link: apply ?view=<id> once after views load; skip if the id is
// unrecognised so we fall back to the default without fighting a later tab switch.
const appliedViewParamRef = useRef(false);
useEffect(() => {
if (appliedViewParamRef.current || views.length === 0) return;
const viewParam = new URLSearchParams(window.location.search).get("view");
if (viewParam && views.some((v) => v.id === viewParam)) {
setActiveViewId(viewParam);
}
appliedViewParamRef.current = true;
}, [views, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// With an active sort the server returns rows in sort order via keyset
// pagination; re-sorting by position on the client would break it as more
// pages load. Position sort only applies when no view sort is active.
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const rowsRef = useRef(rows);
rowsRef.current = rows;
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView);
const guardedPersistViewConfig = useCallback(() => {
if (!editable) return;
persistViewConfig();
}, [editable, persistViewConfig]);
// Mutation result objects change identity every render; only .mutate is
// stable. Rows are memoized on these callbacks' identities, so they must
// not churn with unrelated re-renders.
const updateRow = updateRowMutation.mutate;
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
if (!editable) return;
updateRow({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[editable, pageId, updateRow],
);
const handleAddRow = useCallback(
(afterRowId?: string, focusPropertyId?: string) => {
if (!editable) return;
createRowMutation.mutate(
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
{
onSuccess: (newRow) => {
let propertyId = focusPropertyId;
if (!propertyId) {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
propertyId = (
firstEditable?.columnDef.meta?.property as
| IBaseProperty
| undefined
)?.id;
}
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
setFocusedCell({ rowId: newRow.id, propertyId });
}
},
},
);
},
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder;
const startIndex = order.indexOf(columnId);
if (startIndex === -1 || startIndex === finishIndex) return;
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
guardedPersistViewConfig();
},
[table, guardedPersistViewConfig],
);
const handleResizeEnd = useCallback(() => {
guardedPersistViewConfig();
}, [guardedPersistViewConfig]);
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[] | undefined) => {
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
},
[setDraftSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => {
setDraftFilter(filter);
},
[setDraftFilter],
);
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
// Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
pageId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const { openRowId, openRow, closeRow } = useRowDetailModal(pageId);
// openRow's identity tracks searchParams; rows subscribe to the expand
// context, so hand them a stable wrapper instead.
const openRowRef = useRef(openRow);
openRowRef.current = openRow;
const handleExpandRow = useCallback((rowId: string) => {
openRowRef.current(rowId);
}, []);
const handleRowNavigate = useCallback((rowId: string) => {
openRowRef.current(rowId, { replace: true });
}, []);
const reorderRow = reorderRowMutation.mutate;
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
if (!editable) return;
const remainingRows = rowsRef.current.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos =
targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos =
targetIndex < remainingRows.length - 1
? remainingRows[targetIndex + 1]?.position
: null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRow({ rowId, pageId, position: newPosition });
} catch {
// Position computation failed; skip silently.
}
},
[editable, pageId, reorderRow],
);
if (baseLoading || (!isKanban && rowsLoading)) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconTable size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
canAddView={editable}
onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
onExpand={embedded ? handleExpand : undefined}
getViewShareUrl={getViewShareUrl}
/>
);
const kanbanBand = (
<div className={kanbanClasses.bandWrap}>
{embedded ? null : titleSlot}
{banner}
{toolbar}
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
</div>
);
const viewRenderer = (folded: React.ReactNode) => (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
editable={editable}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
kanbanFilter={activeFilter}
aboveBand={folded}
/>
);
if (embedded) {
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Banner and toolbar go into aboveBand so they scroll with the host document;
// only the column-header row stays pinned (via --sticky-band-top).
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>,
)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<div className={kanbanClasses.standalone}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned.
return (
<BaseEditableProvider editable={editable}>
<div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{titleSlot}
{banner}
{toolbar}
</>,
)}
</RowExpandProvider>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
@@ -0,0 +1,100 @@
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function computeVisibleBadgeCount(
itemWidths: number[],
gap: number,
available: number,
badgeWidth: number,
): number {
const count = itemWidths.length;
if (count === 0) return 0;
if (available <= 0) return count;
let lineWidth = 0;
for (let i = 0; i < count; i++) {
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
}
if (lineWidth <= available) return count;
let used = 0;
let fit = 0;
for (let i = 0; i < count; i++) {
const advance = itemWidths[i] + (i > 0 ? gap : 0);
if (used + advance + gap + badgeWidth <= available) {
used += advance;
fit = i + 1;
} else {
break;
}
}
return Math.max(fit, 1);
}
const BADGE_GAP = 4;
type BadgeOverflowListProps = {
chips: ReactElement[];
measureKey: string;
tooltipLabel?: string;
};
export function BadgeOverflowList({
chips,
measureKey,
tooltipLabel,
}: BadgeOverflowListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(chips.length);
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const recompute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
setVisibleCount(
computeVisibleBadgeCount(
chipWidths,
BADGE_GAP,
container.clientWidth,
badgeWidth,
),
);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(container);
return () => observer.disconnect();
}, [measureKey]);
const visible = chips.slice(0, visibleCount);
const overflow = chips.length - visibleCount;
return (
<Tooltip
label={tooltipLabel}
multiline
withinPortal
openDelay={400}
disabled={!tooltipLabel || overflow <= 0}
>
<div className={cellClasses.badgeGroup} ref={containerRef}>
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
{chips}
<span className={cellClasses.overflowCount}>+{chips.length}</span>
</div>
{visible}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
</Tooltip>
);
}
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
if (readOnly) return;
onCommit(!checked);
}, [readOnly, checked, onCommit]);
return (
<div
className={cellClasses.checkboxCell}
onClick={handleChange}
style={readOnly ? { cursor: "default" } : undefined}
>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{
input: {
cursor: readOnly ? "default" : "pointer",
pointerEvents: "none",
},
}}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,146 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<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,61 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,236 @@
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { uploadFile } from "@/features/page/services/page-service";
import { getFileUrl } from "@/lib/config";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
url?: string;
};
function buildFileUrl(file: Pick<FileValue, "id" | "fileName" | "url">): string {
return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`;
}
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
readOnly,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
if (readOnly) return;
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[readOnly, files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
// Reuse the page-attachment upload pipeline: the base's pageId is passed
// to the standard /files/upload endpoint, which enforces the same edit
// access check as any other page attachment.
for (const file of Array.from(fileList)) {
try {
const attachment = await uploadFile(file, property.pageId);
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.pageId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{!readOnly && files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div key={file.id} className={cellClasses.fileItemRow}>
<IconFile size={14} className={cellClasses.fileItemIcon} />
<a
href={getFileUrl(buildFileUrl(file))}
target="_blank"
rel="noreferrer"
className={cellClasses.fileItemLink}
>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</a>
{!readOnly && (
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
)}
</div>
))}
{!readOnly && (
<>
<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}
className={cellClasses.fileUploadBtn}
style={{
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,38 @@
import { Badge, Tooltip } from "@mantine/core";
import {
IBaseProperty,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
import { CellNumber } from "./cell-number";
import { CellCheckbox } from "./cell-checkbox";
import { CellDate } from "./cell-date";
type Props = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellFormula(props: Props) {
const { value, property } = props;
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`}>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
const readOnlyProps = { ...props, isEditing: false };
if (resultType === "number") return <CellNumber {...readOnlyProps} />;
if (resultType === "boolean") return <CellCheckbox {...readOnlyProps} />;
if (resultType === "date") return <CellDate {...readOnlyProps} />;
return <CellText {...readOnlyProps} />;
}
@@ -0,0 +1,22 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,41 @@
import { Group, Tooltip } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useReferenceStore } from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedByProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedBy({ value, property }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(property.pageId);
const user = userId ? store.users[userId] ?? null : null;
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<span className={cellClasses.lastEditedByName}>{name}</span>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,151 @@
import { useEffect, useRef, useState } from "react";
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLongTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function CellLongText({
value,
isEditing,
onCommit,
onValueChange,
onCancel,
onTabNavigate,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Seed draft and focus on the false->true editing transition only; ignore
// value changes mid-edit so the user's typing is not clobbered.
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
cancelledRef.current = false;
committedRef.current = false;
setDraft(toText(value));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
// Autosave after a typing pause; commit/cancel clear the pending fire so
// a closed editor can never write a stale or discarded draft.
const debouncedAutosave = useDebouncedCallback(() => {
onValueChange(normalize(draft));
}, 10_000);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
debouncedAutosave.cancel();
onCommit(normalize(draft));
};
const cancel = () => {
cancelledRef.current = true;
debouncedAutosave.cancel();
onCancel();
};
const preview = formatLongTextPreview(toText(value));
return (
<Popover
opened={isEditing}
onChange={(opened) => {
if (opened) return;
// Programmatic close after cancel must not re-commit.
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
commit();
}}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={cellClasses.popoverTargetFlex}>
{preview ? (
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
<span className={cellClasses.longTextPreview}>{preview}</span>
</Tooltip>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown
p={4}
onClick={(e) => e.stopPropagation()}
className={cellClasses.longTextDropdown}
>
{isEditing && (
<>
<Group justify="flex-end" mb={2}>
<CloseButton size="sm" onClick={commit} aria-label="Close" />
</Group>
<Textarea
ref={textareaRef}
data-autofocus
autosize
minRows={3}
maxRows={12}
maxLength={25000}
variant="unstyled"
value={draft}
onChange={(e) => {
setDraft(e.currentTarget.value);
debouncedAutosave();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
commit();
onTabNavigate?.(e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();
}
}}
styles={{ input: { padding: 4 } }}
/>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,289 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import { Popover, TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/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;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onValueChange,
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
).filter((c) => !selectedSet.has(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onValueChange(newIds);
},
[selectedIds, selectedSet, onValueChange],
);
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: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onValueChange([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onValueChange]);
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],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<BadgeList choices={selectedChoices} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
{selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
handleToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
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)}
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}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function BadgeList({
choices,
tooltipLabel,
}: {
choices: Choice[];
tooltipLabel?: string;
}) {
const chips = choices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={choices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={tooltipLabel}
/>
);
}
@@ -0,0 +1,158 @@
import {
IBaseProperty,
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { snapNumber } from "@docmost/base-formula/client";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
comma_period: { group: ",", decimal: "." },
period_comma: { group: ".", decimal: "," },
space_comma: { group: " ", decimal: "," },
space_period: { group: " ", decimal: "." },
};
function separatorChars(style: string): { group: string; decimal: string } {
if (style === "local") {
const parts = new Intl.NumberFormat().formatToParts(11111.1);
return {
group: parts.find((p) => p.type === "group")?.value ?? ",",
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
};
}
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
}
function formatPlain(
value: number,
precision: number | undefined,
style: string,
): string {
const fixed = precision == null ? String(value) : value.toFixed(precision);
if (style === "none") return fixed;
const { group, decimal } = separatorChars(style);
const neg = fixed[0] === "-";
const abs = neg ? fixed.slice(1) : fixed;
const dot = abs.indexOf(".");
const intPart = dot === -1 ? abs : abs.slice(0, dot);
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
return neg ? `-${out}` : out;
}
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "currency":
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function sanitizeNumberInput(text: string): string {
return text.replace(/[^0-9.-]/g, "");
}
export function parseNumberDraft(draft: string): number | null {
const cleaned = sanitizeNumberInput(draft);
if (cleaned === "" || cleaned === "-") return null;
const parsed = Number(cleaned);
return isNaN(parsed) ? null : parsed;
}
export function CellNumber({
value,
property,
rowId,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const numValue = typeof value === "number" ? value : null;
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={cellClasses.numberValue}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{formatNumber(numValue, typeOptions)}
</AutoTooltipText>
);
}
@@ -0,0 +1,339 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { IconX, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useResolvePage } from "@/ee/base/reference/reference-store";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { searchSuggestions } from "@/features/search/services/search-service";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type PageSuggestion = {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space?: { id: string; slug: string; name: string } | null;
};
function parsePageId(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
return null;
}
function parsePastedPageSlugId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
let path = trimmed;
if (/^https?:\/\//i.test(trimmed)) {
try {
path = new URL(trimmed).pathname;
} catch {
return null;
}
}
const match = path.match(/\/p\/([^/?#]+)/);
if (!match) return null;
return extractPageSlugId(match[1]) ?? null;
}
export function CellPage({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPageProps) {
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.pageId);
const resolvedPage = useResolvePage(property.pageId, pageId);
if (isEditing) {
return (
<PagePicker
pageId={pageId}
resolvedPage={resolvedPage ?? null}
spaceId={base?.spaceId}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
if (!pageId) {
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === undefined) {
// placeholder to avoid "Page not found" flicker on initial load
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
return <PagePill page={resolvedPage} />;
}
type PillPage = {
slugId: string;
title: string | null;
icon: string | null;
space: { slug: string } | null;
};
function PagePill({ page }: { page: PillPage }) {
const { t } = useTranslation();
const title = getPageTitle(page.title, undefined, t);
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{page.icon ? (
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
type PagePickerProps = {
pageId: string | null;
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
spaceId?: string;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function PagePicker({
pageId,
resolvedPage,
spaceId,
onCommit,
onCancel,
}: PagePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const trimmed = debouncedSearch.trim();
const pastedSlugId = useMemo(
() => parsePastedPageSlugId(debouncedSearch),
[debouncedSearch],
);
const { data: suggestions = [] } = useQuery({
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
queryFn: async () => {
const res = await searchSuggestions({
query: trimmed,
includePages: true,
spaceId,
limit: trimmed ? 25 : 5,
});
return (res.pages ?? []) as PageSuggestion[];
},
enabled: !pastedSlugId,
staleTime: 15_000,
});
// Once the pasted link resolves via slugId lookup, commit and close.
const { data: linkedPage, isFetching: resolvingLink } = usePageQuery(
pastedSlugId ? { pageId: pastedSlugId } : {},
);
const linkedRef = useRef(false);
useEffect(() => {
if (!pastedSlugId) {
linkedRef.current = false;
return;
}
if (linkedPage && !linkedRef.current) {
linkedRef.current = true;
onCommit(linkedPage.id);
}
}, [pastedSlugId, linkedPage, onCommit]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(suggestions.length, [debouncedSearch]);
const handleSelect = useCallback(
(id: string) => {
onCommit(id === pageId ? null : id);
},
[pageId, onCommit],
);
const handleRemove = useCallback(() => {
onCommit(null);
}, [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 >= suggestions.length) return;
e.preventDefault();
handleSelect(suggestions[activeIndex].id);
}
},
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
);
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={320}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{pageId && resolvedPage && (
<span className={cellClasses.personTag}>
{resolvedPage.icon ? (
<span>{resolvedPage.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<span className={cellClasses.personTagName}>
{getPageTitle(resolvedPage.title, undefined, t)}
</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
>
<IconX size={10} />
</button>
</span>
)}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={pageId ? "" : "Search for a page..."}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{pastedSlugId ? (
<div className={cellClasses.personDropdownHint}>
{resolvingLink || linkedPage ? "Linking page…" : "Page not found"}
</div>
) : (
<>
{suggestions.length === 0 && (
<div className={cellClasses.personDropdownHint}>
{trimmed ? "No pages found" : "No pages yet"}
</div>
)}
{suggestions.map((page, idx) => {
const isSelected = page.id === pageId;
return (
<div
key={page.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<div className={cellClasses.pageOptionText}>
<span className={cellClasses.personOptionName}>
{getPageTitle(page.title, undefined, t)}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" truncate>
{page.space.name}
</Text>
)}
</div>
</div>
);
})}
</>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,247 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
} from "@/ee/base/types/base.types";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple === true;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const store = useReferenceStore(property.pageId);
const hydrateUsers = useHydrateUsers(property.pageId);
const suggestions = usePersonSearch(search, isEditing);
// In multi mode omit already-selected from the list (they appear as tags above).
// Single mode keeps the selected row visible so it can be deselected.
const filteredMembers = allowMultiple
? suggestions.filter((m) => !selectedSet.has(m.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
const picked = suggestions.find((s) => s.id === memberId);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (allowMultiple) {
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onValueChange([...personIds, memberId]);
}
} else {
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[suggestions, hydrateUsers, allowMultiple, personIds, onCommit, onValueChange],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit, onValueChange],
);
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],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<PersonReadList personIds={personIds} users={store.users} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = store.users[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}
data-autofocus
/>
</div>
<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)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
);
})}
{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} users={store.users} />;
}
@@ -0,0 +1,239 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/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: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
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
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{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)}
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)}
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 (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import cellClasses from "@/ee/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/ee/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
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{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)}
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 (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,60 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
import gridClasses from "@/ee/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
maxLength={1000}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={gridClasses.cellContent}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{displayValue}
</AutoTooltipText>
);
}
@@ -0,0 +1,70 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { sanitizeUrl } from "@docmost/editor-ext";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return <span>{displayValue}</span>;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,30 @@
function isEmpty(v: unknown): boolean {
return v === null || v === undefined || v === "";
}
/**
* Deep equality for cell values. Treats null/undefined/"" as equivalent
* "empty", compares arrays by ordered content, objects by serialized form.
* Note: an empty array [] is a real value, distinct from "empty".
*/
export function cellValuesEqual(a: unknown, b: unknown): boolean {
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (!aArr && !bArr) {
if (isEmpty(a) && isEmpty(b)) return true;
if (isEmpty(a) !== isEmpty(b)) return false;
}
if (aArr || bArr) {
if (!aArr || !bArr) return false;
if (a.length !== b.length) return false;
return a.every((x, i) => cellValuesEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object" && a && b) {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
@@ -0,0 +1,29 @@
import { CSSProperties, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
type ChoiceBadgeProps = {
name: string;
style: CSSProperties;
};
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
return (
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
<span
ref={ref}
className={cellClasses.badge}
style={style}
onMouseEnter={() => {
const el = ref.current;
if (el) setTruncated(el.scrollWidth > el.clientWidth);
}}
>
{name}
</span>
</Tooltip>
);
}
@@ -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: "#bf2020", 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: "#1864ab", textDark: "#74c0fc" },
cyan: { bg: "#c5f6fa", bgDark: "#1a3a3a", text: "#0b7285", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#066649", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#1b5e20", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#44700a", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#8a5200", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#a63508", 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,258 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORY_LABELS: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type ChoiceGroup = { label: string | null; choices: Choice[] };
type ChoicePickerProps = {
property: IBaseProperty;
selectedIds: string[];
/** Multi keeps the picker open, hides picked options from the list and
* shows them as removable tags instead. */
multiple?: boolean;
/** Group options under status category headings. */
grouped?: boolean;
/** Offer "Add option: <search>" when the search has no exact match. */
allowCreate?: boolean;
onToggle: (choice: Choice) => void;
onEscape: () => void;
};
/** Searchable choice list shared by select-like editors (modal fields; the
* grid cells render the same UI and can migrate here). */
export function ChoicePicker({
property,
selectedIds,
multiple = false,
grouped = false,
allowCreate = false,
onToggle,
onEscape,
}: ChoicePickerProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const groups = useMemo<ChoiceGroup[]>(() => {
const filtered = (
search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices
).filter((c) => !multiple || !selectedSet.has(c.id));
if (!grouped) return [{ label: null, choices: filtered }];
const byCategory: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
(byCategory[cat] ??= []).push(choice);
}
return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map(
(key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }),
);
}, [choices, search, grouped, multiple, selectedSet]);
const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[flatChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onToggle(newChoice);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") onToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption],
);
const addOptionIdx = flatChoices.length;
return (
<>
{multiple && selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
onToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label ?? "all"}>
{group.label && (
<div className={cellClasses.selectCategoryLabel}>{group.label}</div>
)}
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = !multiple && selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => onToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span className={cellClasses.badge} style={choiceColor(addOptionColor)}>
{trimmedSearch}
</span>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { UserRef } from "@/ee/base/types/base.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
type PersonReadListProps = {
personIds: string[];
users: Record<string, UserRef>;
};
export function PersonReadList({ personIds, users }: PersonReadListProps) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Divider,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconMathFunction,
IconPointFilled,
} from "@tabler/icons-react";
import { registry } from "@docmost/base-formula/client";
import { FormulaInput } from "./formula-input";
import { PropertyChipRow } from "./property-chip-row";
import { FunctionPalette } from "./function-palette";
import { useFormulaParser } from "@/ee/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
type Props = {
properties: IBaseProperty[];
editingPropertyId: string | null;
initialSource?: string;
name?: string;
disabled?: boolean;
onSave: (
source: string,
ast: unknown,
resultType: string,
dependencies: string[],
) => void;
onCancel: () => void;
};
export function FormulaEditor({
properties,
editingPropertyId,
initialSource = "",
name,
disabled = false,
onSave,
onCancel,
}: Props) {
const [source, setSource] = useState(initialSource);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingCursorRef = useRef<number | null>(null);
const parseState = useFormulaParser(
source,
properties,
editingPropertyId,
registry,
);
const canSave = parseState.state === "ok" && !disabled;
// useEffect (not RAF) ensures the DOM update ran before restoring cursor.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(pos, pos);
}, [source]);
const insertAtCursor = (snippet: string, cursorOffsetFromEnd = 0) => {
const ta = textareaRef.current;
const start = ta?.selectionStart ?? source.length;
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
const next = before + prefix + snippet + after;
pendingCursorRef.current =
before.length + prefix.length + snippet.length - cursorOffsetFromEnd;
setSource(next);
};
return (
<Paper
withBorder
radius="md"
shadow="sm"
p={0}
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={12}
className={classes.formulaHeaderRow}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<div className={classes.formulaIconBadge}>
<IconMathFunction size={14} />
</div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed" truncate>
· {name}
</Text>
)}
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Button variant="subtle" size="xs" onClick={onCancel}>
Cancel
</Button>
<Button
size="xs"
disabled={!canSave}
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}}
>
Save
</Button>
</Group>
</Group>
<Stack gap={6} px={14} pt={10} pb={8}>
<FormulaInput
ref={textareaRef}
value={source}
onChange={setSource}
hasError={parseState.state === "error"}
/>
<Group justify="space-between" gap={8} mih={16}>
{parseState.state === "error" ? (
<Group gap={6} c="red.7">
<IconAlertTriangle size={12} />
<Text size="xs">{parseState.message}</Text>
</Group>
) : parseState.state === "ok" ? (
<Group gap={6} c="dimmed">
<IconPointFilled size={10} color="var(--mantine-color-teal-6)" />
<Text size="xs">
Returns{" "}
<Text span fw={600} c="gray.8">
{parseState.resultType}
</Text>
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Click a property or function below to insert.
</Text>
)}
</Group>
</Stack>
<Divider />
<Stack gap={8} px={14} pt={10} pb={10}>
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtCursor(`prop("${name}")`)}
/>
</Stack>
<Divider />
<Stack gap={6} px={14} pt={10} pb={10}>
<Text size="xs" fw={600} c="gray.7">
Functions
</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,40 @@
import { forwardRef } from "react";
import { Textarea } from "@mantine/core";
type Props = {
value: string;
onChange: (v: string) => void;
hasError?: boolean;
};
export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
function FormulaInput({ value, onChange, hasError }, ref) {
return (
<Textarea
ref={ref}
autosize
minRows={3}
maxRows={8}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder='prop("Price") * prop("Qty")'
styles={{
input: {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 13,
lineHeight: 1.65,
backgroundColor: "var(--mantine-color-gray-0)",
borderColor: hasError
? "var(--mantine-color-red-6)"
: "var(--mantine-color-blue-6)",
borderWidth: 1.5,
boxShadow: hasError
? "0 0 0 3px var(--mantine-color-red-1)"
: "0 0 0 3px var(--mantine-color-blue-1)",
},
}}
/>
);
},
);
@@ -0,0 +1,48 @@
import { FormulaEditor } from "./formula-editor";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import {
IBaseProperty,
FormulaTypeOptions,
TypeOptions,
} from "@/ee/base/types/base.types";
type Props = {
property: IBaseProperty;
pageId: string;
onClose: () => void;
};
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
const { data: base } = useBaseQuery(pageId);
const updatePropertyMutation = useUpdatePropertyMutation();
const opts = property.typeOptions as FormulaTypeOptions | undefined;
return (
<FormulaEditor
properties={base?.properties ?? []}
editingPropertyId={property.id}
initialSource={opts?.source ?? ""}
name={property.name}
onCancel={onClose}
onSave={(source, ast, resultType, dependencies) => {
if (source === (opts?.source ?? "")) {
onClose();
return;
}
updatePropertyMutation.mutate({
propertyId: property.id,
pageId,
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
});
onClose();
}}
/>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import {
Accordion,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client";
import classes from "@/ee/base/styles/formula.module.css";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
export function FunctionPalette({
registry,
onInsert,
}: {
registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState<string | null>("logic");
const byCat = new Map<string, FormulaFn[]>();
for (const fn of registry.values()) {
if (!byCat.has(fn.category)) byCat.set(fn.category, []);
byCat.get(fn.category)!.push(fn);
}
return (
<Accordion
value={open}
onChange={setOpen}
variant="contained"
radius="md"
chevronSize={14}
styles={{
item: { borderColor: "var(--mantine-color-gray-2)" },
control: { padding: "7px 12px", minHeight: 0 },
label: {
padding: 0,
fontSize: 13,
fontWeight: 600,
textTransform: "capitalize",
},
content: { padding: "6px 10px 10px" },
panel: { background: "var(--mantine-color-gray-0)" },
}}
>
{CATEGORIES.map((cat) => {
const fns = byCat.get(cat) ?? [];
return (
<Accordion.Item key={cat} value={cat}>
<Accordion.Control>
<Group gap={8}>
<span>{cat}</span>
<Text size="xs" c="dimmed" ff="monospace">
{fns.length}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Group gap={6}>
{fns.map((fn) => (
<Tooltip key={fn.name} label={fn.doc} withArrow>
<UnstyledButton
onClick={() => onInsert(fn.name)}
className={classes.fnChip}
>
{fn.name}
<span className={classes.fnChipParens}>
()
</span>
</UnstyledButton>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
@@ -0,0 +1,56 @@
import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
export function PropertyChipRow({
properties,
onInsert,
}: {
properties: IBaseProperty[];
onInsert: (name: string) => void;
}) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return properties;
return properties.filter((p) => p.name.toLowerCase().includes(q));
}, [properties, query]);
return (
<div>
<Group justify="space-between" mb={8}>
<Text size="xs" fw={600} c="gray.7">
Properties
</Text>
<TextInput
size="xs"
placeholder="Search"
leftSection={<IconSearch size={12} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
w={140}
/>
</Group>
{visible.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
No matches.
</Text>
) : (
<Group gap={6}>
{visible.map((p) => (
<UnstyledButton
key={p.id}
onClick={() => onInsert(p.name)}
className={classes.propChip}
>
{p.name}
</UnstyledButton>
))}
</Group>
)}
</div>
);
}
@@ -0,0 +1,32 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/ee/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}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role="button"
tabIndex={0}
>
<IconPlus size={14} />
<span>{t("New row")}</span>
</div>
);
});
@@ -0,0 +1,10 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import classes from "@/ee/base/styles/grid.module.css";
type Props = {
edge: Edge;
};
export function BaseDropEdgeIndicator({ edge }: Props) {
return <div className={classes.dropEdgeLine} data-edge={edge} aria-hidden />;
}
@@ -0,0 +1,301 @@
import { memo, useCallback, useMemo } from "react";
import { flushSync } from "react-dom";
import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom, useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { IBaseRow, EditingCell, FocusedCell } from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import {
isSystemPropertyType,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/ee/base/styles/grid.module.css";
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
colIndex?: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
pageId: string;
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
colIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
const property = cell.column.columnDef.meta?.property;
const isRowNumber = cell.column.id === "__row_number";
const isPinned = cell.column.getIsPinned();
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setFocusedCell = useSetAtom(focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === cell.row.id && fc?.propertyId === property?.id,
),
[pageId, cell.row.id, property?.id],
),
);
const { t } = useTranslation();
const editable = useBaseEditable();
const readOnly = !editable;
const onExpandRow = useRowExpand();
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleEdit = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable.
if (property.type === "file") {
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}
return;
}
if (property.type === "formula") {
setActiveFormulaEditor({ propertyId: property.id, rowId });
return;
}
if (isSystemPropertyType(property.type)) return;
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || e.button !== 0 || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
},
[property, rowId, setFocusedCell, isEditing],
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[property, rowId, setFocusedCell, isEditing],
);
const cellReadOnly = property
? readOnly || isSystemPropertyType(property.type)
: false;
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const handleValueChange = useCallback(
(value: unknown) => {
if (!property) return;
if (!cellValuesEqual(value, cell.getValue())) {
onCellUpdate(rowId, property.id, value);
}
},
[property, rowId, cell, onCellUpdate],
);
const handleCommit = useCallback(
(value: unknown) => {
handleValueChange(value);
setEditingCell(null);
},
[handleValueChange, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
const handleTabNavigate = useCallback(
(shiftKey: boolean) => {
if (!property) return;
const tableInstance = cell.getContext().table;
const colIds = tableInstance
.getVisibleLeafColumns()
.filter((c) => c.id !== "__row_number")
.map((c) => c.id);
const rowIds = tableInstance.getRowModel().rows.map((r) => r.id);
const next = computeNextCell(
rowIds,
colIds,
{ rowId, propertyId: property.id },
0,
shiftKey ? -1 : 1,
true,
);
if (next) {
setEditingCell(next);
setFocusedCell(next);
}
},
[cell, rowId, property, setEditingCell, setFocusedCell],
);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
pageId={pageId}
/>
);
}
if (!property) return null;
const CellComponent = getDescriptor(property.type)?.cellComponent;
if (!CellComponent) return null;
const value = cell.getValue();
const cellInner = (
<div
id={`base-cell-${rowId}-${property.id}`}
role="gridcell"
aria-colindex={colIndex != null ? colIndex + 1 : undefined}
aria-readonly={cellReadOnly || undefined}
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${isFocused && !isEditing ? classes.cellFocused : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onClick={handleClick}
onMouseDown={handleMouseDown}
onDoubleClick={handleEdit}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
onTabNavigate={handleTabNavigate}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
tabIndex={-1}
data-base-row-expand=""
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
>
<IconArrowsDiagonal size={13} />
</button>
</Tooltip>
</span>
)}
</div>
);
if (property.type !== "formula") return cellInner;
const formulaEditorOpen =
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === rowId;
return (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>{cellInner}</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
);
},
gridCellPropsEqual);
// Cell instances are re-created whenever the table data identity changes;
// compare by coordinates + value so unchanged cells skip re-rendering.
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.colIndex !== next.colIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
return false;
}
if (prev.cell === next.cell) return true;
return (
prev.cell.row.id === next.cell.row.id &&
prev.cell.column.id === next.cell.column.id &&
prev.cell.column.columnDef.meta?.property ===
next.cell.column.columnDef.meta?.property &&
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
);
}
@@ -0,0 +1,565 @@
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
observeWindowOffset,
observeWindowRect,
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom, useSetAtom, type PrimitiveAtom } from "jotai";
import {
IBaseRow,
IBaseProperty,
EditingCell,
FocusedCell,
CellCoord,
} from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
focusedCellAtomFamily,
activeFormulaEditorAtomFamily,
pendingTypeInsertAtom,
type FormulaEditorTarget,
type PendingTypeInsert,
} from "@/ee/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { GridGhostRows } from "./grid-ghost-rows";
import { SelectionActionBar } from "./selection-action-bar";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
// var(--base-row-height) from this constant so the rendered height can
// never drift from the virtualizer estimate.
const ROW_HEIGHT = 37;
const OVERSCAN = 25;
const GRID_ROOT_STYLE = {
"--base-row-height": `${ROW_HEIGHT}px`,
} as React.CSSProperties;
const ADD_COLUMN_TRACK_WIDTH = 40;
// Hoisted to module scope to avoid allocating a fresh options object on
// every GridContainer render. The function refs from virtual-core are
// stable; only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: (afterRowId?: string, focusPropertyId?: string) => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/** true when a view filter with at least one condition is active; suppresses ghost rows */
isFiltered?: boolean;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
* with the content while only the column-header row stays pinned. In
* inline mode BaseTable injects banner + toolbar here; standalone passes
* null (they render outside the scrollport instead).
*/
aboveBand?: React.ReactNode;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
pageId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
isFiltered,
scrollElement,
aboveBand,
}: GridContainerProps) {
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const rowsContainerRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
useGridAutoScroll(bodyRef, pageId);
useRowAutoScroll(scrollElement, pageId);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const rowIdsRef = useRef(rowIds);
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
const onExpandRow = useRowExpand();
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const editingCellRef = useRef(editingCell);
editingCellRef.current = editingCell;
const { selectionCount, clear: clearSelection, toggle: toggleRow } = useRowSelection(pageId);
const { deleteSelected } = useDeleteSelectedRows(pageId);
const { t } = useTranslation();
const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void];
const focusedCellRef = useRef(focusedCell);
focusedCellRef.current = focusedCell;
const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const setPendingTypeInsert = useSetAtom(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
// This handler's sole job is to commit an inline input editor
// (text/number/url/email) when the user clicks elsewhere, since clicking
// a non-focusable cell does not natively blur the input. Gating on
// editingCell also stops it from stealing focus from unrelated inputs.
if (!editingCellRef.current) return;
const target = e.target as HTMLElement;
if (target.closest(`.${classes.headerCell}`)) return;
if (target.closest("[role=\"dialog\"]")) return;
if (target.closest("[role=\"listbox\"]")) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
if (target.closest(`.${classes.cellEditing}`)) return;
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
// which also clears editingCell. No setEditingCell(null) needed here.
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body && typeof active.blur === "function") {
active.blur();
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, []);
useColumnResize(table, onResizeEnd ?? (() => {}));
// When the scroll container is the window (inline embed mode), the default
// Element-mode observers read scrollTop/scrollLeft, which Window does not
// have. Swap in the Window-mode observers so the virtualizer reads
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
// upcast on getScrollElement; virtual-core's runtime accepts Window when
// the observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Rows are positioned inside .rowsContainer, which sits below the sticky
// band (and aboveBand content) within the scroll content. scrollMargin =
// the container's offset from the scroll content top, in both modes, so
// virtual indexing lines up with what is actually on screen.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const el = rowsContainerRef.current;
if (!el || !scrollElement) return;
const update = () => {
const rect = el.getBoundingClientRect();
if (isWindowScroll) {
setScrollMargin(rect.top + window.scrollY);
} else {
const scrollport = scrollElement as HTMLElement;
setScrollMargin(
rect.top -
scrollport.getBoundingClientRect().top +
scrollport.scrollTop,
);
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) can move the
// grid without resizing it, so listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll, scrollElement]);
// Stable row-id keys: the direct-update element cache and measurement
// cache are keyed by item key, so index keys would go stale whenever rows
// are inserted or reordered above the viewport.
const getItemKey = useCallback(
(index: number) => rowIds[index] ?? index,
[rowIds],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
getItemKey,
directDomUpdates: true,
// 'position' (writes `top`), not 'transform': a transform on the row
// creates a containing block that breaks the position:sticky pinned
// cells inside it.
directDomUpdatesMode: "position",
...windowScrollOptions,
// virtual-core bug: on first attach _willUpdate calls
// _scrollToOffset(getScrollOffset()), which returns undefined when no
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
// mid-page. Seeding initialOffset to the current scroll position makes
// the first _scrollToOffset a no-op.
initialOffset: isWindowScroll
? () => window.scrollY
: () =>
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
});
const virtualItems = virtualizer.getVirtualItems();
const pinnedLeftWidth = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((c) => c.getIsPinned() === "left")
.reduce((sum, c) => sum + c.getSize(), 0),
[table],
);
const scrollCellIntoView = useCallback(
(coord: CellCoord, rowIndex: number) => {
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
requestAnimationFrame(() => {
const scroller = bodyRef.current;
const el = document.getElementById(
`base-cell-${coord.rowId}-${coord.propertyId}`,
);
if (!scroller || !el) return;
const cellRect = el.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const pinned = pinnedLeftWidth();
if (cellRect.left < scRect.left + pinned) {
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
} else if (cellRect.right > scRect.right) {
scroller.scrollLeft += cellRect.right - scRect.right;
}
});
},
[virtualizer, pinnedLeftWidth],
);
useEffect(() => {
if (!editingCell) return;
const idx = rowIdsRef.current.indexOf(editingCell.rowId);
if (idx >= 0) scrollCellIntoView(editingCell, idx);
}, [editingCell, scrollCellIntoView]);
const openEditor = useCallback(
(coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop) return;
if (prop.type === "checkbox") {
if (!editable) return;
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
onCellUpdate(coord.rowId, coord.propertyId, !current);
return;
}
if (!editable) {
if (prop.type === "file") setEditingCell(coord);
return;
}
if (prop.type === "formula") {
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
return;
}
if (isSystemPropertyType(prop.type)) return;
setEditingCell(coord);
},
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
);
const clearCell = useCallback(
(coord: CellCoord) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type)) return;
onCellUpdate(coord.rowId, coord.propertyId, null);
},
[editable, properties, onCellUpdate],
);
const beginTypeToEdit = useCallback(
(coord: CellCoord, char: string) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
if (["text", "number", "url", "email"].includes(prop.type)) {
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
setEditingCell(coord);
} else {
openEditor(coord);
}
},
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
);
const toggleRowSelection = useCallback(
(rowId: string) => {
toggleRow(rowId, {
shiftKey: false,
rowIndex: rowIdsRef.current.indexOf(rowId),
orderedRowIds: rowIdsRef.current,
});
},
[toggleRow],
);
const expandRow = useCallback(
(rowId: string) => {
onExpandRow?.(rowId);
},
[onExpandRow],
);
const prevEditingRef = useRef(editingCell);
useEffect(() => {
const prev = prevEditingRef.current;
prevEditingRef.current = editingCell;
if (prev && !editingCell) {
if (!focusedCellRef.current) setFocusedCell(prev);
const grid = bodyRef.current;
const active = document.activeElement;
if (grid && active && !grid.contains(active)) {
grid.focus({ preventScroll: true });
}
}
}, [editingCell, setFocusedCell]);
useEffect(() => {
const fc = focusedCellRef.current;
if (!fc) return;
const rowOk = rowIds.includes(fc.rowId);
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
if (!rowOk || !colOk) setFocusedCell(null);
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
const handleGridFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (editingCellRef.current || focusedCellRef.current) return;
const firstRow = rowIdsRef.current[0];
const firstCol = table
.getVisibleLeafColumns()
.find((c) => c.id !== "__row_number")?.id;
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
},
[table, setFocusedCell],
);
const handleAddRowBelow = useCallback(
(afterRowId: string, focusPropertyId: string) => {
onAddRow?.(afterRowId, focusPropertyId);
},
[onAddRow],
);
useGridKeyboardNav({
table,
properties,
containerRef: bodyRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
addRow: handleAddRowBelow,
});
const activeCell = editingCell ?? focusedCell;
const activeDescendantId = activeCell
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
: undefined;
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
useEffect(() => {
// When the row set shrinks (filter/sort/view change) or resets to zero,
// un-gate the trigger so the first page can trigger the next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return (
columnWidths.join(" ") +
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
);
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
const totalColumnsWidth = useMemo(
() =>
table
.getVisibleLeafColumns()
.reduce((sum, col) => sum + col.getSize(), 0) +
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
);
const showGhostRows = rows.length === 0 && !isFiltered;
// Append a flexible trailing track so every row spans the full width.
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
// fills remaining width otherwise. The header grid keeps the plain template.
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const getColumnOrder = useCallback(
() => table.getState().columnOrder,
[table],
);
return (
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<GridRowOrderProvider value={getOrderedRowIds}>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
role="grid"
aria-label={t("Base table")}
aria-rowcount={rows.length}
aria-colcount={table.getVisibleLeafColumns().length}
aria-multiselectable
aria-activedescendant={activeDescendantId}
onFocus={handleGridFocus}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
} as React.CSSProperties
}
>
<div
className={classes.rowsContainer}
ref={(node) => {
rowsContainerRef.current = node;
virtualizer.containerRef(node);
}}
role="rowgroup"
style={{ width: totalColumnsWidth, minWidth: "100%" }}
>
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
measureRef={virtualizer.measureElement}
onCellUpdate={onCellUpdate}
properties={properties}
columnVisibility={table.getState().columnVisibility}
columnOrder={table.getState().columnOrder}
pageId={pageId}
onRowReorder={onRowReorder}
/>
);
})}
</div>
{showGhostRows && (
<GridGhostRows
count={3}
columnCount={table.getVisibleLeafColumns().length}
onCreate={editable ? handleAddRow : undefined}
/>
)}
{editable && <AddRowButton onClick={handleAddRow} />}
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</GridRowOrderProvider>
</div>
);
}
@@ -0,0 +1,32 @@
import classes from "@/ee/base/styles/grid.module.css";
type GridGhostRowsProps = {
/** how many placeholder rows to render */
count: number;
/** number of visible leaf columns (incl. the row-number column) */
columnCount: number;
/** create the first real row (clicking any ghost cell); omit when read-only */
onCreate?: () => void;
};
// Empty-state ghost rows shown when no data rows exist and no filter is active.
// Clicking any ghost row creates the first real row; cells align via subgrid.
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
return (
<>
{Array.from({ length: count }).map((_, rowIdx) => (
<div
key={rowIdx}
className={`${classes.row} ${classes.ghostRow}`}
role={onCreate ? "button" : undefined}
aria-label={onCreate ? "Create first row" : undefined}
onClick={onCreate}
>
{Array.from({ length: columnCount }).map((_, colIdx) => (
<div key={colIdx} className={classes.cell} aria-hidden="true" />
))}
</div>
))}
</>
);
}
@@ -0,0 +1,356 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { Badge, Popover } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import {
activePropertyMenuAtomFamily,
propertyMenuDirtyAtomFamily,
propertyMenuCloseRequestAtomFamily,
editingCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import classes from "@/ee/base/styles/grid.module.css";
export const COLUMN_DRAG_TYPE = "base-column";
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
pageId: string;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
pageId,
getColumnOrder,
onColumnReorder,
}: GridHeaderCellProps) {
const { t } = useTranslation();
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount, toggleAll } = useRowSelection(pageId);
const hasSelection = selectionCount > 0;
const editable = useBaseEditable();
const isHeaderInteractive = editable && !!property && !isRowNumber;
const isRowNumberHeaderInteractive =
isRowNumber && editable && loadedRowIds.length > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const resizeIntentRef = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || !!isPinned || !editable;
// onColumnReorder ultimately depends on React Query result objects
// (activeView, base) via persistViewConfig, and their identity changes on
// every cache invalidation (every WS-driven collab refresh). Holding the
// callback in a ref keeps it out of the DnD effect's dep array so we don't
// tear down and re-register the pragmatic-dnd adapter on every header cell
// each time another user edits the base.
const onColumnReorderRef = useRef(onColumnReorder);
useLayoutEffect(() => {
onColumnReorderRef.current = onColumnReorder;
});
useEffect(() => {
const el = cellRef.current;
if (!el || isSortableDisabled) return;
return combine(
draggable({
element: el,
canDrag: () => !resizeIntentRef.current,
getInitialData: () => ({
type: COLUMN_DRAG_TYPE,
columnId: header.column.id,
pageId,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === COLUMN_DRAG_TYPE &&
source.data.columnId !== header.column.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ columnId: header.column.id },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
const order = getColumnOrder();
const startIndex = order.indexOf(source.data.columnId as string);
const indexOfTarget = order.indexOf(header.column.id);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
onColumnReorderRef.current?.(source.data.columnId as string, finishIndex);
triggerPostMoveFlash(el);
liveRegion.announce(`Moved column to position ${finishIndex + 1}`);
},
}),
);
}, [header.column.id, isSortableDisabled, getColumnOrder]);
const handleHeaderClick = useCallback(() => {
if (resizeIntentRef.current) {
resizeIntentRef.current = false;
return;
}
setEditingCell(null);
if (!editable) return;
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [editable, isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
const handleEditFormula = useCallback(() => {
if (!property) return;
handleMenuClose();
setActiveFormulaEditor({ propertyId: property.id, rowId: null });
}, [property, handleMenuClose, setActiveFormulaEditor]);
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const formulaEditorOpen =
!!property &&
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === null;
// A closed property menu can never hold unsaved changes. Saving a rename
// must clear propertyMenuDirty; otherwise it stays stuck true and
// handleHeaderClick refuses to reopen any property menu, making the menu
// appear dead after the first save. Reset only on the open-to-closed
// transition so a sibling header cell can't clear the flag while another
// column's menu is mid-edit.
const wasMenuOpenedRef = useRef(menuOpened);
useEffect(() => {
if (wasMenuOpenedRef.current && !menuOpened) {
setPropertyMenuDirty(false);
}
wasMenuOpenedRef.current = menuOpened;
}, [menuOpened, setPropertyMenuDirty]);
const handleMenuOpenChange = useCallback(
(next: boolean) => {
if (next) return; // opening is driven by the atom, not Mantine
if (propertyMenuDirty) {
// Veto the close and route through the discard-confirm flow.
setCloseRequest(closeRequest + 1);
} else {
handleMenuClose();
}
},
[propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose],
);
const TypeIcon = property ? getDescriptor(property.type)?.icon : undefined;
return (
<div
ref={cellRef}
role="columnheader"
tabIndex={isHeaderInteractive || isRowNumberHeaderInteractive ? 0 : undefined}
aria-haspopup={isHeaderInteractive ? "menu" : undefined}
aria-label={isRowNumberHeaderInteractive ? t("Select all loaded rows") : undefined}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber || !editable ? {} : { cursor: "pointer" }),
opacity: isDragging ? 0.4 : 1,
}}
onPointerDown={() => {
resizeIntentRef.current = false;
}}
onClick={handleHeaderClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isRowNumber) {
if (isRowNumberHeaderInteractive) toggleAll(loadedRowIds);
} else {
handleHeaderClick();
}
}
}}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{property?.pendingType && (
<Badge size="xs" color="gray" variant="light" ml={6}>
{t("Converting…")}
</Badge>
)}
</div>
)}
{editable && 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) => {
resizeIntentRef.current = true;
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{editable && property && !isRowNumber && (
<Popover
opened={menuOpened}
onChange={handleMenuOpenChange}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
onEditFormula={
property.type === "formula" ? handleEditFormula : undefined
}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
)}
{property && !isRowNumber && property.type === "formula" && (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
)}
</div>
);
});
@@ -0,0 +1,64 @@
import { memo, useMemo } from "react";
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import classes from "@/ee/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
pageId: string;
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
properties: IBaseProperty[];
loadedRowIds: string[];
onPropertyCreated?: () => void;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
};
export const GridHeader = memo(function GridHeader({
table,
pageId,
// 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,
getColumnOrder,
onColumnReorder,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
const editable = useBaseEditable();
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}
pageId={pageId}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
))}
{editable && (
<CreatePropertyPopover
pageId={pageId}
properties={properties}
onPropertyCreated={onPropertyCreated}
/>
)}
</div>
);
});
@@ -0,0 +1,198 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Row, VisibilityState } from "@tanstack/react-table";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { GridCell } from "./grid-cell";
import classes from "@/ee/base/styles/grid.module.css";
export const ROW_DRAG_TYPE = "base-row";
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
measureRef: (node: Element | null) => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onRowReorder?: (
rowId: string,
targetRowId: string,
position: "above" | "below",
) => void;
properties: IBaseProperty[];
columnVisibility: VisibilityState;
columnOrder: string[];
pageId: string;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
measureRef,
onCellUpdate,
onRowReorder,
pageId,
}: GridRowProps) {
const rowId = row.id;
const isSelected = useRowSelection(pageId).isSelected(rowId);
const rowRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const setRowEl = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
measureRef(node);
},
[measureRef],
);
// onRowReorder ultimately depends on React Query result objects (activeView,
// base) via persistViewConfig, and its identity changes on every WS-driven
// cache invalidation. Holding it in a ref keeps it out of the DnD effect's
// dep array so we don't tear down and re-register every row's pragmatic-dnd
// adapter each time another user edits the base. Same pattern as the column
// header's onColumnReorderRef.
const onRowReorderRef = useRef(onRowReorder);
useLayoutEffect(() => {
onRowReorderRef.current = onRowReorder;
});
useEffect(() => {
const rowEl = rowRef.current;
if (!rowEl || !onRowReorder) return;
// The whole row is the draggable element (full-row native preview).
// dragHandle limits initiation to the grip, leaving cell clicks and
// inline editing untouched.
const handle = rowEl.querySelector<HTMLElement>(
`.${classes.rowNumberDragHandle}`,
);
if (!handle) return;
return combine(
draggable({
element: rowEl,
dragHandle: handle,
getInitialData: () => ({ type: ROW_DRAG_TYPE, rowId, pageId }),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// Native preview of the full-width sticky subgrid row rasterizes
// garbled (it pulls in surrounding page paint, e.g. the sidebar).
// Render a compact card that clones just the title cell instead.
const titleCell =
rowEl.querySelector<HTMLElement>(`.${classes.primaryCell}`) ??
rowEl.querySelector<HTMLElement>(`.${classes.cell}`);
if (!titleCell) return;
const width = titleCell.getBoundingClientRect().width;
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
render: ({ container }) => {
const card = document.createElement("div");
card.className = classes.rowDragPreview;
card.style.width = `${width}px`;
const clone = titleCell.cloneNode(true) as HTMLElement;
clone.style.position = "static";
clone.style.left = "auto";
clone.style.width = "100%";
clone.style.opacity = "1";
clone.style.borderRight = "none";
card.appendChild(clone);
container.appendChild(card);
},
});
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: rowEl,
canDrop: ({ source }) =>
source.data.type === ROW_DRAG_TYPE &&
source.data.pageId === pageId &&
source.data.rowId !== rowId,
getData: ({ input, element }) =>
attachClosestEdge(
{ rowId },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onRowReorderRef.current?.(
source.data.rowId as string,
rowId,
edge === "top" ? "above" : "below",
);
triggerPostMoveFlash(rowEl);
liveRegion.announce("Moved row");
},
}),
);
// onRowReorder is read through onRowReorderRef; only its presence gates
// registration, and that does not change across a row's mounted life.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowId, pageId]);
const dropIndicatorClass = closestEdge
? closestEdge === "top"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
ref={setRowEl}
data-index={rowIndex}
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
aria-rowindex={rowIndex + 1}
aria-selected={isSelected}
>
{row.getVisibleCells().map((cell, colIndex) => (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
colIndex={colIndex}
onCellUpdate={onCellUpdate}
pageId={pageId}
/>
))}
</div>
);
},
gridRowPropsEqual);
// row compares by row.original: React Query structural sharing keeps
// unchanged rows reference-stable, while TanStack re-instantiates Row/Cell
// wrappers on every data change. properties/columnVisibility/columnOrder are
// layout busters — schema or column-state changes must re-render rows.
function gridRowPropsEqual(prev: GridRowProps, next: GridRowProps) {
return (
prev.row.id === next.row.id &&
prev.row.original === next.row.original &&
prev.rowIndex === next.rowIndex &&
prev.pageId === next.pageId &&
prev.onCellUpdate === next.onCellUpdate &&
prev.onRowReorder === next.onRowReorder &&
prev.measureRef === next.measureRef &&
prev.properties === next.properties &&
prev.columnVisibility === next.columnVisibility &&
prev.columnOrder === next.columnOrder
);
}
@@ -0,0 +1,111 @@
import { memo, useCallback, useMemo } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { FocusedCell } from "@/ee/base/types/base.types";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberCellProps = {
rowId: string;
rowIndex: number;
isPinned: boolean;
pinOffset?: number;
pageId: string;
};
export const RowNumberCell = memo(function RowNumberCell({
rowId,
rowIndex,
isPinned,
pinOffset,
pageId,
}: RowNumberCellProps) {
const { isSelected, toggle } = useRowSelection(pageId);
const selected = isSelected(rowId);
const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder();
const setFocusedCell = useSetAtom(
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
),
[pageId, rowId],
),
);
const handleCellMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
setFocusedCell({ rowId, propertyId: "__row_number" });
},
[rowId, setFocusedCell],
);
const handleCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setFocusedCell({ rowId, propertyId: "__row_number" });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[rowId, setFocusedCell],
);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds: getOrderedRowIds(),
});
},
[rowId, rowIndex, getOrderedRowIds, toggle],
);
return (
<div
id={`base-cell-${rowId}-__row_number`}
role="gridcell"
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
onClick={handleCellClick}
onMouseDown={handleCellMouseDown}
>
<div className={classes.rowNumberCellInner}>
{editable && (
<span className={classes.rowNumberDragHandle} aria-label="Drag row">
<IconGripVertical size={12} />
</span>
)}
{editable && (
<span className={classes.rowNumberCheckbox}>
<Checkbox
size="xs"
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
tabIndex={-1}
/>
</span>
)}
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
</div>
</div>
);
});
@@ -0,0 +1,51 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
pageId: string;
};
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
loadedRowIds,
pageId,
}: RowNumberHeaderCellProps) {
const { selectedIds, toggleAll } = useRowSelection(pageId);
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"
tabIndex={-1}
/>
</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 "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import classes from "@/ee/base/styles/grid.module.css";
type SelectionActionBarProps = {
pageId: string;
};
export const SelectionActionBar = memo(function SelectionActionBar({
pageId,
}: SelectionActionBarProps) {
const { t } = useTranslation();
const { selectionCount, clear } = useRowSelection(pageId);
const { deleteSelected, isPending } = useDeleteSelectedRows(pageId);
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,214 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanMoveCardMutation } from "@/ee/base/queries/base-row-query";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
import classes from "@/ee/base/styles/kanban.module.css";
type BaseKanbanProps = {
base: IBase;
view: IBaseView;
pageId: string;
embedded?: boolean;
editable: boolean;
viewFilter: FilterGroup | undefined;
};
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId);
const openRowRef = useRef(openRow);
useLayoutEffect(() => { openRowRef.current = openRow; });
const handleOpenRow = useCallback((id: string) => openRowRef.current(id), []);
const boardRef = useRef<HTMLDivElement>(null);
useKanbanBoardAutoScroll(boardRef, pageId);
const cardRefs = useRef<Map<string, { columnKey: string; el: HTMLDivElement }>>(new Map());
const registerCardRef = useCallback((rowId: string, columnKey: string, el: HTMLDivElement | null) => {
if (el) {
cardRefs.current.set(rowId, { columnKey, el });
} else {
cardRefs.current.delete(rowId);
}
}, []);
const columnRows = useRef<Map<string, IBaseRow[]>>(new Map());
const registerColumnRows = useCallback((key: string, rows: IBaseRow[]) => {
columnRows.current.set(key, rows);
}, []);
const hideColumn = useCallback(
(key: string) => {
const next = Array.from(new Set([...(view.config?.hiddenChoiceIds ?? []), key]));
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
},
[updateView, view.id, view.config?.hiddenChoiceIds, pageId],
);
const onCardDropRef = useRef<(args: {
draggedRowId: string;
sourceColumnKey: string;
targetColumnKey: string;
targetRowId: string | null;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onCardDropRef.current = ({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge }) => {
if (!groupByPropertyId) return;
const targetColumnRows = columnRows.current.get(targetColumnKey) ?? [];
const result = resolveCardDrop({
draggedRowId,
targetRowId,
edge: edge === "left" || edge === "right" ? null : edge,
targetColumnKey,
sourceColumnKey,
targetColumnRows,
});
if (!result) return;
const sourceFilter = buildColumnFilter(viewFilter, groupByPropertyId, sourceColumnKey);
const destFilter = buildColumnFilter(viewFilter, groupByPropertyId, targetColumnKey);
moveCard.mutate({
pageId,
rowId: draggedRowId,
sourceColumnFilter: sourceFilter,
destColumnFilter: destFilter,
columnChanged: result.columnChanged,
groupByPropertyId,
destChoiceValue: result.destChoiceValue,
position: result.position,
});
const el = cardRefs.current.get(draggedRowId)?.el;
if (el) triggerPostMoveFlash(el);
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved card to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const draggedRowId = source.data.rowId as string;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const isColumnBody = target.data.isColumnBody === true;
const targetRowId = isColumnBody ? null : (target.data.rowId as string);
const edge = isColumnBody ? null : extractClosestEdge(target.data);
onCardDropRef.current({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge });
},
});
}, [pageId]);
const onColumnDropRef = useRef<(args: {
sourceColumnKey: string;
targetColumnKey: string;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onColumnDropRef.current = ({ sourceColumnKey, targetColumnKey, edge }) => {
const fullOrder: string[] = view.config?.choiceOrder?.length
? view.config.choiceOrder
: columns.map((c) => c.key);
const startIndex = fullOrder.indexOf(sourceColumnKey);
const indexOfTarget = fullOrder.indexOf(targetColumnKey);
if (startIndex === -1 || indexOfTarget === -1) {
const visibleKeys = columns.map((c) => c.key);
const visStart = visibleKeys.indexOf(sourceColumnKey);
const visTarget = visibleKeys.indexOf(targetColumnKey);
if (visStart === -1 || visTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex: visStart,
indexOfTarget: visTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === visStart) return;
const reorderedVisible = reorder({ list: visibleKeys, startIndex: visStart, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: [...reorderedVisible, ...(view.config?.hiddenChoiceIds ?? [])] } });
} else {
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
const newChoiceOrder = reorder({ list: fullOrder, startIndex, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: newChoiceOrder } });
}
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved column to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const edge = extractClosestEdge(target.data);
onColumnDropRef.current({ sourceColumnKey, targetColumnKey, edge });
},
});
}, [pageId]);
if (!hasValidGroupBy) {
return <KanbanEmptyState base={base} view={view} pageId={pageId} editable={editable} />;
}
return (
<div
ref={boardRef}
className={clsx(classes.board, embedded ? classes.boardEmbed : classes.boardFullPage)}
>
{columns.map((column) => (
<KanbanColumn
key={column.key}
base={base}
view={view}
pageId={pageId}
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
groupByProperty={groupByProperty}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
registerCardRef={registerCardRef}
registerColumnRows={registerColumnRows}
/>
))}
</div>
);
}
@@ -0,0 +1,339 @@
import { Text, Badge, Tooltip, Group } from "@mantine/core";
import { IconCheck, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { sanitizeUrl } from "@docmost/editor-ext";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
import {
formatNumber,
formatDateDisplay,
formatTimestamp,
formatLongTextPreview,
} from "@/ee/base/formatters/cell-formatters";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { FileValue } from "@/ee/base/components/cells/cell-file";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CardFieldProps = {
property: IBaseProperty;
value: unknown;
pageId: string;
};
export function CardField({ property, value, pageId }: CardFieldProps) {
if (value === null || value === undefined || value === "") return null;
if (Array.isArray(value) && value.length === 0) return null;
switch (property.type) {
case "text":
return <TextField value={value} />;
case "longText":
return <LongTextField value={value} />;
case "number":
return <NumberField value={value} property={property} />;
case "select":
case "status":
return <SelectField value={value} property={property} />;
case "multiSelect":
return <MultiSelectField value={value} property={property} />;
case "date":
return <DateField value={value} property={property} />;
case "createdAt":
case "lastEditedAt":
return <TimestampField value={value} />;
case "person":
return <PersonField value={value} pageId={pageId} />;
case "lastEditedBy":
return <LastEditedByField value={value} pageId={pageId} />;
case "file":
return <FileField value={value} />;
case "page":
return <PageField value={value} basePageId={pageId} propertyPageId={property.pageId} />;
case "checkbox":
return <CheckboxField value={value} />;
case "url":
return <UrlField value={value} />;
case "email":
return <EmailField value={value} />;
case "formula":
return <FormulaField value={value} property={property} />;
default:
return (
<Text size="xs" lineClamp={1}>
{String(value)}
</Text>
);
}
}
function TextField({ value }: { value: unknown }) {
const text = typeof value === "string" ? value : String(value);
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
function LongTextField({ value }: { value: unknown }) {
const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
if (!preview) return null;
return (
<Text size="xs" c="dimmed" lineClamp={2}>
{preview}
</Text>
);
}
function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
const num = typeof value === "number" ? value : null;
if (num === null) return null;
const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
if (!formatted) return null;
return <Text size="sm">{formatted}</Text>;
}
function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const choice = choices.find((c) => c.id === selectedId);
if (!choice) return null;
return (
<ChoiceBadge
name={choice.name}
style={{ ...choiceColor(choice.color), alignSelf: "flex-start" }}
/>
);
}
function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
if (selectedChoices.length === 0) return null;
const chips = selectedChoices.map((choice) => (
<span key={choice.id} className={cellClasses.badge} style={choiceColor(choice.color)}>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={selectedChoices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
const dateStr = typeof value === "string" ? value : null;
const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function TimestampField({ value }: { value: unknown }) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
const store = useReferenceStore(pageId);
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
if (personIds.length === 0) return null;
return <PersonReadList personIds={personIds} users={store.users} />;
}
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(pageId);
if (!userId) return null;
const user = store.users[userId] ?? null;
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar avatarUrl={user?.avatarUrl ?? ""} name={name} size={20} radius="xl" />
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<Text size="xs" truncate>
{name}
</Text>
</Tooltip>
</Group>
);
}
function FileField({ value }: { value: unknown }) {
const files = Array.isArray(value)
? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
: [];
if (files.length === 0) return null;
const maxVisible = 2;
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
{file.fileName}
</span>
))}
{overflow > 0 && <span className={cellClasses.overflowCount}>+{overflow}</span>}
</div>
);
}
function PageField({
value,
basePageId,
propertyPageId,
}: {
value: unknown;
basePageId: string;
propertyPageId: string;
}) {
const { t } = useTranslation();
const pageId = typeof value === "string" && value.length > 0 ? value : null;
const resolvedPage = useResolvePage(propertyPageId, pageId);
if (!pageId) return null;
if (resolvedPage === undefined) return null;
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
const title = getPageTitle(resolvedPage.title, undefined, t);
const spaceSlug = resolvedPage.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{resolvedPage.icon ? (
<span className={cellClasses.pagePillIcon}>{resolvedPage.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
function CheckboxField({ value }: { value: unknown }) {
if (value !== true) return null;
return <IconCheck size={14} />;
}
function UrlField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return (
<Text size="xs" lineClamp={1}>
{displayValue}
</Text>
);
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function EmailField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`} withinPortal>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
if (resultType === "number") {
return <NumberField value={value} property={property} />;
}
if (resultType === "boolean") {
return <CheckboxField value={value} />;
}
if (resultType === "date") {
return <DateField value={value} property={property} />;
}
const text = typeof value === "string" ? value : value != null ? String(value) : null;
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { IconPlus } from "@tabler/icons-react";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanAddCardButtonProps = {
onAddCard: () => void;
};
export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addCard}
role="button"
tabIndex={0}
onClick={onAddCard}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAddCard();
}
}}
>
<IconPlus size={16} />
{t("New row")}
</div>
);
}
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import cellClasses from "@/ee/base/styles/cells.module.css";
import propClasses from "@/ee/base/styles/property.module.css";
const DRAG_TYPE = "base-card-property";
type KanbanCardPropertiesProps = {
opened: boolean;
onClose: () => void;
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanCardProperties({
opened,
onClose,
base,
view,
pageId,
children,
}: KanbanCardPropertiesProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
const visibleIds = view.config?.visiblePropertyIds ?? [];
const savedOrder = view.config?.propertyOrder ?? [];
const orderedProperties = [
...savedOrder
.map((id) => nonPrimaryProperties.find((p) => p.id === id))
.filter((p): p is IBaseProperty => p !== undefined),
...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
];
const primaryProperty = base.properties.find((p) => p.isPrimary);
const PrimaryIcon = primaryProperty
? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
: undefined;
const handleToggle = useCallback(
(propertyId: string, checked: boolean) => {
const next = checked
? [...visibleIds, propertyId]
: visibleIds.filter((id) => id !== propertyId);
updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
},
[updateView, view.id, visibleIds, pageId],
);
const handleReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return;
const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
updateView.mutate({
viewId: view.id,
pageId,
config: { propertyOrder: reordered.map((p) => p.id) },
});
},
[orderedProperties, updateView, view.id, pageId],
);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={4}>
<Group justify="space-between" px={4} py={2}>
<Text size="xs" fw={600} c="dimmed">
{t("Card properties")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(60vh, 420px)" scrollbarSize={6} offsetScrollbars>
<Stack gap={0}>
{primaryProperty && (
<div className={cellClasses.menuItem} style={{ paddingLeft: 4, cursor: "default" }}>
<div className={propClasses.dragHandle} style={{ visibility: "hidden" }}>
<IconGripVertical size={14} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{PrimaryIcon && <PrimaryIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{primaryProperty.name}
</Text>
</Group>
<Switch
size="xs"
checked
disabled
onChange={() => {}}
styles={{ track: { cursor: "default" } }}
/>
</div>
)}
{orderedProperties.map((p) => {
const isVisible = visibleIds.includes(p.id);
const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
const TypeIcon = typeConfig?.icon;
return (
<SortablePropertyRow
key={p.id}
property={p}
isVisible={isVisible}
TypeIcon={TypeIcon}
onToggle={handleToggle}
onReorder={handleReorder}
/>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
type SortablePropertyRowProps = {
property: IBaseProperty;
isVisible: boolean;
TypeIcon: typeof IconLetterT | undefined;
onToggle: (propertyId: string, checked: boolean) => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
};
function SortablePropertyRow({
property,
isVisible,
TypeIcon,
onToggle,
onReorder,
}: SortablePropertyRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
const row = rowRef.current;
const handle = handleRef.current;
if (!row || !handle) return;
return combine(
draggable({
element: row,
dragHandle: handle,
getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: row,
canDrop: ({ source }) =>
source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ propertyId: property.id },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(source.data.propertyId as string, property.id, edge);
},
}),
);
}, [property.id]);
return (
<div
ref={rowRef}
style={{ position: "relative", opacity: isDragging ? 0.4 : 1 }}
>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => onToggle(property.id, !isVisible)}
style={{ paddingLeft: 4 }}
>
<div ref={handleRef} className={propClasses.dragHandle} onClick={(e) => e.stopPropagation()}>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -0,0 +1,85 @@
import { forwardRef, useCallback, useRef } from "react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanCardProps = {
base: IBase;
view: IBaseView;
row: IBaseRow;
columnKey: string;
onOpen: (rowId: string) => void;
};
export const KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(
function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
const { t } = useTranslation();
const primary = base.properties.find((p) => p.isPrimary);
const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
const visibleIds = view.config?.visiblePropertyIds ?? [];
const propertyOrder = view.config?.propertyOrder;
const cardProps = base.properties.filter(
(p) => visibleIds.includes(p.id) && !p.isPrimary,
);
if (propertyOrder) {
cardProps.sort(
(a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
);
}
const cardRef = useRef<HTMLDivElement>(null);
const setCardEl = useCallback(
(node: HTMLDivElement | null) => {
cardRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const { closestEdge, isDragging } = useKanbanCardDnd({
cardRef,
rowId: row.id,
columnKey,
pageId: base.id,
});
return (
<div
ref={setCardEl}
className={clsx(classes.card, isDragging && classes.cardDragging)}
role="button"
tabIndex={0}
onClick={() => onOpen(row.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(row.id);
}
}}
>
{closestEdge === "top" && <BaseDropEdgeIndicator edge="top" />}
<div className={clsx(classes.cardTitle, !title && classes.cardUntitled)}>
{title || t("Untitled")}
</div>
{cardProps.map((property) => (
<CardField
key={property.id}
property={property}
value={row.cells[property.id]}
pageId={base.id}
/>
))}
{closestEdge === "bottom" && <BaseDropEdgeIndicator edge="bottom" />}
</div>
);
},
);
@@ -0,0 +1,70 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Text } from "@mantine/core";
import { IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { IBaseProperty, KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { KanbanColumnTitle } from "@/ee/base/components/kanban/kanban-column-title";
import { KanbanColumnMenu } from "@/ee/base/components/kanban/kanban-column-menu";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
property: IBaseProperty | undefined;
count?: string;
canEdit: boolean;
onHide: () => void;
onAddCard: () => void;
};
export function KanbanColumnHeader({ column, pageId, property, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation();
const dotColor = column.color
? choiceColor(column.color).color as string
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
const headerRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const { closestEdge, isDragging } = useKanbanColumnDnd({
headerRef,
handleRef,
columnKey: column.key,
pageId,
});
return (
<div ref={headerRef} className={clsx(classes.columnHeader, isDragging && classes.columnHeaderDragging)}>
{canEdit && (
<div ref={handleRef} className={classes.columnDragHandle} aria-hidden>
<IconGripVertical size={14} />
</div>
)}
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<KanbanColumnTitle column={column} property={property} pageId={pageId} canEdit={canEdit} />
{count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && (
<>
{property && (
<KanbanColumnMenu property={property} pageId={pageId} onHide={onHide} />
)}
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} />
</ActionIcon>
</>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Popover, Stack } from "@mantine/core";
import { IconDots, IconEyeOff, IconSettings } from "@tabler/icons-react";
import { useAtom } from "jotai";
import {
propertyMenuCloseRequestAtomFamily,
propertyMenuDirtyAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { IBaseProperty } from "@/ee/base/types/base.types";
import {
MenuItem,
PropertyMenuContent,
} from "@/ee/base/components/property/property-menu";
type KanbanColumnMenuProps = {
property: IBaseProperty;
pageId: string;
onHide: () => void;
};
export function KanbanColumnMenu({ property, pageId, onHide }: KanbanColumnMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [view, setView] = useState<"menu" | "property">("menu");
const [dirty, setDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const handleClose = useCallback(() => {
setOpened(false);
setView("menu");
}, []);
const wasOpenedRef = useRef(opened);
useEffect(() => {
if (wasOpenedRef.current && !opened) setDirty(false);
wasOpenedRef.current = opened;
}, [opened, setDirty]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) return;
if (dirty) {
setCloseRequest(closeRequest + 1);
} else {
handleClose();
}
},
[dirty, closeRequest, setCloseRequest, handleClose],
);
const toggle = useCallback(() => {
if (opened) {
handleOpenChange(false);
} else if (!dirty) {
setOpened(true);
}
}, [opened, dirty, handleOpenChange]);
return (
<Popover
opened={opened}
onChange={handleOpenChange}
onClose={handleClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
aria-label={t("Column options")}
onClick={toggle}
>
<IconDots size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{view === "menu" ? (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconSettings size={14} />}
label={t("Edit property")}
onClick={() => setView("property")}
/>
<MenuItem
icon={<IconEyeOff size={14} />}
label={t("Hide group")}
onClick={() => {
handleClose();
onHide();
}}
/>
</Stack>
) : (
<PropertyMenuContent
property={property}
opened={opened}
onClose={handleClose}
onDirtyChange={setDirty}
pageId={pageId}
initialPanel={property.pendingType ? "main" : "options"}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,113 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Group, Popover, Text, TextInput, UnstyledButton } from "@mantine/core";
import { IBaseProperty, KanbanColumn, SelectTypeOptions } from "@/ee/base/types/base.types";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnTitleProps = {
column: KanbanColumn;
property: IBaseProperty | undefined;
pageId: string;
canEdit: boolean;
};
export function KanbanColumnTitle({ column, property, pageId, canEdit }: KanbanColumnTitleProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState("");
const updateProperty = useUpdatePropertyMutation();
const commit = useCallback(() => {
setOpened(false);
const name = draft.trim();
const options = property?.typeOptions as SelectTypeOptions | undefined;
if (!property || !options || !name || name === column.name) return;
if (!options.choices.some((c) => c.id === column.key)) return;
updateProperty.mutate({
propertyId: property.id,
pageId,
typeOptions: {
...options,
choices: options.choices.map((c) =>
c.id === column.key ? { ...c, name } : c,
),
},
});
}, [draft, property, column.name, column.key, pageId, updateProperty]);
const toggle = useCallback(() => {
if (opened) {
commit();
} else {
setDraft(column.name);
setOpened(true);
}
}, [opened, commit, column.name]);
const cancel = useCallback(() => setOpened(false), []);
if (!canEdit || column.isNoValue || !property) {
return (
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
);
}
return (
<Popover
opened={opened}
onChange={(next) => {
if (!next) commit();
}}
position="bottom-start"
shadow="md"
width={240}
withinPortal
trapFocus
returnFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<UnstyledButton className={classes.columnTitleButton} onClick={toggle}>
<Text fw={600} size="sm" truncate flex={1} ta="left">
{column.name}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancel();
}
}}
>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
flex={1}
value={draft}
data-autofocus
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
}
}}
/>
<Button size="xs" onClick={commit}>
{t("Done")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseProperty, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useBaseRowsQuery } from "@/ee/base/queries/base-row-query";
import { useKanbanCreateCardMutation } from "@/ee/base/queries/base-row-query";
import { KanbanColumnHeader } from "@/ee/base/components/kanban/kanban-column-header";
import { KanbanAddCardButton } from "@/ee/base/components/kanban/kanban-add-card-button";
import { KanbanCard } from "@/ee/base/components/kanban/kanban-card";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnProps = {
base: IBase;
view: IBaseView;
pageId: string;
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
groupByProperty: IBaseProperty | undefined;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
registerCardRef: (rowId: string, columnKey: string, el: HTMLDivElement | null) => void;
registerColumnRows: (columnKey: string, rows: IBaseRow[]) => void;
};
export function KanbanColumn({
base,
view,
pageId,
column,
viewFilter,
groupByPropertyId,
groupByProperty,
canEdit,
onOpenRow,
onHide,
registerCardRef,
registerColumnRows,
}: KanbanColumnProps) {
const filter = useMemo(
() => buildColumnFilter(viewFilter, groupByPropertyId, column.key),
[viewFilter, groupByPropertyId, column.key],
);
const rowsQuery = useBaseRowsQuery(pageId, filter, undefined);
const createCard = useKanbanCreateCardMutation();
const rows = useMemo(() => {
const pages = rowsQuery.data?.pages ?? [];
const seen = new Set<string>();
const flat: IBaseRow[] = [];
for (const page of pages) {
for (const row of page.items) {
if (!seen.has(row.id)) {
seen.add(row.id);
flat.push(row);
}
}
}
return flat.slice().sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsQuery.data]);
const count = rowsQuery.isSuccess
? formatKanbanCount(rows.length, rowsQuery.hasNextPage ?? false)
: undefined;
useEffect(() => {
registerColumnRows(column.key, rows);
}, [column.key, rows, registerColumnRows]);
const listRef = useRef<HTMLDivElement>(null);
useKanbanColumnAutoScroll(listRef, pageId);
const pendingScrollRef = useRef<"top" | "bottom" | null>(null);
useEffect(() => {
const placement = pendingScrollRef.current;
if (!placement) return;
pendingScrollRef.current = null;
const el = listRef.current;
if (!el) return;
el.scrollTop = placement === "top" ? 0 : el.scrollHeight;
}, [rows]);
useEffect(() => {
const listEl = listRef.current;
if (!listEl) return;
return dropTargetForElements({
element: listEl,
canDrop: ({ source }) =>
source.data.type === KANBAN_CARD_DRAG_TYPE && source.data.pageId === pageId,
getData: () => ({ columnKey: column.key, isColumnBody: true }),
});
}, [column.key, pageId]);
const onScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const { scrollHeight, scrollTop, clientHeight } = el;
if (
scrollHeight - scrollTop - clientHeight < 200 &&
rowsQuery.hasNextPage &&
!rowsQuery.isFetchingNextPage
) {
rowsQuery.fetchNextPage();
}
}, [rowsQuery.hasNextPage, rowsQuery.isFetchingNextPage, rowsQuery.fetchNextPage]);
const addCard = useCallback(
(placement: "top" | "bottom") => {
let position: string | undefined;
try {
position =
placement === "top"
? generateJitteredKeyBetween(null, rows[0]?.position ?? null)
: generateJitteredKeyBetween(rows[rows.length - 1]?.position ?? null, null);
} catch {
position = undefined;
}
createCard.mutate(
{ pageId, destColumnFilter: filter, groupByPropertyId, columnKey: column.key, position },
{
onSuccess: (newRow) => {
pendingScrollRef.current = placement;
onOpenRow(newRow.id);
},
},
);
},
[createCard, pageId, filter, groupByPropertyId, column.key, onOpenRow, rows],
);
return (
<div className={classes.column} data-column-key={column.key}>
<KanbanColumnHeader
column={column}
pageId={pageId}
property={groupByProperty}
count={count}
canEdit={canEdit}
onHide={() => onHide(column.key)}
onAddCard={() => addCard("top")}
/>
<div className={classes.cardList} ref={listRef} onScroll={onScroll}>
{rows.map((row) => (
<KanbanCard
key={row.id}
base={base}
view={view}
row={row}
columnKey={column.key}
onOpen={onOpenRow}
ref={(el) => registerCardRef(row.id, column.key, el)}
/>
))}
{canEdit && <KanbanAddCardButton onAddCard={() => addCard("bottom")} />}
</div>
</div>
);
}
@@ -0,0 +1,99 @@
import { useCallback } from "react";
import { Stack, Text, Select, Button } from "@mantine/core";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
type KanbanEmptyStateProps = {
base: IBase;
view: IBaseView;
pageId: string;
editable: boolean;
};
export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptyStateProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const createProperty = useCreatePropertyMutation();
const groupableProperties = base.properties.filter(
(p) => p.type === "select" || p.type === "status",
);
const selectData = groupableProperties.map((p) => ({
value: p.id,
label: p.name,
}));
const handleSelect = useCallback(
(value: string | null) => {
if (!value) return;
updateView.mutate({ viewId: view.id, pageId, config: { groupByPropertyId: value } });
},
[updateView, view.id, pageId],
);
const handleCreateStatus = useCallback(() => {
const todoId = generateBaseChoiceId();
const inProgressId = generateBaseChoiceId();
const completeId = generateBaseChoiceId();
createProperty.mutate(
{
pageId,
name: t("Status"),
type: "status",
typeOptions: {
choices: [
{ id: todoId, name: t("Not started"), color: "gray", category: "todo" },
{ id: inProgressId, name: t("In progress"), color: "blue", category: "inProgress" },
{ id: completeId, name: t("Done"), color: "green", category: "complete" },
],
choiceOrder: [todoId, inProgressId, completeId],
},
},
{
onSuccess: (newProperty) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: newProperty.id },
});
},
},
);
}, [createProperty, updateView, view.id, pageId, t]);
if (!editable) {
return (
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
</Stack>
);
}
return (
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
{groupableProperties.length > 0 ? (
<Select
placeholder={t("Choose a property")}
data={selectData}
value={view.config?.groupByPropertyId ?? null}
onChange={handleSelect}
w={240}
/>
) : (
<Button
variant="light"
size="sm"
onClick={handleCreateStatus}
loading={createProperty.isPending}
>
{t("Create a status property")}
</Button>
)}
</Stack>
);
}
@@ -0,0 +1,115 @@
import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import cellClasses from "@/ee/base/styles/cells.module.css";
type KanbanGroupByPickerProps = {
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
const data = base.properties
.filter((p) => p.type === "select" || p.type === "status")
.map((p) => ({ value: p.id, label: p.name }));
const handleChange = (value: string | null) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: value ?? null },
});
};
const toggleGroup = (key: string, currentlyHidden: boolean) => {
const current = view.config?.hiddenChoiceIds ?? [];
const next = currentlyHidden
? current.filter((k) => k !== key)
: [...current, key];
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
};
return (
<Popover
position="bottom-end"
shadow="md"
width={300}
withinPortal
trapFocus
closeOnEscape
closeOnClickOutside
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed">
{t("Group by")}
</Text>
<Select
size="xs"
placeholder={t("Select a property")}
data={data}
value={view.config?.groupByPropertyId ?? null}
onChange={handleChange}
clearable
/>
{hasValidGroupBy && allGroups.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t("Groups")}
</Text>
<Stack gap={0}>
{allGroups.map((g) => {
const dotColor = g.color
? (choiceColor(g.color).color as string)
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
return (
<UnstyledButton
key={g.key}
className={cellClasses.menuItem}
onClick={() => toggleGroup(g.key, g.hidden)}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<Text
size="sm"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{g.isNoValue ? t("No value") : g.name}
</Text>
</Group>
<Switch
size="xs"
checked={!g.hidden}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,673 @@
import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } 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 classes from "@/ee/base/styles/property.module.css";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { DefaultValuePicker } from "./default-value-picker";
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;
// Default choices for a new status property, one per category.
export function defaultStatusChoices(): Choice[] {
return [
{ id: generateBaseChoiceId(), name: "Not started", color: "gray", category: "todo" },
{ id: generateBaseChoiceId(), name: "In progress", color: "blue", category: "inProgress" },
{ id: generateBaseChoiceId(), name: "Done", color: "green", category: "complete" },
];
}
function pruneDefault(
value: string | string[] | null,
choices: Choice[],
): string | string[] | null {
if (value === null) return null;
const ids = new Set(choices.map((c) => c.id));
if (Array.isArray(value)) {
const live = value.filter((id) => ids.has(id));
return live.length ? live : null;
}
return ids.has(value) ? value : null;
}
function defaultsEqual(
a: string | string[] | null,
b: string | string[] | null,
): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
return a === b;
}
type ChoiceEditorProps = {
initialChoices: Choice[];
onSave: (choices: Choice[], defaultValue: string | string[] | null) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
hideButtons?: boolean;
initialDefaultValue?: string | string[] | null;
multiDefault?: boolean;
/**
* Where the per-choice color-picker popover portals. Pass the enclosing
* property-menu dropdown node so the picker renders INSIDE that subtree —
* otherwise a color click registers as "outside" and closes the menu.
*/
dropdownPortalTarget?: HTMLElement | null;
};
export function ChoiceEditor({
initialChoices,
onSave,
onClose,
onDirtyChange,
showCategories = false,
hideButtons = false,
initialDefaultValue = null,
multiDefault = false,
dropdownPortalTarget,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Choice[]>(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
const [defaultDraft, setDefaultDraft] = useState<string | string[] | null>(
initialDefaultValue,
);
useEffect(() => {
if (!hideButtons) {
setDraft(initialChoices);
setDefaultDraft(initialDefaultValue);
}
}, [initialChoices, initialDefaultValue, hideButtons]);
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
if (hideButtons) {
const cleaned = draft.filter((c) => c.name.trim());
onSaveRef.current(cleaned, pruneDefault(defaultDraft, cleaned));
}
}, [hideButtons, draft, defaultDraft]);
const isDirty = useMemo(() => {
if (!defaultsEqual(defaultDraft, initialDefaultValue)) return true;
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, defaultDraft, initialDefaultValue]);
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));
setDefaultDraft((prev) => {
if (prev === null) return prev;
if (Array.isArray(prev)) {
const next = prev.filter((id) => id !== choiceId);
return next.length ? next : null;
}
return prev === choiceId ? null : prev;
});
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = generateBaseChoiceId();
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, pruneDefault(defaultDraft, cleaned));
onClose();
}, [draft, defaultDraft, onSave, onClose]);
const handleCancel = useCallback(() => {
setDraft(initialChoices);
setDefaultDraft(initialDefaultValue);
onDirtyChange?.(false);
onClose();
}, [initialChoices, initialDefaultValue, onDirtyChange, onClose]);
const handleReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
setDraft((prev) => {
const startIndex = prev.findIndex((c) => c.id === activeId);
const indexOfTarget = prev.findIndex((c) => c.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return prev;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return prev;
return reorder({ list: prev, startIndex, finishIndex });
});
},
[],
);
const handleCategoryReorder = useCallback(
(category: string, activeId: string, targetId: string, edge: Edge) => {
setDraft((prev) => {
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
const startIndex = catChoices.findIndex((c) => c.id === activeId);
const indexOfTarget = catChoices.findIndex((c) => c.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return prev;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return prev;
const reordered = reorder({
list: catChoices,
startIndex,
finishIndex,
});
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} className={classes.alphabetizeBtn}>
<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}
dropdownPortalTarget={dropdownPortalTarget}
/>
) : (
<FlatChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onReorder={handleReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
)}
<DefaultValuePicker
choices={draft.filter((c) => c.name.trim())}
value={defaultDraft}
multiple={multiDefault}
onChange={setDefaultDraft}
dropdownPortalTarget={dropdownPortalTarget}
/>
{!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,
dropdownPortalTarget,
}: {
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, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const { t } = useTranslation();
return (
<Stack gap={4}>
{draft.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
dragType="base-choice-flat"
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onReorder={onReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
<UnstyledButton
onClick={() => onAdd()}
className={classes.addOptionBtn}
>
<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,
dropdownPortalTarget,
}: {
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, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
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}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
</Stack>
);
}
function CategorySection({
category,
label,
choices,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
dropdownPortalTarget,
}: {
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,
targetId: string,
edge: Edge,
) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const { t } = useTranslation();
const handleRowReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
onReorder(category, activeId, targetId, edge);
},
[category, onReorder],
);
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t(label)}
</Text>
{choices.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
// Per-category drag type prevents cross-category drops.
dragType={`base-choice-status:${category}`}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onReorder={handleRowReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
<UnstyledButton
onClick={() => onAdd(category)}
className={classes.addOptionBtn}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function SortableChoiceRow({
choice,
dragType,
autoFocus,
onFocused,
onRename,
onColorChange,
onRemove,
onReorder,
dropdownPortalTarget,
}: {
choice: Choice;
dragType: string;
autoFocus?: boolean;
onFocused?: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
// Stable ref so the DnD effect doesn't re-register on every parent render.
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus();
onFocused?.();
}
}, [autoFocus, onFocused]);
useEffect(() => {
const row = rowRef.current;
const handle = handleRef.current;
if (!row || !handle) return;
return combine(
draggable({
element: row,
dragHandle: handle,
getInitialData: () => ({ type: dragType, choiceId: choice.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: row,
canDrop: ({ source }) =>
source.data.type === dragType &&
source.data.choiceId !== choice.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ choiceId: choice.id },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(
source.data.choiceId as string,
choice.id,
edge,
);
triggerPostMoveFlash(row);
liveRegion.announce("Moved option");
},
}),
);
}, [choice.id, dragType]);
const hasError = !choice.name.trim();
return (
<Group
ref={rowRef}
gap={6}
wrap="nowrap"
align="center"
style={{
position: "relative",
opacity: isDragging ? 0.4 : 1,
}}
data-dragging={isDragging || undefined}
>
<div ref={handleRef} className={classes.dragHandle}>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<ColorDot
color={choice.color}
onChange={(c) => onColorChange(choice.id, c)}
dropdownPortalTarget={dropdownPortalTarget}
/>
<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)} />
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</Group>
);
}
function ColorDot({
color,
onChange,
dropdownPortalTarget,
}: {
color: string;
onChange: (color: string) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const [opened, setOpened] = useState(false);
const colors = choiceColor(color);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
shadow="sm"
withinPortal
portalProps={{ target: dropdownPortalTarget ?? undefined }}
>
<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,127 @@
import type { BasePropertyType } from "@/ee/base/types/base.types";
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
"createdAt",
"lastEditedAt",
"lastEditedBy",
"formula",
]);
type ConversionInfo = {
// i18n source key (translation files key them by their exact text).
message: string;
// True when cells can be cleared, discarded, truncated, or have their
// structured value flattened, i.e. the change is not safely reversible.
// Drives the destructive (red) "Apply" button in the confirm panel.
lossy: boolean;
};
// Buckets ordered most-specific first; default covers safe reinterpretations.
function describeConversion(
from: BasePropertyType,
to: BasePropertyType,
): ConversionInfo {
if (to === "text" || to === "longText") {
if (from === "longText" && to === "text") {
return {
message:
"Cells longer than the Text limit will be truncated and the extra content permanently lost.",
lossy: true,
};
}
if (from === "select" || from === "status") {
return { message: "Cells will be replaced with the option name.", lossy: true };
}
if (from === "multiSelect") {
return {
message:
"Cells will be replaced with a comma-separated list of option names.",
lossy: true,
};
}
if (from === "person") {
return { message: "Cells will be replaced with the person's name.", lossy: true };
}
if (from === "file") {
return {
message:
"Cells will be replaced with a comma-separated list of file names.",
lossy: true,
};
}
if (from === "page") {
return { message: "Cells will be replaced with the page title.", lossy: true };
}
}
if (to === "select" && from === "multiSelect") {
return {
message:
"Only the first selected item per row will be kept; the rest will be discarded.",
lossy: true,
};
}
if (to === "multiSelect" && from === "select") {
return {
message: "Existing values become single-item lists. No data is lost.",
lossy: false,
};
}
if (to === "page") {
return {
message: "Cells that aren't already a page reference will be cleared.",
lossy: true,
};
}
if (to === "number" && from !== "number") {
return {
message: "Cells that can't be parsed as a number will be cleared.",
lossy: true,
};
}
if (to === "date" && from !== "date") {
return {
message: "Cells that can't be parsed as a date will be cleared.",
lossy: true,
};
}
if (to === "checkbox" && from !== "checkbox") {
return {
message:
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
lossy: true,
};
}
if ((to === "url" || to === "email") && from !== to) {
return {
message:
to === "url"
? "Cells that aren't a valid URL will be cleared."
: "Cells that aren't a valid email address will be cleared.",
lossy: true,
};
}
return { message: "Cells will be reinterpreted under the new type.", lossy: false };
}
export function conversionWarning(
from: BasePropertyType,
to: BasePropertyType,
): string {
return describeConversion(from, to).message;
}
// Whether the type change can lose data, used to make "Apply" destructive.
export function isLossyConversion(
from: BasePropertyType,
to: BasePropertyType,
): boolean {
return describeConversion(from, to).lossy;
}
@@ -0,0 +1,417 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Popover,
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 "@/ee/base/types/base.types";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
getDescriptor,
defaultTypeOptionsFor,
propertyTypes,
} from "@/ee/base/property-types/property-type.registry";
import { FormulaEditor } from "../formula/formula-editor";
import classes from "@/ee/base/styles/grid.module.css";
type CreatePropertyPopoverProps = {
pageId: string;
properties?: IBaseProperty[];
onPropertyCreated?: (property: IBaseProperty) => void;
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
* Defaults to the grid's + column button. */
renderTarget?: (open: () => void) => React.ReactElement;
};
type Panel = "typePicker" | "configure" | "confirmDiscard";
const noop = () => {};
export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: 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>>({});
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<"bottom-start" | "top-start">(
"bottom-start",
);
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 nameTaken = useMemo(() => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return false;
return (properties ?? []).some(
(p) => p.name.trim().toLowerCase() === trimmed,
);
}, [name, properties]);
// Fall back to the type label when Name is blank, suffixing a counter if taken.
const fallbackName = useMemo(() => {
const base = selectedTypeLabel || "Property";
const existing = new Set(
(properties ?? []).map((p) => p.name.trim().toLowerCase()),
);
if (!existing.has(base.toLowerCase())) return base;
for (let i = 1; i < 1000; i++) {
const candidate = `${base} ${i}`;
if (!existing.has(candidate.toLowerCase())) return candidate;
}
return `${base} ${Date.now()}`;
}, [selectedTypeLabel, properties]);
const resetState = useCallback(() => {
setPanel("typePicker");
setSelectedType(null);
setName("");
setTypeOptions({});
}, []);
const handleOpen = useCallback(
(event?: React.SyntheticEvent) => {
resetState();
const trigger = event?.currentTarget as HTMLElement | undefined;
if (trigger) {
const rect = trigger.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
}
setOpened(true);
},
[resetState],
);
const handleClose = useCallback(() => {
// Don't reset state here: resetting mid-close flashes the type picker.
// handleOpen resets on the next open instead.
setOpened(false);
}, []);
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(defaultTypeOptionsFor(type));
setPanel("configure");
}, []);
useEffect(() => {
if (panel === "configure") {
setTimeout(() => nameInputRef.current?.focus(), 0);
}
}, [panel]);
const handleCreate = useCallback(() => {
if (!selectedType || nameTaken) return;
const finalName = name.trim() || fallbackName;
createPropertyMutation.mutate(
{
pageId,
name: finalName,
type: selectedType,
typeOptions: Object.keys(typeOptions).length > 0
? typeOptions as TypeOptions
: undefined,
},
{
onSuccess: (created) => {
onPropertyCreated?.(created);
},
},
);
handleClose();
}, [selectedType, nameTaken, name, fallbackName, typeOptions, pageId, 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: "",
pageId,
name: name || "",
type: selectedType ?? "text",
position: "",
typeOptions: typeOptions as TypeOptions,
isPrimary: false,
workspaceId: "",
createdAt: "",
updatedAt: "",
}), [pageId, name, selectedType, typeOptions]);
const TypeIcon = selectedTypeIcon;
const showOptions = !!selectedType && (getDescriptor(selectedType)?.hasOptions ?? false);
return (
<>
<Popover
opened={opened}
onChange={(o) => {
if (!o) attemptClose();
}}
position={position}
shadow="md"
closeOnClickOutside
closeOnEscape={false}
withinPortal
hideDetached={false}
middlewares={{
flip: false,
shift: true,
size: {
padding: 8,
apply: ({ availableHeight }) => {
const el = scrollRef.current;
if (el) el.style.maxHeight = `${availableHeight}px`;
},
},
}}
>
<Popover.Target>
{renderTarget ? (
renderTarget(handleOpen)
) : (
<div
className={classes.addColumnButton}
onClick={handleOpen}
role="button"
tabIndex={0}
>
<IconPlus size={16} />
</div>
)}
</Popover.Target>
<Popover.Dropdown
ref={setDropdownNode}
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
style={{
zIndex: 300,
width: selectedType === "formula" ? 460 : undefined,
minWidth: selectedType === "formula" ? undefined : 320,
maxWidth: "calc(100vw - 32px)",
}}
>
<div ref={scrollRef} style={{ overflowY: "auto", overflowX: "hidden" }}>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<ScrollArea.Autosize
mah="min(60vh, 400px)"
scrollbarSize={6}
offsetScrollbars
>
<PropertyTypePicker
onSelect={handleTypeSelect}
showSearch
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "configure" && selectedType === "formula" && (
<Stack gap="xs" p="sm">
<TextInput
ref={nameInputRef}
size="xs"
label={t("Name")}
placeholder={fallbackName}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
error={nameTaken ? t("A property with this name already exists") : undefined}
/>
<FormulaEditor
properties={properties ?? []}
editingPropertyId={null}
name={name.trim() || undefined}
onCancel={handleBackToTypePicker}
disabled={nameTaken}
onSave={(source, ast, resultType, dependencies) => {
if (nameTaken) return;
createPropertyMutation.mutate(
{
pageId,
name: name.trim() || fallbackName,
type: "formula",
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
},
{ onSuccess: (created) => onPropertyCreated?.(created) },
);
handleClose();
}}
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<TextInput
ref={nameInputRef}
size="xs"
label={t("Name")}
placeholder={fallbackName}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={handleNameKeyDown}
error={nameTaken ? t("A property with this name already exists") : undefined}
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
dropdownPortalTarget={dropdownNode}
/>
</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} disabled={nameTaken}>
{t("Create property")}
</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>
)}
</div>
</Popover.Dropdown>
</Popover>
</>
);
}
@@ -0,0 +1,98 @@
import { Group, MultiSelect, Select, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import type { ComboboxItem } from "@mantine/core";
type DefaultValuePickerProps = {
choices: Choice[];
value: string | string[] | null;
multiple?: boolean;
onChange: (value: string | string[] | null) => void;
dropdownPortalTarget?: HTMLElement | null;
};
export function DefaultValuePicker({
choices,
value,
multiple,
onChange,
dropdownPortalTarget,
}: DefaultValuePickerProps) {
const { t } = useTranslation();
const data = choices.map((c) => ({ value: c.id, label: c.name }));
const comboboxProps = {
portalProps: { target: dropdownPortalTarget ?? undefined },
};
const renderOption = ({
option,
checked,
}: {
option: ComboboxItem;
checked?: boolean;
}) => {
const choice = choices.find((c) => c.id === option.value);
const colors = choice ? choiceColor(choice.color) : undefined;
return (
<Group gap={6} wrap="nowrap" justify="space-between" style={{ flex: 1 }}>
<Group gap={6} wrap="nowrap">
{colors && (
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
)}
<Text size="xs">{option.label}</Text>
</Group>
{checked && (
<IconCheck size={14} color="var(--mantine-color-dimmed)" />
)}
</Group>
);
};
if (multiple) {
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
label={t("Default value")}
placeholder={selected.length ? undefined : t("None")}
data={data}
value={selected}
onChange={(vals) => onChange(vals.length ? vals : null)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
const single =
typeof value === "string" && choices.some((c) => c.id === value)
? value
: null;
return (
<Select
size="xs"
label={t("Default value")}
placeholder={t("None")}
data={data}
value={single}
onChange={(val) => onChange(val)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
@@ -0,0 +1,591 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
UnstyledButton,
TextInput,
Button,
Stack,
Text,
Group,
ActionIcon,
Divider,
ScrollArea,
Loader,
} from "@mantine/core";
import {
IconTrash,
IconPencil,
IconChevronRight,
IconSettings,
IconMathFunction,
} from "@tabler/icons-react";
import {
IBaseProperty,
BasePropertyType,
TypeOptions,
SelectTypeOptions,
} from "@/ee/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
conversionWarning,
isLossyConversion,
NON_USER_TARGET_TYPES,
} from "./conversion-warning";
import { useTranslation } from "react-i18next";
import {
isSystemPropertyType,
propertyTypes,
defaultTypeOptionsFor,
} from "@/ee/base/property-types/property-type.registry";
import cellClasses from "@/ee/base/styles/cells.module.css";
import classes from "@/ee/base/styles/property.module.css";
type PropertyMenuContentProps = {
property: IBaseProperty;
opened: boolean;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void;
pageId: string;
initialPanel?: "main" | "options";
};
type MenuPanel =
| "main"
| "rename"
| "options"
| "changeType"
| "confirmTypeChange"
| "confirmDelete"
| "confirmDiscard";
const CHOICE_TYPES = new Set<BasePropertyType>([
"select",
"multiSelect",
"status",
]);
function typeOptionsForConversion(
source: IBaseProperty,
target: BasePropertyType,
): TypeOptions {
if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) {
return defaultTypeOptionsFor(target);
}
const opts = source.typeOptions as SelectTypeOptions | undefined;
const choices = opts?.choices ?? [];
const choiceOrder = opts?.choiceOrder?.length
? opts.choiceOrder
: choices.map((c) => c.id);
const carried: SelectTypeOptions = { choices, choiceOrder };
if (target === "status") {
carried.defaultValue = choices[0]?.id ?? null;
}
return carried;
}
export function PropertyMenuContent({
property,
opened,
onClose,
onDirtyChange,
onEditFormula,
pageId,
initialPanel,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [optionsAnchor, setOptionsAnchor] = useState<HTMLDivElement | null>(null);
const [pendingTargetType, setPendingTargetType] = useState<BasePropertyType | null>(null);
const pendingActionRef = useRef<"back" | "close" | null>(null);
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number];
const closeRequestRef = useRef(closeRequest);
const renameDirty = renameValue !== property.name;
const updatePropertyMutation = useUpdatePropertyMutation();
const deletePropertyMutation = useDeletePropertyMutation();
useEffect(() => {
if (opened) {
setPanel(initialPanel ?? "main");
setRenameValue(property.name);
setOptionsDirty(false);
setPendingTargetType(null);
}
}, [opened, property.name, initialPanel]);
useEffect(() => {
if (panel === "rename") {
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [panel]);
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
setOptionsDirty(dirty);
}, []);
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,
pageId: property.pageId,
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,
pageId: property.pageId,
typeOptions,
});
setOptionsDirty(false);
},
[property, updatePropertyMutation],
);
const handleTypeSelect = useCallback(
(type: BasePropertyType) => {
if (type === property.type) {
onClose();
return;
}
setPendingTargetType(type);
setPanel("confirmTypeChange");
},
[property.type, onClose],
);
const handleApplyTypeChange = useCallback(() => {
if (!pendingTargetType) return;
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
type: pendingTargetType,
typeOptions: typeOptionsForConversion(property, pendingTargetType),
});
onClose();
}, [pendingTargetType, property, updatePropertyMutation, onClose]);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
});
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")}
onChangeType={() => setPanel("changeType")}
onOptions={() => setPanel("options")}
onDelete={() => setPanel("confirmDelete")}
onEditFormula={onEditFormula}
/>
)}
{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 === "changeType" && (
<Stack gap={0} p={4}>
<Group gap="xs" px="sm" py={6}>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => setPanel("main")}
>
<IconChevronRight
size={14}
className={classes.chevronBack}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Change type")}
</Text>
</Group>
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
<PropertyTypePicker
onSelect={handleTypeSelect}
currentType={property.type}
excludeTypes={NON_USER_TARGET_TYPES}
showSearch
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "confirmTypeChange" && pendingTargetType && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Change type to {{label}}?", {
label: t(
propertyTypes.find((pt) => pt.type === pendingTargetType)
?.labelKey ?? pendingTargetType,
),
})}
</Text>
<Text size="xs" c="dimmed">
{t(conversionWarning(property.type, pendingTargetType))}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setPanel("main")}
>
{t("Cancel")}
</Button>
<Button
size="xs"
color={
isLossyConversion(property.type, pendingTargetType)
? "red"
: undefined
}
onClick={handleApplyTypeChange}
>
{t("Apply")}
</Button>
</Group>
</Stack>
)}
{(panel === "options" || panel === "confirmDiscard") && (
<Stack
ref={setOptionsAnchor}
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}
className={classes.chevronBack}
/>
</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}
dropdownPortalTarget={optionsAnchor}
/>
</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>
)}
</>
);
}
PropertyMenuContent.displayName = "PropertyMenuContent";
export 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,
onChangeType,
onOptions,
onDelete,
onEditFormula,
}: {
property: IBaseProperty;
onRename: () => void;
onChangeType: () => void;
onOptions: () => void;
onDelete: () => void;
onEditFormula?: () => void;
}) {
const { t } = useTranslation();
const isSystem = isSystemPropertyType(property.type);
const isPending = property.pendingType != null;
const hasOptions =
!isSystem &&
!isPending &&
(property.type === "select" ||
property.type === "multiSelect" ||
property.type === "status" ||
property.type === "number" ||
property.type === "date" ||
property.type === "text" ||
property.type === "longText" ||
property.type === "checkbox" ||
property.type === "url" ||
property.type === "email");
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}
/>
{property.type === "formula" && !isPending && onEditFormula && (
<MenuItem
icon={<IconMathFunction size={14} />}
label={t("Edit formula")}
onClick={onEditFormula}
/>
)}
{isPending && (
<Group gap={8} px="sm" py={8}>
<Loader size={12} />
<Text size="sm" c="dimmed">
{t("Converting…")}
</Text>
</Group>
)}
{!isSystem && !isPending && !property.isPrimary && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={onChangeType}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{TypeIcon ? <TypeIcon size={14} /> : null}
<Text size="sm">
{typeDef ? t(typeDef.labelKey) : property.type}
</Text>
</Group>
<IconChevronRight size={14} />
</UnstyledButton>
)}
{hasOptions && (
<MenuItem
icon={<IconSettings size={14} />}
label={t("Options")}
rightIcon={<IconChevronRight size={14} />}
onClick={onOptions}
/>
)}
{!property.isPrimary && !isPending && (
<>
<Divider my={4} />
<MenuItem
icon={<IconTrash size={14} />}
label={t("Delete property")}
color="red"
onClick={onDelete}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,622 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Stack,
NumberInput,
Select,
Switch,
Text,
Button,
Group,
Divider,
TextInput,
Textarea,
} from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
PersonTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
import {
CURRENCIES,
DEFAULT_CURRENCY_CODE,
} from "@/ee/base/constants/currencies";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
// Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
dropdownPortalTarget?: HTMLElement | null;
};
export function PropertyOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "person":
return (
<PersonOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "text":
case "longText":
case "url":
case "email":
return (
<TextDefaultOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "checkbox":
return (
<CheckboxOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
type OptionEditorProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
dropdownPortalTarget?: HTMLElement | null;
};
const EMPTY_OPTIONS: Record<string, unknown> = {};
function optionsEqual(
a: Record<string, unknown>,
b: Record<string, unknown>,
): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
const av = a[key];
const bv = b[key];
if (Array.isArray(av) && Array.isArray(bv)) {
if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
return false;
}
} else if (av !== bv) {
return false;
}
}
return true;
}
// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
function useEditableTypeOptions(
initialRaw: Record<string, unknown> | undefined,
{
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
onUpdate: (opts: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
},
) {
const initial = initialRaw ?? EMPTY_OPTIONS;
const [draft, setDraft] = useState<Record<string, unknown>>(initial);
useEffect(() => {
if (!hideButtons) setDraft(initial);
}, [initial, hideButtons]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
useEffect(() => {
if (hideButtons) onUpdateRef.current(draft);
}, [hideButtons, draft]);
const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const update = useCallback(
(patch: Record<string, unknown>) =>
setDraft((prev) => ({ ...prev, ...patch })),
[],
);
const save = useCallback(() => {
onUpdate(draft);
onClose();
}, [draft, onUpdate, onClose]);
const cancel = useCallback(() => {
setDraft(initial);
onDirtyChange?.(false);
onClose();
}, [initial, onClose, onDirtyChange]);
return { draft, update, isDirty, save, cancel };
}
function OptionsFooter({
isDirty,
onCancel,
onSave,
}: {
isDirty: boolean;
onCancel: () => void;
onSave: () => void;
}) {
const { t } = useTranslation();
return (
<>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={onSave} disabled={!isDirty}>
{t("Save")}
</Button>
</Group>
</>
);
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
multiDefault={property.type === "multiSelect"}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function NumberOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as NumberTypeOptions;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
]}
value={options.format ?? "plain"}
onChange={(val) => update({ format: val ?? "plain" })}
/>
{options.format === "currency" && (
<Select
size="xs"
label={t("Currency")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={CURRENCIES.map((c) => ({
value: c.code,
label: `${c.name} (${c.code})`,
}))}
value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
onChange={(val) =>
update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
}
/>
)}
<Select
size="xs"
label={t("Thousands and decimal separators")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "none", label: t("None") },
{ value: "local", label: t("Local") },
{ value: "comma_period", label: t("Comma, period") },
{ value: "period_comma", label: t("Period, comma") },
{ value: "space_comma", label: t("Space, comma") },
{ value: "space_period", label: t("Space, period") },
]}
value={options.separators ?? "none"}
onChange={(val) => update({ separators: val ?? "none" })}
/>
<Select
size="xs"
label={t("Decimal places")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "default", label: t("Default") },
...Array.from({ length: 9 }, (_, i) => ({
value: String(i),
label: String(i),
})),
]}
value={options.precision == null ? "default" : String(options.precision)}
onChange={(val) =>
update({ precision: val == null || val === "default" ? undefined : Number(val) })
}
/>
<NumberInput
size="xs"
label={t("Default value")}
placeholder={t("None")}
value={typeof options.defaultValue === "number" ? options.defaultValue : ""}
onChange={(val) =>
update({ defaultValue: typeof val === "number" ? val : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function DateOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as DateTypeOptions;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options.includeTime ?? false}
onChange={(e) => update({ includeTime: e.currentTarget.checked })}
/>
{options.includeTime && (
<Select
size="xs"
label={t("Time format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options.timeFormat ?? "12h"}
onChange={(val) => update({ timeFormat: val ?? "12h" })}
/>
)}
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function PersonOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as PersonTypeOptions;
const allowMultiple = options.allowMultiple === true;
const handleAllowMultipleChange = (toMulti: boolean) => {
const dv = options.defaultValue;
const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
update({
allowMultiple: toMulti,
defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
});
};
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Allow multiple people")}
checked={allowMultiple}
onChange={(e) => handleAllowMultipleChange(e.currentTarget.checked)}
/>
<FilterPersonInput
pageId={property.pageId}
multiple={allowMultiple}
value={options.defaultValue ?? null}
onChange={(value) =>
update({ defaultValue: value as string | string[] | undefined })
}
placeholder={t("None")}
label={t("Default value")}
w="100%"
portalTarget={dropdownPortalTarget}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function TextDefaultOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const defaultValue =
typeof draft.defaultValue === "string" ? draft.defaultValue : "";
const defaultValueError =
defaultValue && property.type === "url" && !URL.canParse(defaultValue)
? t("Please enter a valid url")
: defaultValue &&
property.type === "email" &&
!EMAIL_FORMAT.test(defaultValue)
? t("Please enter a valid email")
: null;
return (
<Stack gap="xs">
{property.type === "longText" ? (
<Textarea
size="xs"
label={t("Default value")}
placeholder={t("None")}
autosize
minRows={2}
maxRows={6}
value={defaultValue}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
) : (
<TextInput
size="xs"
label={t("Default value")}
placeholder={
property.type === "url"
? "https://example.com"
: property.type === "email"
? "name@example.com"
: t("None")
}
value={defaultValue}
error={defaultValueError}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
)}
{!hideButtons && (
<OptionsFooter
isDirty={isDirty && !defaultValueError}
onCancel={cancel}
onSave={save}
/>
)}
</Stack>
);
}
function CheckboxOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Checked by default")}
checked={draft.defaultValue === true}
onChange={(e) =>
update({ defaultValue: e.currentTarget.checked ? true : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
@@ -0,0 +1,71 @@
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { BasePropertyType } from "@/ee/base/types/base.types";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useState, useRef, useEffect } from "react";
import classes from "@/ee/base/styles/cells.module.css";
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 property 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>
))}
</>
);
}
@@ -0,0 +1,142 @@
import { forwardRef } from "react";
import { Checkbox } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import clsx from "clsx";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldText } from "./field-text";
import { FieldLongText } from "./field-long-text";
import { FieldNumber } from "./field-number";
import { FieldDate } from "./field-date";
import { FieldChoice } from "./field-choice";
import { FieldCellAdapter } from "./field-cell-adapter";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
export type FieldProps = {
property: IBaseProperty;
value: unknown;
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
};
type FieldShellProps = {
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
cursor?: "text" | "pointer" | "default";
/** Popover open — keeps the focus ring while focus is in the portal. */
active?: boolean;
locked?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
// ref injected into this element; without it the picker renders at (0,0).
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
function FieldShell(
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
ref,
) {
return (
<div
ref={ref}
className={clsx(
classes.fieldShell,
cursor === "text" && classes.fieldShellText,
cursor === "pointer" && classes.fieldShellPointer,
active && classes.fieldShellActive,
locked && classes.fieldShellLocked,
alignTop && classes.fieldShellTop,
className,
)}
{...rest}
>
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
{children}
</div>
);
},
);
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
const checked = value === true;
return (
<FieldShell>
<Checkbox
size="sm"
checked={checked}
disabled={readOnly}
onChange={() => onChange(!checked)}
/>
</FieldShell>
);
}
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
const CellComponent = getDescriptor(property.type)?.cellComponent;
return (
<FieldShell locked>
<div className={classes.fieldCellDisplay}>
{CellComponent && (
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={false}
readOnly
onCommit={() => {}}
onValueChange={() => {}}
onCancel={() => {}}
/>
)}
</div>
</FieldShell>
);
}
type DetailFieldProps = {
property: IBaseProperty;
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
: (row.cells ?? {})[property.id];
const fieldProps: FieldProps = {
property,
value,
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
};
switch (property.type) {
case "text":
case "url":
case "email":
return <FieldText {...fieldProps} />;
case "longText":
return <FieldLongText {...fieldProps} />;
case "number":
return <FieldNumber {...fieldProps} />;
case "checkbox":
return <FieldCheckbox {...fieldProps} />;
case "date":
return <FieldDate {...fieldProps} />;
case "select":
case "status":
case "multiSelect":
return <FieldChoice {...fieldProps} />;
case "person":
case "file":
case "page":
return <FieldCellAdapter {...fieldProps} />;
default:
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
return <FieldReadonlyCell {...fieldProps} />;
}
}
@@ -0,0 +1,80 @@
import { useCallback, useRef, useState } from "react";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
/** Person, file and page editors are popover pickers owned by their cell
* components; the shell supplies modal styling and click-anywhere
* activation while the cell keeps its picker behavior. */
export function FieldCellAdapter({
property,
value,
rowId,
readOnly,
onChange,
}: FieldProps) {
const [editing, setEditing] = useState(false);
// Whether the picker was open when the current gesture's mousedown fired.
const editingAtMouseDownRef = useRef(false);
const CellComponent = getDescriptor(property.type)?.cellComponent;
// Files stay openable read-only (download-only popover), matching the grid.
const canActivate = !readOnly || property.type === "file";
// Activate on click, not mousedown: opening on mousedown mounts the cell's
// picker mid-dispatch, and its document-level outside-mousedown listener
// then catches the same still-bubbling event and instantly closes it. By
// click time the mousedown has fully finished. The ref keeps toggle-close
// working: when the gesture started with the picker open, the picker's own
// outside-close already handled it and we must not reopen.
const handleMouseDown = useCallback(() => {
editingAtMouseDownRef.current = editing;
}, [editing]);
const handleClick = useCallback(() => {
if (!canActivate || editingAtMouseDownRef.current || editing) return;
setEditing(true);
}, [canActivate, editing]);
const handleCommit = useCallback(
(next: unknown) => {
setEditing(false);
onChange(next);
},
[onChange],
);
const handleCancel = useCallback(() => setEditing(false), []);
if (!CellComponent) return <FieldShell />;
return (
<FieldShell
cursor={canActivate ? "pointer" : "default"}
active={editing}
onMouseDown={handleMouseDown}
onClick={handleClick}
role={canActivate ? "button" : undefined}
tabIndex={canActivate ? 0 : undefined}
aria-label={property.name}
onKeyDown={(e) => {
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setEditing(true);
}
}}
>
<div className={classes.fieldCellDisplay}>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={editing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={onChange}
onCancel={handleCancel}
/>
</div>
</FieldShell>
);
}
@@ -0,0 +1,104 @@
import { useCallback, useState } from "react";
import { Popover } from "@mantine/core";
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const multiple = property.type === "multiSelect";
const choices =
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = multiple
? Array.isArray(value)
? (value as string[])
: []
: typeof value === "string"
? [value]
: [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
if (multiple) {
const next = selectedIds.includes(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onChange(next.length > 0 ? next : null);
} else {
onChange(choice.id === selectedIds[0] ? null : choice.id);
setOpened(false);
}
},
[multiple, selectedIds, onChange],
);
const chips = selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
if (readOnly) {
return (
<FieldShell>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="target"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape={false}
hideDetached={false}
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p={4}>
{opened && (
<ChoicePicker
property={property}
selectedIds={selectedIds}
multiple={multiple}
grouped={property.type === "status"}
allowCreate={property.type !== "status"}
onToggle={handleToggle}
onEscape={() => setOpened(false)}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,76 @@
import { useState } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { DateTypeOptions } from "@/ee/base/types/base.types";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const display = formatDateDisplay(dateStr, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="auto"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p="xs">
<DatePicker
value={toISODateString(dateStr)}
onChange={(selected) => {
onChange(selected ? new Date(selected).toISOString() : null);
setOpened(false);
}}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { Textarea } from "@mantine/core";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
};
if (readOnly) {
return (
<FieldShell alignTop>
<span className={classes.fieldValueTextMultiline}>{text}</span>
</FieldShell>
);
}
return (
<FieldShell cursor="text" alignTop>
<Textarea
autosize
minRows={3}
maxRows={16}
maxLength={25000}
variant="unstyled"
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import {
formatNumber,
parseNumberDraft,
sanitizeNumberInput,
} from "@/ee/base/components/cells/cell-number";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(toDraft(value));
}, [value, focused]);
const formatted = formatNumber(numValue, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{formatted}</span>
</FieldShell>
);
}
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
return;
}
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
return (
<FieldShell cursor="text">
<input
type="text"
inputMode="decimal"
className={classes.fieldInput}
value={focused ? draft : formatted}
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
}}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onPaste={(e) => {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? draft.length;
const end = el.selectionEnd ?? draft.length;
setDraft(
draft.slice(0, start) +
sanitizeNumberInput(e.clipboardData.getData("text")) +
draft.slice(end),
);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}

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