Compare commits

..

77 Commits

Author SHA1 Message Date
Philipinho efcca2e4fd sync 2026-07-03 21:20:31 +01:00
Philipinho 3b57ce7c89 fix: comment error boundary 2026-07-03 21:12:09 +01:00
Philipinho 86687d2bf2 add image and table node to comment editor
* fix react error
2026-07-03 20:10:07 +01:00
Philipinho 15f893184e Merge branch 'main' into confluence-importer 2026-07-03 16:21:19 +01:00
Philip Okugbe 4132dd597c New Crowdin updates (#2323) 2026-07-03 11:57:07 +01:00
Philip Okugbe 8a2c457ab9 feat: kanban group editing (#2322)
* feat: support opening property menu directly on options panel

* refactor: expose group-by property from useKanbanColumns

* feat: rename kanban group by clicking column name

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

* fix: license-gate base and kanban inserts

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

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

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

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

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

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

* update offerings

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

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

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

* fix refetch

* fix focus

* fix spacing

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix grid cells on mobile

* sync

* fix: read-only export

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

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

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

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

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

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

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

* chore(base): seed choice ids with nanoid

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

* sync

* sync

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

* pref

* feat: on-demand only

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

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
Philipinho b0f7c11fb3 sync 2026-06-16 00:31:02 +01:00
Philipinho feb5167771 feat(confluence-import): hide API importer behind BETA_CONFLUENCE_IMPORTER flag 2026-06-15 21:43:38 +01:00
Philipinho 97a1248818 feat(confluence-import): gate API importer UI to enterprise entitlement 2026-06-12 13:16:47 +01:00
Philipinho b7d7ad4521 feat(licence): add confluence API import feature key 2026-06-12 13:12:42 +01:00
Philipinho 79df1229cb feat: import page permissions 2026-06-12 12:59:31 +01:00
Philip Okugbe 6191acfa14 fix: a11y (#2275) 2026-06-09 22:51:55 +01:00
Peter Tripp d86d51c27e fix: Table jitter on edit/read toggle (#2252) 2026-06-03 11:31:45 +01:00
Philipinho ef04c22aea sync 2026-05-28 16:57:59 +01:00
Philipinho b6760c63c4 fix: package updates 2026-05-28 16:39:47 +01:00
Philipinho 2b68879e72 0.90.1 2026-05-28 16:36:18 +01:00
Philipinho db32910634 fix; change inline code text color 2026-05-28 16:35:37 +01:00
Philip Okugbe 33895b0607 bug fixes (#2250)
* util

* fix page position collation

* support fixed toolbar in templates editor

* date localization

* fix clipped emoji in templates editor

* fix page updated time object

* fix flickers

* fix: remove redundant breadcrumb from destination modal
2026-05-28 16:20:37 +01:00
Philipinho 830b5b4d45 fix synced block 2026-05-25 19:17:14 +01:00
Philipinho 812d0765b5 types 2026-05-18 16:58:52 +01:00
Philipinho 234bd13453 fix(auth): clear page indexeddb databases on logout 2026-05-18 01:59:43 +01:00
Philipinho affd2e6740 feat(comment): enable text color in comments 2026-05-18 00:14:59 +01:00
Philipinho 37e652ade5 chore: bump ee submodule 2026-05-18 00:14:56 +01:00
Philipinho 38a847f305 fix(confluence-api-import): rename list endpoint to history 2026-05-18 00:14:53 +01:00
Philipinho 7c80688698 feat(confluence-api-import): show user count in import history 2026-05-18 00:14:46 +01:00
Philipinho 0320e07bb1 feat(confluence-api-import): add import mappings type 2026-05-18 00:14:44 +01:00
Philipinho 1982a0ed1e feat: encryption module 2026-05-16 23:40:43 +01:00
Philipinho fcb2b08cf3 docs(import): trim auto-embed comment 2026-05-16 21:53:25 +01:00
Philipinho e5ff58a73a fix(import): only auto-embed links when they're the sole content of their block 2026-05-16 21:00:41 +01:00
Philipinho d3bb105366 feat(import): add deterministic anchor nodeId helper 2026-05-16 18:24:44 +01:00
Philipinho bbb1b5eb26 feat(import): add Confluence emoji-id parser with tests 2026-05-16 01:28:10 +01:00
Philipinho 3e66aff1e2 feat(import): extract Confluence indent helper with auto-detect unit and tests 2026-05-16 01:21:14 +01:00
Philipinho 89442f5154 feat(import): add shared Confluence margin-left indent normalizer 2026-05-16 01:10:28 +01:00
Philipinho 12ef426541 Merge branch 'main' into confluence 2026-05-15 13:26:33 +01:00
Philipinho 93ff3f43b7 fix badge 2026-04-22 13:54:05 +01:00
Philipinho cbf03fc956 fix 2026-04-22 12:58:19 +01:00
Philipinho 68309247b5 share html normalization between zip and single-file imports 2026-04-22 01:36:14 +01:00
Philipinho 474ff6c629 normalize column widths during zip import html formatting 2026-04-22 00:57:00 +01:00
Philipinho c5e9be6b36 add shared table column width normalizer for imports 2026-04-22 00:51:42 +01:00
Philipinho 00e8c53852 types 2026-04-21 15:44:14 +01:00
538 changed files with 24129 additions and 27289 deletions
File diff suppressed because it is too large Load Diff
@@ -1,309 +0,0 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -1,479 +0,0 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 510MB per-origin cap, but practically negligible. No cleanup logic needed.
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
1. "Save for everyone" (current behavior)
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
## Future extension
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules
.git
dist
**/node_modules
**/.git
**/dist
/data
.env*
.nx
**/.env*
**/.nx
+1 -1
View File
@@ -3,7 +3,7 @@
.env.prod
data
# compiled output
/dist
dist
/node_modules
# Logs
+2
View File
@@ -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
+19 -18
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.90.0",
"version": "0.95.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -21,20 +21,20 @@
"@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-table": "8.21.3",
"@tanstack/react-virtual": "3.13.24",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
@@ -43,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"
@@ -75,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",
@@ -91,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"
}
}
+207 -20
View File
@@ -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",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
@@ -22,6 +22,7 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Cancelled": "Cancelled",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -29,7 +30,9 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Completed": "Completed",
"Confirm": "Confirm",
"Confluence site": "Confluence site",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -41,6 +44,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",
@@ -55,6 +60,10 @@
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Everyone with access to this space": "Everyone with access to this space",
"Failed": "Failed",
"Import details": "Import details",
"Permissions": "Permissions",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
@@ -74,8 +83,27 @@
"Failed to restore page": "Failed to restore page",
"Failed to fetch recent pages": "Failed to fetch recent pages",
"Failed to import pages": "Failed to import pages",
"Failed to load comments. An error occurred.": "Failed to load comments. An error occurred.",
"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",
@@ -130,6 +158,9 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"Restricted pages": "Restricted pages",
"Restrictions": "Restrictions",
"Running": "Running",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -166,6 +197,7 @@
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Site": "Site",
"Slug": "Slug",
"Space": "Space",
"Space description": "Space description",
@@ -174,10 +206,13 @@
"Space settings": "Space settings",
"Space slug": "Space slug",
"Spaces": "Spaces",
"spaces": "spaces",
"Spaces you belong to": "Spaces you belong to",
"No space found": "No space found",
"Search for spaces": "Search for spaces",
"Start typing to search...": "Start typing to search...",
"Started at": "Started at",
"Started by": "Started by",
"Status": "Status",
"Successfully imported": "Successfully imported",
"Successfully restored": "Successfully restored",
@@ -191,6 +226,8 @@
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
"User": "User",
"Users": "Users",
"users": "users",
"Workspace": "Workspace",
"Workspace Name": "Workspace Name",
"Workspace settings": "Workspace settings",
@@ -398,6 +435,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 +463,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 +636,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 +1007,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 +1021,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 +1107,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",
@@ -1085,6 +1129,12 @@
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{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.",
@@ -1102,5 +1152,159 @@
"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."
"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"
}
+207 -20
View File
@@ -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",
@@ -1085,22 +1112,182 @@
"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}}",
"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."
"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"
}
+207 -20
View File
@@ -41,6 +41,8 @@
"Dark": "Sombre",
"Date": "Date",
"Delete": "Supprimer",
"Remove from page": "Supprimer de la page",
"Base options": "Options de la base",
"Delete group": "Supprimer groupe",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Êtes-vous sûr de vouloir supprimer cette page ? Cela supprimera ses enfants et l'historique de la page. Cette action est irréversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"Failed to import pages": "Échec de l'importation des pages",
"Failed to load page. An error occurred.": "Échec du chargement de la page. Une erreur s'est produite.",
"Failed to update data": "Échec de la mise à jour des données",
"Failed to create base": "Échec de la création de la base",
"Failed to update base": "Échec de la mise à jour de la base",
"Failed to delete base": "Échec de la suppression de la base",
"Failed to create property": "Échec de la création de la propriété",
"Failed to update property": "Échec de la mise à jour de la propriété",
"Failed to delete property": "Échec de la suppression de la propriété",
"Failed to reorder property": "Échec de la réorganisation de la propriété",
"Failed to create view": "Échec de la création de la vue",
"Failed to update view": "Échec de la mise à jour de la vue",
"Failed to delete view": "Échec de la suppression de la vue",
"Failed to create row": "Échec de la création de la ligne",
"Failed to update row": "Échec de la mise à jour de la ligne",
"Failed to delete row": "Échec de la suppression de la ligne",
"Failed to delete rows": "Échec de la suppression des lignes",
"Failed to reorder row": "Échec de la réorganisation de la ligne",
"Failed to move card": "Échec du déplacement de la carte",
"Failed to add card": "Échec de l'ajout de la carte",
"Failed to export CSV": "Échec de l'exportation du CSV",
"Favorite spaces": "Espaces favoris",
"Favorite spaces appear here": "Les espaces favoris apparaissent ici",
"Favorites": "Favoris",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insérer un diagramme Mermaid",
"Insert and design Drawio diagrams": "Insérer et concevoir des diagrammes Drawio",
"Insert current date": "Insérer la date actuelle",
"Time": "Heure",
"Insert current time": "Insérer l'heure actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple",
"Turn into": "Transformer en",
@@ -424,6 +446,7 @@
"Names do not match": "Les noms ne correspondent pas",
"Today, {{time}}": "Aujourdhui, {{time}}",
"Yesterday, {{time}}": "Hier, {{time}}",
"now": "maintenant",
"Space created successfully": "Espace créé avec succès",
"Space updated successfully": "Espace mis à jour avec succès",
"Space deleted successfully": "Espace supprimé avec succès",
@@ -596,6 +619,8 @@
"Deleted by": "Supprimé par",
"Deleted at": "Supprimé le",
"Preview": "Aperçu",
"Base preview unavailable": "Aperçu de la base indisponible",
"Restore this base to view its contents.": "Restaurez cette base pour afficher son contenu.",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"No subpages": "Aucune sous-page",
@@ -965,6 +990,8 @@
"Are you sure you want to delete this template?": "Êtes-vous sûr de vouloir supprimer ce modèle ?",
"Template scope updated": "Portée du modèle mise à jour",
"Choose which space this template belongs to": "Choisissez à quel espace appartient ce modèle",
"Only workspace admins can create global templates.": "Seuls les administrateurs de lespace de travail peuvent créer des modèles globaux.",
"You need edit access to this space to create templates.": "Vous devez disposer dun accès en modification à cet espace pour créer des modèles.",
"Scope": "Portée",
"Select scope": "Sélectionner la portée",
"Title": "Titre",
@@ -977,7 +1004,7 @@
"Search pages and spaces...": "Rechercher des pages et des espaces...",
"No results found": "Aucun résultat trouvé",
"You don't have permission to create pages here": "Vous navez pas lautorisation de créer des pages ici",
"Chat menu": "Menu du chat",
"Chat menu for {{title}}": "Menu de discussion pour {{title}}",
"API key menu": "Menu de la clé API",
"Jump to comment selection": "Aller à la sélection de commentaires",
"Slash commands": "Commandes slash",
@@ -1063,7 +1090,7 @@
"Filter": "Filtrer",
"Page title": "Titre de la page",
"Page content": "Contenu de la page",
"Member actions": "Actions des membres",
"Member actions for {{name}}": "Actions du membre pour {{name}}",
"Toggle password visibility": "Afficher/masquer le mot de passe",
"Send comment": "Envoyer le commentaire",
"Token actions": "Actions du jeton",
@@ -1085,22 +1112,182 @@
"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}}",
"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."
"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"
}
+207 -20
View File
@@ -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",
@@ -1085,22 +1112,182 @@
"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}}",
"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."
"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"
}
+207 -20
View File
@@ -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": "トークンの操作",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
+207 -20
View File
@@ -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": "토큰 작업",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
+207 -20
View File
@@ -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",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
+207 -20
View File
@@ -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",
@@ -1085,22 +1112,182 @@
"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}}",
"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."
"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"
}
+207 -20
View File
@@ -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": "Действия с токеном",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
+207 -20
View File
@@ -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": "Дії з токеном",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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"
}
+207 -20
View File
@@ -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": "令牌操作",
@@ -1085,22 +1112,182 @@
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"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}} 项"
}
+6 -1
View File
@@ -38,7 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/pages/base/base-page.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";
@@ -47,6 +47,7 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page";
import ConfluenceImportPage from "@/ee/confluence-import/pages/confluence-import.tsx";
export default function App() {
const { t } = useTranslation();
@@ -128,6 +129,10 @@ export default function App() {
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
<Route
path={"import/confluence"}
element={<ConfluenceImportPage />}
/>
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</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>
@@ -1,6 +1,7 @@
import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { CommentErrorBoundary } from "@/features/comment/components/comment-error-boundary.tsx";
import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode, useEffect } from "react";
@@ -28,7 +29,11 @@ export default function Aside() {
switch (tab) {
case "comments":
component = <CommentListWithTabs />;
component = (
<CommentErrorBoundary>
<CommentListWithTabs />
</CommentErrorBoundary>
);
title = "Comments";
break;
case "toc":
@@ -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} />
</>
);
}
@@ -15,11 +15,12 @@ import {
IconSparkles,
IconHistory,
IconShieldCheck,
IconFileImport,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { isBetaConfluenceImporter, isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
@@ -51,6 +52,7 @@ type DataItem = {
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
show?: () => boolean;
};
type DataGroup = {
@@ -125,6 +127,14 @@ const groupedData: DataGroup[] = [
role: "owner",
env: "selfhosted",
},
{
label: "Import",
icon: IconFileImport,
path: "/settings/import/confluence",
feature: Feature.CONFLUENCE_API_IMPORT,
role: "admin",
show: () => isBetaConfluenceImporter(),
},
],
},
{
@@ -158,6 +168,7 @@ export default function SettingsSidebar() {
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.show && !item.show()) return false;
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
+21 -10
View File
@@ -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";
+39 -1
View File
@@ -1 +1,39 @@
Files in this directory are subject to the Docmost Enterprise Edition license.
Files in this directory are subject to the Docmost Enterprise Edition license.
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -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>
@@ -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"
@@ -1,13 +1,9 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell } from "@/features/base/types/base.types";
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
// Atoms are scoped per-base via `pageId` so that two BaseTable instances
// rendered on the same page (e.g. multiple base embeds inside one
// document) don't share UI state. A global atom would otherwise cause
// each instance's `useEffect` writers to clobber the other's value
// every render — pinning React into a "Maximum update depth exceeded"
// loop.
// 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),
@@ -17,6 +13,15 @@ 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),
);
@@ -36,3 +41,15 @@ export const selectedRowIdsAtomFamily = atomFamily((_pageId: 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,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 },
};
}
@@ -1,5 +1,5 @@
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
import { BaseViewDraft } from "@/ee/base/types/base.types";
export type ViewDraftKey = {
userId: string;
@@ -10,10 +10,9 @@ export type ViewDraftKey = {
export const viewDraftStorageKey = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`;
// `atomWithStorage` handles JSON serialization, cross-tab sync via the
// `storage` event, and lazy first-read out of the box. `atomFamily`'s
// comparator ensures the same triple resolves to the same atom instance
// across renders, so identity-equality cache hits in Jotai still work.
// 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),
@@ -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();
}
}}
/>
);
}
@@ -1,23 +1,18 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
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 per-cell widths so the skeleton doesn't flicker between
// renders. Values are rough normal distribution around 55-85 % of cell.
// 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 = {
// Override the rendered shape to match what the eventual content
// will be — the inline-embed placeholder passes rows=1, columns=3
// (matching the seeded Title + Text 1 + Text 2 with one default
// row) so the swap from skeleton to real table doesn't visibly
// collapse a large fake table down to a small empty one.
// Match the eventual content shape to avoid a jarring size jump on swap.
rows?: number;
columns?: number;
};
@@ -32,7 +27,7 @@ export function BaseTableSkeleton({
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className={classes.root}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
@@ -1,10 +1,10 @@
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { GridContainer } from "@/ee/base/components/grid/grid-container";
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
} from "@/ee/base/types/base.types";
type BaseTableProps = {
base: IBase;
@@ -13,11 +13,12 @@ type BaseTableProps = {
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
isFiltered: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
@@ -26,8 +27,8 @@ type BaseTableProps = {
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportEl: HTMLDivElement | null;
stickyBandPrelude?: React.ReactNode;
scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode;
};
export function BaseTable({
@@ -36,6 +37,7 @@ export function BaseTable({
table,
pageId,
embedded,
isFiltered,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
@@ -44,8 +46,8 @@ export function BaseTable({
onColumnReorder,
onResizeEnd,
onRowReorder,
scrollportEl,
stickyBandPrelude,
scrollportRef,
aboveBand,
}: BaseTableProps) {
return (
<GridContainer
@@ -54,14 +56,15 @@ export function BaseTable({
onCellUpdate={onCellUpdate}
onAddRow={onAddRow}
pageId={pageId}
isFiltered={isFiltered}
onColumnReorder={onColumnReorder}
onResizeEnd={onResizeEnd}
onRowReorder={onRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
scrollElement={embedded ? window : scrollportEl}
stickyBandPrelude={stickyBandPrelude ?? null}
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>
);
}
@@ -26,7 +26,7 @@ export function BaseViewDraftBanner({
{canSave && (
<Tooltip
label={t("Filter and sort changes are visible only to you")}
position="bottom"
position="top"
withArrow
>
<Button
@@ -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>;
}
@@ -4,8 +4,8 @@ import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
} from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
@@ -16,7 +16,7 @@ type CellDateProps = {
onCancel: () => void;
};
function formatDateDisplay(
export function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
@@ -106,13 +106,18 @@ export function CellDate({
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<div className={cellClasses.popoverTarget}>
<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>
);
}
@@ -6,8 +6,8 @@ import {
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
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";
@@ -16,12 +16,6 @@ export type FileValue = {
fileName: string;
mimeType?: string;
fileSize?: number;
// `/api/files/{id}/{fileName}` — same shape the editor's attachment
// node view uses. `getFileUrl` strips the `/api/` prefix and
// prepends the backend host to produce a fetchable URL. Stored on
// upload so the original filename round-trips even if the cell
// value is moved to a row where the file's storage path no longer
// resolves from the cell's pageId.
url?: string;
};
@@ -34,6 +28,7 @@ type CellFileProps = {
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
@@ -57,6 +52,7 @@ export function CellFile({
value,
property,
isEditing,
readOnly,
onCommit,
onCancel,
}: CellFileProps) {
@@ -66,10 +62,11 @@ export function CellFile({
const handleRemove = useCallback(
(fileId: string) => {
if (readOnly) return;
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[files, onCommit],
[readOnly, files, onCommit],
);
const handleUpload = useCallback(
@@ -79,12 +76,9 @@ export function CellFile({
const newFiles: FileValue[] = [...files];
// Reuse the page-attachment upload pipeline. A base IS a page
// (isBase=true) — the server's /files/upload endpoint accepts the
// base's pageId, runs the standard pageAccessService.validateCanEdit
// check (which lines up with Base edit at the space-role level per
// the casl rules), and stores the attachment via the same flow as
// any other page attachment.
// 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);
@@ -122,52 +116,37 @@ export function CellFile({
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<div className={cellClasses.popoverTarget}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{files.length === 0 && !uploading && (
{!readOnly && files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div
key={file.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 0",
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
<IconFile
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-gray-6)",
}}
/>
<div key={file.id} className={cellClasses.fileItemRow}>
<IconFile size={14} className={cellClasses.fileItemIcon} />
<a
href={getFileUrl(buildFileUrl(file))}
target="_blank"
rel="noreferrer"
style={{
flex: 1,
minWidth: 0,
textDecoration: "none",
color: "inherit",
}}
className={cellClasses.fileItemLink}
>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
@@ -178,46 +157,47 @@ export function CellFile({
</Text>
)}
</a>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
{!readOnly && (
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
)}
</div>
))}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
{!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}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 0",
marginTop: 4,
fontSize: "var(--mantine-font-size-xs)",
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
<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>
);
@@ -2,7 +2,7 @@ import { Badge, Tooltip } from "@mantine/core";
import {
IBaseProperty,
isFormulaErrorCell,
} from "@/features/base/types/base.types";
} from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
import { CellNumber } from "./cell-number";
import { CellCheckbox } from "./cell-checkbox";
@@ -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>
);
}
@@ -1,16 +1,24 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
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 "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
} 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",
@@ -27,6 +35,7 @@ type CellMultiSelectProps = {
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
@@ -34,7 +43,7 @@ export function CellMultiSelect({
value,
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
@@ -54,18 +63,20 @@ export function CellMultiSelect({
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices;
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];
onCommit(newIds);
onValueChange(newIds);
},
[selectedIds, selectedSet, onCommit],
[selectedIds, selectedSet, onValueChange],
);
const updatePropertyMutation = useUpdatePropertyMutation();
@@ -98,7 +109,7 @@ export function CellMultiSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -112,9 +123,9 @@ export function CellMultiSelect({
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit([...selectedIds, newChoice.id]);
onValueChange([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onCommit]);
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onValueChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -141,24 +152,50 @@ export function CellMultiSelect({
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
const MAX_VISIBLE = 3;
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 style={{ width: "100%", height: "100%" }}>
<BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />
<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"
@@ -167,35 +204,28 @@ export function CellMultiSelect({
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleToggle(choice)}
{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)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{choice.name}
</span>
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
@@ -204,9 +234,6 @@ export function CellMultiSelect({
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
@@ -228,33 +255,35 @@ export function CellMultiSelect({
return <span className={cellClasses.emptyValue} />;
}
return <BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />;
return (
<BadgeList
choices={selectedChoices}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function BadgeList({
choices,
maxVisible,
tooltipLabel,
}: {
choices: Choice[];
maxVisible: number;
tooltipLabel?: string;
}) {
const visible = choices.slice(0, maxVisible);
const overflow = choices.length - maxVisible;
const chips = choices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
return (
<div className={cellClasses.badgeGroup}>
{visible.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
<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>
);
}
@@ -1,17 +1,20 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text } from "@mantine/core";
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 "@/features/base/types/base.types";
import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query";
import { useBaseQuery } from "@/features/base/queries/base-query";
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 } from "@/features/page/page.utils";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/features/base/styles/cells.module.css";
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;
@@ -36,6 +39,22 @@ function parsePageId(value: unknown): string | null {
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,
@@ -46,9 +65,7 @@ export function CellPage({
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.pageId);
const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]);
const { pages } = useResolvedPages(ids);
const resolvedPage = pageId ? pages.get(pageId) : undefined;
const resolvedPage = useResolvePage(property.pageId, pageId);
if (isEditing) {
return (
@@ -67,8 +84,7 @@ export function CellPage({
}
if (resolvedPage === undefined) {
// Still resolving — render an empty pill-shaped placeholder to avoid
// the "Page not found" flicker on initial load.
// placeholder to avoid "Page not found" flicker on initial load
return <span className={cellClasses.emptyValue} />;
}
@@ -92,24 +108,27 @@ type PillPage = {
};
function PagePill({ page }: { page: PillPage }) {
const title = page.title || "Untitled";
const { t } = useTranslation();
const title = getPageTitle(page.title, undefined, t);
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<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 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>
);
}
@@ -128,6 +147,7 @@ function PagePicker({
onCommit,
onCancel,
}: PagePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
@@ -137,6 +157,12 @@ function PagePicker({
}, []);
const trimmed = debouncedSearch.trim();
const pastedSlugId = useMemo(
() => parsePastedPageSlugId(debouncedSearch),
[debouncedSearch],
);
const { data: suggestions = [] } = useQuery({
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
queryFn: async () => {
@@ -148,9 +174,26 @@ function PagePicker({
});
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]);
@@ -183,9 +226,21 @@ function PagePicker({
);
return (
<Popover opened onClose={onCancel} position="bottom-start" width={320} trapFocus>
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={320}
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<div className={cellClasses.popoverTarget}>
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
</div>
</Popover.Target>
@@ -196,10 +251,13 @@ function PagePicker({
{resolvedPage.icon ? (
<span>{resolvedPage.icon}</span>
) : (
<IconFileDescription size={14} />
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<span className={cellClasses.personTagName}>
{resolvedPage.title || "Untitled"}
{getPageTitle(resolvedPage.title, undefined, t)}
</span>
<button
type="button"
@@ -220,11 +278,18 @@ function PagePicker({
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"}
@@ -242,25 +307,31 @@ function PagePicker({
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription size={14} />
)}
<span className={cellClasses.personOptionName}>
{page.title || "Untitled"}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" ml="auto" truncate>
{page.space.name}
</Text>
<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>
@@ -1,15 +1,20 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
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 "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
} from "@/ee/base/types/base.types";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
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;
@@ -17,6 +22,7 @@ type CellPersonProps = {
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
@@ -25,10 +31,11 @@ export function CellPerson({
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
(property.typeOptions as PersonTypeOptions)?.allowMultiple === true;
const personIds = Array.isArray(value)
? (value as string[])
@@ -36,6 +43,8 @@ export function CellPerson({
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
@@ -46,37 +55,36 @@ export function CellPerson({
}
}, [isEditing]);
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const members = membersData?.items ?? [];
const memberMap = useMemo(() => {
const map = new Map<string, (typeof members)[0]>();
for (const m of members) map.set(m.id, m);
return map;
}, [members]);
const store = useReferenceStore(property.pageId);
const filteredMembers = search
? members.filter(
(m) =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.email && m.email.toLowerCase().includes(search.toLowerCase())),
)
: members;
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) {
// Multi mode: toggle add/remove
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit([...personIds, memberId]);
onValueChange([...personIds, memberId]);
}
} else {
// Single mode: replace or clear
if (personIds.includes(memberId)) {
onCommit(null);
} else {
@@ -84,19 +92,19 @@ export function CellPerson({
}
}
},
[allowMultiple, personIds, onCommit],
[suggestions, hydrateUsers, allowMultiple, personIds, onCommit, onValueChange],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit],
[allowMultiple, personIds, onCommit, onValueChange],
);
const handleKeyDown = useCallback(
@@ -121,27 +129,30 @@ export function CellPerson({
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
);
const selectedSet = new Set(personIds);
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 style={{ width: "100%", height: "100%" }}>
<PersonReadList personIds={personIds} memberMap={memberMap} />
<div className={cellClasses.popoverTarget}>
<PersonReadList personIds={personIds} users={store.users} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
{/* Tag input area */}
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = memberMap.get(id);
const member = store.users[id];
const name = member?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
@@ -172,10 +183,10 @@ export function CellPerson({
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
{/* Dropdown */}
<div className={cellClasses.personDropdownDivider} />
{allowMultiple && (
<div className={cellClasses.personDropdownHint}>
@@ -195,21 +206,24 @@ export function CellPerson({
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
);
})}
@@ -228,36 +242,6 @@ export function CellPerson({
return <span className={cellClasses.emptyValue} />;
}
return <PersonReadList personIds={personIds} memberMap={memberMap} />;
return <PersonReadList personIds={personIds} users={store.users} />;
}
function PersonReadList({
personIds,
memberMap,
}: {
personIds: string[];
memberMap: Map<
string,
{ id: string; name: string; email?: string; avatarUrl?: string }
>;
}) {
return (
<div className={cellClasses.personGroup}>
{personIds.map((id) => {
const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 8);
return (
<div key={id} className={cellClasses.personRow}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<span className={cellClasses.personName}>{name}</span>
</div>
);
})}
</div>
);
}
@@ -5,12 +5,13 @@ import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
} 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",
@@ -95,7 +96,7 @@ export function CellSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -142,13 +143,18 @@ export function CellSelect({
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
@@ -184,10 +190,6 @@ export function CellSelect({
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
@@ -207,9 +209,6 @@ export function CellSelect({
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
@@ -232,11 +231,9 @@ export function CellSelect({
}
return (
<span
className={cellClasses.badge}
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
/>
);
}
@@ -4,11 +4,12 @@ import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
} 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 "@/features/base/hooks/use-list-keyboard-nav";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
type CellStatusProps = {
value: unknown;
@@ -116,13 +117,18 @@ export function CellStatus({
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
@@ -164,10 +170,6 @@ export function CellStatus({
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
@@ -192,11 +194,9 @@ export function CellStatus({
}
return (
<span
className={cellClasses.badge}
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
/>
);
}
@@ -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>
);
}
@@ -2,18 +2,18 @@ import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
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: "#1971c2", textDark: "#74c0fc" },
cyan: { bg: "#c3fae8", bgDark: "#1a3a3a", text: "#0c8599", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#087f5b", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#2b8a3e", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#5c940d", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#e67700", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#d9480f", textDark: "#ffa94d" },
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 {
@@ -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(", ")}
/>
);
}
@@ -3,7 +3,6 @@ import {
Button,
Divider,
Group,
Kbd,
Paper,
Stack,
Text,
@@ -17,8 +16,9 @@ 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 "@/features/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/features/base/types/base.types";
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[];
@@ -55,9 +55,7 @@ export function FormulaEditor({
);
const canSave = parseState.state === "ok" && !disabled;
// After a palette insert mutates `source`, wait for React to flush the
// new value into the textarea, then focus + restore the cursor. Using
// useEffect (not RAF) guarantees the DOM update ran first.
// useEffect (not RAF) ensures the DOM update ran before restoring cursor.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
@@ -74,8 +72,6 @@ export function FormulaEditor({
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
// Add a space separator when inserting after content that would
// otherwise mash against the snippet (e.g. `2` + `prop("A")`).
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
@@ -95,34 +91,45 @@ export function FormulaEditor({
>
<Stack gap={0}>
<Group
gap={10}
justify="space-between"
wrap="nowrap"
px="md"
py={12}
style={{
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
className={classes.formulaHeaderRow}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 5,
display: "grid",
placeItems: "center",
background: "var(--mantine-color-blue-0)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconMathFunction size={14} />
</div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed">
· {name}
<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}>
@@ -176,44 +183,6 @@ export function FormulaEditor({
onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/>
</Stack>
<Group
justify="space-between"
px="md"
py={10}
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Group gap={6}>
<Kbd></Kbd>
<Kbd></Kbd>
<Text size="xs" c="dimmed">
to save
</Text>
</Group>
<Group gap={8}>
<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>
</Paper>
);
@@ -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();
}}
/>
);
}
@@ -7,6 +7,7 @@ import {
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;
@@ -63,23 +64,10 @@ export function FunctionPalette({
<Tooltip key={fn.name} label={fn.doc} withArrow>
<UnstyledButton
onClick={() => onInsert(fn.name)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 5,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 12.5,
color: "var(--mantine-color-blue-7)",
background: "var(--mantine-color-white)",
border: "1px solid var(--mantine-color-gray-3)",
cursor: "pointer",
}}
className={classes.fnChip}
>
{fn.name}
<span style={{ color: "var(--mantine-color-gray-5)" }}>
<span className={classes.fnChipParens}>
()
</span>
</UnstyledButton>
@@ -1,7 +1,8 @@
import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/features/base/types/base.types";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
export function PropertyChipRow({
properties,
@@ -43,19 +44,7 @@ export function PropertyChipRow({
<UnstyledButton
key={p.id}
onClick={() => onInsert(p.name)}
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 9px",
borderRadius: 6,
fontSize: 12.5,
fontWeight: 500,
lineHeight: 1.4,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
color: "var(--mantine-color-blue-7)",
cursor: "pointer",
}}
className={classes.propChip}
>
{p.name}
</UnstyledButton>
@@ -1,7 +1,7 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
import classes from "@/ee/base/styles/grid.module.css";
type AddRowButtonProps = {
onClick?: () => void;
@@ -16,6 +16,12 @@ export const AddRowButton = memo(function AddRowButton({
<div
className={classes.addRowButton}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role="button"
tabIndex={0}
>
@@ -1,5 +1,5 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import classes from "@/features/base/styles/grid.module.css";
import classes from "@/ee/base/styles/grid.module.css";
type Props = {
edge: Edge;
@@ -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>
))}
</>
);
}
@@ -16,53 +16,25 @@ import {
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 "@/features/base/types/base.types";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import {
activePropertyMenuAtomFamily,
propertyMenuDirtyAtomFamily,
propertyMenuCloseRequestAtomFamily,
editingCellAtomFamily,
} from "@/features/base/atoms/base-atoms";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
} from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
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 "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
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";
const COLUMN_DRAG_TYPE = "base-column";
const typeIcons: Record<string, typeof IconLetterT> = {
text: IconLetterT,
number: IconHash,
select: IconCircleDot,
status: IconProgressCheck,
multiSelect: IconTags,
date: IconCalendar,
person: IconUser,
file: IconPaperclip,
checkbox: IconCheckbox,
url: IconLink,
email: IconMail,
createdAt: IconClockPlus,
lastEditedAt: IconClockEdit,
lastEditedBy: IconUserEdit,
};
export const COLUMN_DRAG_TYPE = "base-column";
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
@@ -85,8 +57,12 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection(pageId);
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;
@@ -94,22 +70,27 @@ export const GridHeaderCell = memo(function GridHeaderCell({
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;
const isSortableDisabled = isRowNumber || !!isPinned || !editable;
// onColumnReorder ultimately depends on React Query result objects
// (activeView, base) via persistViewConfig their identity changes on
// every cache invalidation (i.e. 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.
// (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;
@@ -121,9 +102,11 @@ export const GridHeaderCell = memo(function GridHeaderCell({
return combine(
draggable({
element: el,
canDrag: () => !resizeIntentRef.current,
getInitialData: () => ({
type: COLUMN_DRAG_TYPE,
columnId: header.column.id,
pageId,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
@@ -164,45 +147,96 @@ export const GridHeaderCell = memo(function GridHeaderCell({
}, [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);
}
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
}, [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 (!menuOpened) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
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();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [menuOpened, propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose]);
},
[propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose],
);
const TypeIcon = property ? typeIcons[property.type] : undefined;
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 ? {} : { cursor: "pointer" }),
...(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 ? (
@@ -222,7 +256,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
)}
</div>
)}
{header.column.getCanResize() && (
{editable && header.column.getCanResize() && (
<div
className={`${classes.resizeHandle} ${
header.column.getIsResizing() ? classes.resizeHandleActive : ""
@@ -235,23 +269,30 @@ export const GridHeaderCell = memo(function GridHeaderCell({
e.stopPropagation();
header.getResizeHandler()(e);
}}
onPointerDown={(e) => e.stopPropagation()}
onPointerDown={(e) => {
resizeIntentRef.current = true;
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{property && !isRowNumber && (
{editable && property && !isRowNumber && (
<Popover
opened={menuOpened}
onChange={handleMenuOpenChange}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside={false}
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
@@ -263,11 +304,53 @@ export const GridHeaderCell = memo(function GridHeaderCell({
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>
);
});
@@ -1,9 +1,10 @@
import { memo, useMemo } from "react";
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
import classes from "@/features/base/styles/grid.module.css";
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>;
@@ -31,6 +32,7 @@ export const GridHeader = memo(function GridHeader({
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);
@@ -50,11 +52,13 @@ export const GridHeader = memo(function GridHeader({
onColumnReorder={onColumnReorder}
/>
))}
<CreatePropertyPopover
pageId={pageId}
properties={properties}
onPropertyCreated={onPropertyCreated}
/>
{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>
);
});
@@ -1,7 +1,7 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
@@ -42,6 +42,7 @@ export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
tabIndex={-1}
/>
</Tooltip>
</span>
@@ -2,9 +2,9 @@ import { memo } from "react";
import { Transition } from "@mantine/core";
import { IconTrash, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import classes from "@/features/base/styles/grid.module.css";
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;
@@ -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>
);
}

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