Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4132dd597c | |||
| 8a2c457ab9 | |||
| 6ff7f69137 | |||
| 481aae5507 | |||
| 12d68765d9 | |||
| 27a80b57f8 | |||
| 15bdf680d9 | |||
| 96faff7175 | |||
| 2959504470 | |||
| 37a88d171a | |||
| c86d556888 | |||
| e8efef235c | |||
| 04110884b4 | |||
| b88833277d | |||
| 186a0bf71b | |||
| 303b6a048f | |||
| 4aa4feb41f | |||
| 4cf47f4538 | |||
| ccf61f3f84 | |||
| d469558c1e | |||
| fca5619eb7 | |||
| b7f1dd3a6b | |||
| 5658d30484 | |||
| 89f13c3fbc | |||
| e0fdc0c178 | |||
| e44ce2714f | |||
| d99d321e4a | |||
| 4f202e4ab5 | |||
| d7fdcdec80 | |||
| 2dc35fa100 | |||
| 4f2fbf22d4 | |||
| d68e241f45 | |||
| 510199cf04 | |||
| aa5d52ad3e | |||
| 1867aa8bf6 | |||
| 2ff8720832 | |||
| 6191acfa14 | |||
| d86d51c27e | |||
| ef04c22aea | |||
| b6760c63c4 | |||
| 2b68879e72 | |||
| db32910634 | |||
| 33895b0607 | |||
| 830b5b4d45 | |||
| d7c4f0551e | |||
| 61a91cd086 | |||
| f010f6a83a | |||
| 13a7f1372f | |||
| 4295ea09f6 | |||
| ed0501a864 | |||
| aa0c37bd68 | |||
| a5858bc470 |
+5
-5
@@ -1,6 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
dist
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/dist
|
||||
/data
|
||||
.env*
|
||||
.nx
|
||||
**/.env*
|
||||
**/.nx
|
||||
|
||||
+6
-1
@@ -10,7 +10,7 @@ JWT_TOKEN_EXPIRES_IN=30d
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# options: local | s3
|
||||
# options: local | s3 | azure
|
||||
STORAGE_DRIVER=local
|
||||
|
||||
# S3 driver config
|
||||
@@ -21,6 +21,11 @@ AWS_S3_BUCKET=
|
||||
AWS_S3_ENDPOINT=
|
||||
AWS_S3_FORCE_PATH_STYLE=
|
||||
|
||||
# Azure Blob Storage driver config
|
||||
AZURE_STORAGE_ACCOUNT_NAME=
|
||||
AZURE_STORAGE_ACCOUNT_KEY=
|
||||
AZURE_STORAGE_CONTAINER=
|
||||
|
||||
# default: 50mb
|
||||
FILE_UPLOAD_SIZE_LIMIT=
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
.env.prod
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
|
||||
@@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
|
||||
# Copy packages
|
||||
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
|
||||
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
|
||||
+21
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.90.0",
|
||||
"version": "0.95.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -18,21 +18,23 @@
|
||||
"@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",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
@@ -41,24 +43,25 @@
|
||||
"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.5",
|
||||
"js-cookie": "3.0.7",
|
||||
"jwt-decode": "4.0.0",
|
||||
"katex": "0.16.40",
|
||||
"lowlight": "3.3.0",
|
||||
"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",
|
||||
@@ -424,6 +446,7 @@
|
||||
"Names do not match": "Names do not match",
|
||||
"Today, {{time}}": "Today, {{time}}",
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Space created successfully",
|
||||
"Space updated successfully": "Space updated successfully",
|
||||
"Space deleted successfully": "Space deleted successfully",
|
||||
@@ -596,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",
|
||||
@@ -965,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",
|
||||
@@ -977,7 +1004,7 @@
|
||||
"Search pages and spaces...": "Search pages and spaces...",
|
||||
"No results found": "No results found",
|
||||
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
||||
"Chat menu": "Chat menu",
|
||||
"Chat menu for {{title}}": "Chat menu for {{title}}",
|
||||
"API key menu": "API key menu",
|
||||
"Jump to comment selection": "Jump to comment selection",
|
||||
"Slash commands": "Slash commands",
|
||||
@@ -1063,7 +1090,7 @@
|
||||
"Filter": "Filter",
|
||||
"Page title": "Page title",
|
||||
"Page content": "Page content",
|
||||
"Member actions": "Member actions",
|
||||
"Member actions for {{name}}": "Member actions for {{name}}",
|
||||
"Toggle password visibility": "Toggle password visibility",
|
||||
"Send comment": "Send comment",
|
||||
"Token actions": "Token actions",
|
||||
@@ -1084,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}}": "Aujourd’hui, {{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 l’espace de travail peuvent créer des modèles globaux.",
|
||||
"You need edit access to this space to create templates.": "Vous devez disposer d’un 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 n’avez pas l’autorisation 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}} 项"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,13 +6,21 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
exportPage,
|
||||
exportPageToDocx,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { exportSpace } from "@/features/space/services/space-service";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||
|
||||
interface ExportModalProps {
|
||||
id: string;
|
||||
@@ -32,17 +40,25 @@ export default function ExportModal({
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDocx = format === ExportFormat.Docx;
|
||||
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||
const blockedByLicense = isDocx && !docxEntitled;
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (type === "page") {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
if (format === ExportFormat.Docx) {
|
||||
await exportPageToDocx({ pageId: id });
|
||||
} else {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
@@ -88,10 +104,15 @@ export default function ExportModal({
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
<ExportFormatSelection
|
||||
format={format}
|
||||
onChange={handleChange}
|
||||
includeDocx={type === "page"}
|
||||
docxEntitled={docxEntitled}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === "page" && (
|
||||
{type === "page" && !isDocx && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
@@ -143,7 +164,16 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
disabled={blockedByLicense}
|
||||
data-disabled={blockedByLicense || undefined}
|
||||
>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
@@ -154,23 +184,49 @@ export default function ExportModal({
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
includeDocx?: boolean;
|
||||
docxEntitled?: boolean;
|
||||
}
|
||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||
function ExportFormatSelection({
|
||||
format,
|
||||
onChange,
|
||||
includeDocx,
|
||||
docxEntitled,
|
||||
}: ExportFormatSelection) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data = [
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
...(includeDocx
|
||||
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
data={data}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||
comboboxProps={{ width: 200 }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label={t("Select export format")}
|
||||
renderOption={({ option }) =>
|
||||
option.value === "docx" && !docxEntitled ? (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{option.label}
|
||||
</Text>
|
||||
<Badge size="xs" mt={4}>
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm">{option.label}</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||
{t("Favorite spaces appear here")}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,10 @@ interface CustomAvatarProps {
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
|
||||
// - filled: white text on the shade as bg
|
||||
// - light: shade as text on the color's light-bg (10% color.6 over white)
|
||||
// Avoids lime/yellow/green/orange — even their dark shades have weak
|
||||
// contrast. grape and indigo were bumped from .7 to darker shades because
|
||||
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
|
||||
// indigo.7 was 4.98/4.39 (light fails by a hair).
|
||||
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
||||
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
||||
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
||||
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||
"blue.8",
|
||||
"cyan.9",
|
||||
@@ -54,12 +51,24 @@ function sanitizeInitialsSource(name: string) {
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const resolvedColor =
|
||||
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||
const isInitials = !color || color === "initials";
|
||||
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-${hue}-9)`,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
ref={ref}
|
||||
@@ -67,6 +76,8 @@ export const CustomAvatar = React.forwardRef<
|
||||
name={initialsSource}
|
||||
alt={name}
|
||||
color={resolvedColor}
|
||||
variant={variant}
|
||||
styles={placeholderStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Group } from "@mantine/core";
|
||||
import { Modal, Button, Group, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
@@ -52,7 +52,9 @@ export function DestinationPickerModal({
|
||||
searchSpacesOnly={searchSpacesOnly}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Divider my="md" />
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
|
||||
@@ -89,14 +89,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.selectedIndicator {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -221,14 +221,6 @@ export function DestinationPicker({
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{selection && (
|
||||
<div className={classes.selectedIndicator}>
|
||||
{selection.type === "space"
|
||||
? selection.space.name
|
||||
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,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>
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
|
||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
||||
export const RadioMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<"button">
|
||||
>((props, ref) => (
|
||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
||||
));
|
||||
|
||||
RadioMenuItem.displayName = "RadioMenuItem";
|
||||
@@ -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.
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function AiChatSidebarItem({
|
||||
[chat.updatedAt, i18n.language],
|
||||
);
|
||||
|
||||
const chatTitle = chat.title || t("Untitled chat");
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
// Wait for the input to be mounted before selecting.
|
||||
@@ -120,9 +122,7 @@ export default function AiChatSidebarItem({
|
||||
className={classes.chatItem}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<span className={classes.chatItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</span>
|
||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||
<div className={classes.chatItemActions}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
|
||||
size="xs"
|
||||
color="gray"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
aria-label={t("Chat menu")}
|
||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useEffect, useState } from "react";
|
||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||
import { Popover } from "@mantine/core";
|
||||
@@ -107,6 +107,7 @@ export default function ChatInput({
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||
const plusMenuId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
@@ -176,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);
|
||||
@@ -263,7 +264,7 @@ export default function ChatInput({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && autofocus) {
|
||||
if (editor && !editor.isDestroyed && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
@@ -342,6 +343,7 @@ export default function ChatInput({
|
||||
position="top-start"
|
||||
width={220}
|
||||
shadow="md"
|
||||
withRoles={false}
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
@@ -351,13 +353,17 @@ export default function ChatInput({
|
||||
className={classes.plusButton}
|
||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||
aria-label="Add content"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={plusMenuOpen}
|
||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -377,6 +383,7 @@ export default function ChatInput({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
@@ -385,7 +392,7 @@ export default function ChatInput({
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
Mention a page
|
||||
{t("Mention a page")}
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -17,14 +17,26 @@ import ChatToolGroup from "./chat-tool-group";
|
||||
import classes from "../styles/chat-message.module.css";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
|
||||
const PAGE_PATH_RE = /\/s\/[^/?#]+\/p\/[^/?#]+/;
|
||||
|
||||
const chatSanitizer = DOMPurify();
|
||||
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
const href = node.getAttribute("href") || "";
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
if (node.tagName !== "A") return;
|
||||
const href = node.getAttribute("href") || "";
|
||||
|
||||
// Recover the canonical /s/{slug}/p/{slugId} path if the model wrapped it
|
||||
// in a fabricated host (https://s/..., https://yoursite.com/s/..., //s/...).
|
||||
const m = href.match(PAGE_PATH_RE);
|
||||
if (m) {
|
||||
node.setAttribute("href", m[0]);
|
||||
node.removeAttribute("target");
|
||||
node.removeAttribute("rel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith("http://") || href.startsWith("https://")) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||
}
|
||||
|
||||
/* Empty state - Notion AI style centered layout */
|
||||
.emptyState {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
|
||||
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IApiKey } from "@/ee/api-key";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import React from "react";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
|
||||
interface ApiKeyTableProps {
|
||||
apiKeys: IApiKey[];
|
||||
@@ -23,10 +23,11 @@ export function ApiKeyTable({
|
||||
onRevoke,
|
||||
}: ApiKeyTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return t("Never");
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateApiKeyModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [expirationOption, setExpirationOption] = useState<string>("30");
|
||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
|
||||
const getExpirationLabel = (days: number) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
const formatted = date.toLocaleDateString("en-US", {
|
||||
const formatted = date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user