Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 237bbe1f6c | |||
| 6935a73e4f | |||
| 48ff82800b | |||
| bd194c8bd5 | |||
| f0b7bdef17 | |||
| ee3c5ce9d9 | |||
| d1ebeffe19 | |||
| 2acd55c38d | |||
| 4412009194 | |||
| abf75ec90b | |||
| dba47956b1 | |||
| c91f89a055 | |||
| 6cdceede0b | |||
| f5602e9bcf | |||
| 9237e94769 | |||
| e071de9248 | |||
| aebd54f13d | |||
| a7d390932d | |||
| beb7120b00 | |||
| 83d52fc324 | |||
| d97d8108d2 | |||
| bdfd0413b4 | |||
| a9c6051d12 | |||
| 76ca68bec6 | |||
| c350308c03 | |||
| f36df26d75 | |||
| ee4bf73c92 | |||
| 68cdcd970c | |||
| dfd6d3aee0 | |||
| ad0e65371b | |||
| fd6f6a9341 | |||
| a60de83e57 | |||
| f75779951e | |||
| 6b3babb3de | |||
| b8192e69d1 | |||
| 46c5960e99 | |||
| 38cdf1267a | |||
| 9cec9b64c6 | |||
| a793e65560 | |||
| b9ab95af4e | |||
| a60febc92f | |||
| 3c62331826 | |||
| b83b92bea6 | |||
| 651f799e3a | |||
| 5f845ab4c3 | |||
| d607e858d5 | |||
| 909b6a8f9c | |||
| 23ea060e54 | |||
| 73e626e3bc | |||
| c2dac69e70 | |||
| e1f862967a | |||
| a66d31178a | |||
| eeb84e97c9 | |||
| 9c124f8851 | |||
| c8ce98347e | |||
| 64a1e22cb4 | |||
| 218b5755a9 | |||
| 911bf53924 | |||
| 01ba1add5d | |||
| d6575a1cf8 | |||
| 5069573e8a | |||
| 0615bcf222 | |||
| 936c0de7fe | |||
| 0ec30ba804 | |||
| 38f7ffefe0 | |||
| 4b75383460 | |||
| 7dfb172d45 | |||
| 79394f93f5 | |||
| 0532253034 | |||
| 7e35936544 | |||
| edc7143f77 | |||
| dac65914ed | |||
| e9926d5cef | |||
| 4a25e787fa | |||
| 86809fc0dc | |||
| b411f52c18 | |||
| c8086b33e4 | |||
| bfa85b9835 | |||
| cd8d1e0ed8 | |||
| 1cfd0fb2c4 | |||
| fc13520322 | |||
| 3ad666adad | |||
| a645ab947b | |||
| 6a89212e32 | |||
| 701ae5c78d | |||
| 1de982c95b | |||
| 9eee7bc12c | |||
| d476d0b8ba | |||
| 361afc2426 | |||
| 144838aa89 | |||
| 00e9d1d724 | |||
| 583d2d37c4 | |||
| a992fa8a63 | |||
| 7a119010c7 | |||
| 678c6f5f3f | |||
| 030d3e878a | |||
| a7105267ed | |||
| 67f45ee61b | |||
| d73ac010af | |||
| 8132b171f4 | |||
| e0e87329f4 | |||
| 8aabf86abb | |||
| adae526627 | |||
| e66e18cd60 | |||
| 7f8ed733a3 | |||
| 3826e5a50d | |||
| 4a15c805f1 | |||
| 69e7bd73f2 | |||
| 19821d3aeb | |||
| 060bd1048f | |||
| f56af5e6b4 | |||
| 3e9f26a8dd | |||
| 4510543510 | |||
| 25dfb44774 | |||
| 78d450a238 | |||
| 2e7fe5bbb4 | |||
| 43cf1665f5 | |||
| fd6a208235 | |||
| a53dabae70 | |||
| 54de3a7791 | |||
| 16161d793b | |||
| 42f950e11d | |||
| fdb250bbe8 | |||
| 2b4a9b8a00 | |||
| a2917bad6d | |||
| ccdf2343f2 | |||
| cbed118c11 | |||
| 0f9dee4b28 | |||
| 0257f03003 | |||
| 731fa45672 | |||
| 2b05d1520b | |||
| 24a70e48da | |||
| a847c2121c | |||
| 137c02a10f | |||
| be79b7159c | |||
| 18222d1142 | |||
| 72fc93dcc1 | |||
| 89ee3714ac | |||
| b9d8bf948c | |||
| f7ea6e8fd3 | |||
| dcff8d3c53 | |||
| 2db43788d5 | |||
| 3bfdae7990 | |||
| 464bd701ba | |||
| 8c0071ee23 | |||
| 3962ccdc29 | |||
| 7a254a9412 | |||
| 9808791db4 | |||
| f99450f04a | |||
| 82705ce3bd | |||
| 3eb7c9b1d4 | |||
| 28fed815ba | |||
| 48d77a2b53 | |||
| 46f9292c05 | |||
| 230c4e35f0 | |||
| e729e77bda | |||
| 89e2d0d62f | |||
| fbee344e96 | |||
| 46386bf4e1 | |||
| 5b5c98daa8 | |||
| 2da8779b34 | |||
| 493613e634 | |||
| 5a82d660da | |||
| c67ae19c39 | |||
| ea0dc2b56b | |||
| 0174fada5f | |||
| 6669832e24 | |||
| 22716f3df9 | |||
| e9e903abe9 | |||
| 1b30de32b5 | |||
| ded855e44e | |||
| e445fc4fa9 | |||
| 77897733de | |||
| 216a4a99e1 | |||
| d8c96089b1 | |||
| dc825b0f62 | |||
| 7202e65a07 | |||
| 4c2d6772f1 | |||
| a65aec0925 | |||
| 99fd4a9503 | |||
| bf75cc9c74 | |||
| 5c8ce178e5 | |||
| cfb02766e2 | |||
| ae595c51ed | |||
| 6c44354403 | |||
| 184fa25d3e | |||
| 6740912adf | |||
| f524243da1 | |||
| d5093da863 | |||
| 196afc21d4 | |||
| 9b5e3783dd | |||
| 4ae941c5c4 | |||
| 8bfa0aaf7e | |||
| 58a47893a6 | |||
| 2d91817602 | |||
| 9ecf88511b | |||
| 30988c1959 | |||
| eb0f37bfe5 | |||
| 4c0348e46a | |||
| cac4774641 | |||
| c4d8b6c300 | |||
| 95d0457a7e | |||
| 83d28a8505 | |||
| f9bbbc7ebf | |||
| d9e2d7ba3d | |||
| 44ec2dbe88 | |||
| a6e9e66bbd | |||
| a9ea2a99b4 | |||
| 2f6bad141c | |||
| fd1257f61c | |||
| 321184394d | |||
| b01f6e9af9 | |||
| 93b1fc534b | |||
| 1aa92b1bb5 | |||
| d385099eb1 | |||
| d4fe0e0a69 | |||
| ab9b00f91c | |||
| 64dafe5ac0 | |||
| 097b1c76d4 | |||
| 2c1f66b603 | |||
| f812162a26 | |||
| b88c060df8 | |||
| 97cd88405d | |||
| 5de9a69130 | |||
| 83d55d9bd3 | |||
| 9c71a90637 | |||
| c6f993b610 | |||
| c331e0ffd3 | |||
| 53ee685874 | |||
| 082a32faa0 | |||
| 5c11e59128 | |||
| 5a4d10081d | |||
| 18668c7bcf | |||
| f119d728a8 | |||
| 66f9194e96 | |||
| 19b3f26cbb | |||
| 56c57afff3 | |||
| d84aadadbb | |||
| da0321b468 | |||
| db6f82ff7a | |||
| 207c74427d | |||
| c53d70b64e | |||
| 9a1cbc8ea9 | |||
| 8b343d25f0 | |||
| 2d47ffb25a | |||
| b6882d774b | |||
| 4dc6d32e49 | |||
| 8994575437 | |||
| 3f52e54207 | |||
| b6b6e1809a | |||
| d8adcd44c2 | |||
| 6a230b14ca | |||
| 05406640f0 | |||
| 4c4bbe9b15 | |||
| 3fca962c9f | |||
| fda163311a | |||
| 0d824dcd24 | |||
| 8d793ec26b | |||
| 0bbcc7ee30 | |||
| e017209d76 | |||
| fc734475df | |||
| a7f9d66778 | |||
| 4a9e891582 | |||
| 65c5bb11b8 | |||
| 1466d95078 | |||
| 901445305d | |||
| 5985238b4b | |||
| 10ee8d0c85 | |||
| d2f19b2aa0 | |||
| 493915a0c3 | |||
| da49ffc332 | |||
| b95f3033d1 | |||
| 88c906cdcd | |||
| 836a25cdbf | |||
| b02b2cd5d8 | |||
| bb398bb7d6 | |||
| 4cefa40f5b | |||
| 2ca27f16a1 | |||
| f8edb587e4 | |||
| 0f4a819ec5 | |||
| ede1a799f2 | |||
| 845b49968e | |||
| b244f831da | |||
| 2ececc8203 | |||
| 6d9107b727 | |||
| 5ae49cab49 | |||
| 89638fb11d | |||
| f5b19316af | |||
| 081bb67239 | |||
| eb0538b856 | |||
| 084746e65a | |||
| 4ff13cef62 | |||
| 2a6e604bf8 | |||
| 674b0ec64a | |||
| ac03a54ae6 | |||
| 2cf7958dac | |||
| 94ee1e80fb |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
# Base `page` Property Type — Design Spec
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Draft
|
||||
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
|
||||
|
||||
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
|
||||
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
|
||||
- **Creating pages from within the picker.**
|
||||
- **Cross-workspace page linking.**
|
||||
- **Rich previews / hover cards** showing page excerpts — pill-only.
|
||||
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
|
||||
|
||||
## UX overview
|
||||
|
||||
### Picker (edit mode)
|
||||
|
||||
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
|
||||
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
|
||||
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
|
||||
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
|
||||
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
|
||||
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
|
||||
- Esc / click-outside → `onCancel`.
|
||||
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
|
||||
- Keyboard: reuse `useListKeyboardNav`.
|
||||
|
||||
### View mode
|
||||
|
||||
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
|
||||
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
|
||||
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
|
||||
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
|
||||
|
||||
### Sort / filter UI
|
||||
|
||||
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
|
||||
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
|
||||
|
||||
## Data model
|
||||
|
||||
### Cell value
|
||||
|
||||
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
|
||||
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
|
||||
|
||||
### Property type options
|
||||
|
||||
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
|
||||
- **Future:** `{ allowMultiple?: boolean }`.
|
||||
|
||||
### Schema additions
|
||||
|
||||
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
|
||||
|
||||
```ts
|
||||
export const BasePropertyType = {
|
||||
// ...existing entries...
|
||||
PAGE: 'page',
|
||||
} as const;
|
||||
|
||||
// typeOptionsSchemaMap
|
||||
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||
|
||||
// cellValueSchemaMap
|
||||
[BasePropertyType.PAGE]: z.uuid(),
|
||||
```
|
||||
|
||||
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
|
||||
|
||||
```ts
|
||||
export type BasePropertyType = ... | 'page';
|
||||
export type PageTypeOptions = Record<string, never>;
|
||||
```
|
||||
|
||||
### Property kind & engine
|
||||
|
||||
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
|
||||
|
||||
```ts
|
||||
export const PropertyKind = {
|
||||
// ...existing...
|
||||
PAGE: 'page',
|
||||
} as const;
|
||||
|
||||
// propertyKind()
|
||||
case BasePropertyType.PAGE:
|
||||
return PropertyKind.PAGE;
|
||||
```
|
||||
|
||||
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
|
||||
|
||||
- `isEmpty` / `isNotEmpty` → `textCell` is null or empty
|
||||
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
|
||||
- `any` → `textCell IN (...)`
|
||||
- `none` → `textCell NOT IN (...)` or null
|
||||
|
||||
Wired into the `switch (kind)` in `buildCondition`:
|
||||
```ts
|
||||
case PropertyKind.PAGE:
|
||||
return pageCondition(eb, cond);
|
||||
```
|
||||
|
||||
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
|
||||
|
||||
### Type conversion
|
||||
|
||||
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
|
||||
|
||||
```ts
|
||||
export type CellConversionContext = {
|
||||
fromTypeOptions?: unknown;
|
||||
userNames?: Map<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>; // NEW
|
||||
};
|
||||
```
|
||||
|
||||
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
|
||||
|
||||
**`attemptCellConversion` branches:**
|
||||
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
|
||||
- `page → *` (anything else): return `{converted: true, value: null}`.
|
||||
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
|
||||
|
||||
## Server: page resolver endpoint
|
||||
|
||||
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
|
||||
|
||||
### `POST /bases/pages/resolve`
|
||||
|
||||
**Request:**
|
||||
```ts
|
||||
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```ts
|
||||
{
|
||||
items: Array<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string };
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
1. Deduplicate input IDs.
|
||||
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
|
||||
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
|
||||
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
|
||||
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
|
||||
|
||||
### Code layout
|
||||
|
||||
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
|
||||
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
|
||||
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
|
||||
|
||||
## Client: cell component & resolver
|
||||
|
||||
### Batch resolver hook
|
||||
|
||||
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
|
||||
|
||||
```ts
|
||||
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
|
||||
```
|
||||
|
||||
- Deduplicate + sort IDs to form a stable React Query key.
|
||||
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
|
||||
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
|
||||
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
|
||||
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
|
||||
|
||||
### Cell component
|
||||
|
||||
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
|
||||
|
||||
```ts
|
||||
type CellPageProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
|
||||
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
|
||||
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
|
||||
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
|
||||
|
||||
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
|
||||
|
||||
```ts
|
||||
const cellComponents = {
|
||||
// ...existing...
|
||||
page: CellPage,
|
||||
};
|
||||
```
|
||||
|
||||
### Property type picker
|
||||
|
||||
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
|
||||
|
||||
```ts
|
||||
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
|
||||
```
|
||||
|
||||
### Filter editor
|
||||
|
||||
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
|
||||
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
|
||||
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
|
||||
|
||||
### Sort editor
|
||||
|
||||
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
|
||||
|
||||
## Testing
|
||||
|
||||
### Server — unit
|
||||
|
||||
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
|
||||
- **Conversion:**
|
||||
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
|
||||
- Same call with empty `pageTitles` → `""`.
|
||||
- `page → number/date/select/…` → `{converted: true, value: null}`.
|
||||
- `text → page` with any string input → `{converted: true, value: null}`.
|
||||
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
|
||||
|
||||
### Server — integration
|
||||
|
||||
- **Resolver endpoint `POST /bases/pages/resolve`:**
|
||||
- valid IDs in an accessible space → present in `items`
|
||||
- deleted pages (trash) → absent
|
||||
- pages in a space the user isn't a member of → absent
|
||||
- pages in another workspace → absent
|
||||
- empty array → 400
|
||||
- array length > 100 → 400
|
||||
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
|
||||
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
|
||||
|
||||
### Client — unit (Vitest + React Testing Library)
|
||||
|
||||
- `cell-page.test.tsx`:
|
||||
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
|
||||
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
|
||||
- double-click opens picker
|
||||
- Enter on highlighted result commits `pageId`
|
||||
- Esc cancels
|
||||
- Remove tag button commits `null`
|
||||
- `base-page-resolver-query.test.ts`:
|
||||
- dedupes IDs
|
||||
- stable query key across re-renders with same set
|
||||
- missing IDs render as `null` in the returned map
|
||||
|
||||
### Manual QA checklist
|
||||
|
||||
- Link a page in the same space.
|
||||
- Link a page in another space → pill shows, picker shows muted space-name hint.
|
||||
- Remove link → cell empties.
|
||||
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
|
||||
- Viewer loses space access → same "Page not found" fallback.
|
||||
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
|
||||
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
|
||||
- Conversion `page → text` populates cells with page titles.
|
||||
- Conversion `text → page` wipes cells.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
|
||||
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
|
||||
- Existing bases continue to work unchanged.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
|
||||
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
|
||||
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
|
||||
|
||||
## Future extension (multiple pages per cell)
|
||||
|
||||
When `allowMultiple` lands:
|
||||
|
||||
1. Widen cell-value schema: `z.uuid()` → `z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
|
||||
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
|
||||
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
|
||||
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
|
||||
5. No data writes required for existing cells.
|
||||
|
||||
This spec leaves room for that change without locking the storage shape.
|
||||
@@ -0,0 +1,479 @@
|
||||
# Base View Draft (Local-First Filter & Sort) — Design Spec
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Draft
|
||||
**Feature area:** `apps/client/src/features/base` (client-only)
|
||||
|
||||
## Goal
|
||||
|
||||
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
|
||||
|
||||
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
|
||||
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
|
||||
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
|
||||
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
|
||||
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
|
||||
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
|
||||
|
||||
## UX overview
|
||||
|
||||
### Draft banner
|
||||
|
||||
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
|
||||
|
||||
Render condition: `isDirty === true` (see "Dirty check").
|
||||
|
||||
Layout (match the reference screenshot):
|
||||
|
||||
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
|
||||
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
|
||||
- Right region (a `<Group gap="sm">`):
|
||||
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
|
||||
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
|
||||
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
|
||||
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
|
||||
|
||||
### Filter/sort editors in draft mode
|
||||
|
||||
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
|
||||
|
||||
### Reset behavior
|
||||
|
||||
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
|
||||
|
||||
### Save for everyone
|
||||
|
||||
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
|
||||
|
||||
### Permission gating
|
||||
|
||||
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
|
||||
|
||||
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
|
||||
|
||||
## Data model
|
||||
|
||||
### localStorage key
|
||||
|
||||
```
|
||||
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
|
||||
```
|
||||
|
||||
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
|
||||
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
|
||||
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
|
||||
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
|
||||
|
||||
### Value shape
|
||||
|
||||
```ts
|
||||
// apps/client/src/features/base/types/base.types.ts (additive)
|
||||
export type BaseViewDraft = {
|
||||
filter?: FilterGroup;
|
||||
sorts?: ViewSortConfig[];
|
||||
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
|
||||
};
|
||||
```
|
||||
|
||||
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
|
||||
|
||||
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
|
||||
|
||||
## Client architecture
|
||||
|
||||
### Storage atom family
|
||||
|
||||
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
|
||||
|
||||
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
|
||||
|
||||
```ts
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { BaseViewDraft } from "@/features/base/types/base.types";
|
||||
|
||||
export type ViewDraftKey = {
|
||||
userId: string;
|
||||
baseId: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
const keyFor = (k: ViewDraftKey) =>
|
||||
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
|
||||
|
||||
export const viewDraftAtomFamily = atomFamily(
|
||||
(k: ViewDraftKey) =>
|
||||
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
|
||||
(a, b) =>
|
||||
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
|
||||
);
|
||||
```
|
||||
|
||||
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
|
||||
|
||||
### Hook: `useViewDraft`
|
||||
|
||||
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
|
||||
|
||||
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
|
||||
|
||||
```ts
|
||||
export type ViewDraftState = {
|
||||
draft: BaseViewDraft | null;
|
||||
effectiveFilter: FilterGroup | undefined;
|
||||
effectiveSorts: ViewSortConfig[] | undefined;
|
||||
isDirty: boolean;
|
||||
setFilter: (filter: FilterGroup | undefined) => void;
|
||||
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
reset: () => void;
|
||||
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
|
||||
};
|
||||
|
||||
export function useViewDraft(args: {
|
||||
userId: string | undefined;
|
||||
baseId: string | undefined;
|
||||
viewId: string | undefined;
|
||||
baselineFilter: FilterGroup | undefined;
|
||||
baselineSorts: ViewSortConfig[] | undefined;
|
||||
}): ViewDraftState;
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
|
||||
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
|
||||
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
|
||||
4. `reset()` is `setDraft(RESET)`.
|
||||
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
|
||||
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
|
||||
|
||||
**Return composition:**
|
||||
|
||||
- `effectiveFilter = draft?.filter ?? baselineFilter`
|
||||
- `effectiveSorts = draft?.sorts ?? baselineSorts`
|
||||
|
||||
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
|
||||
|
||||
### Integration into `useBaseTable` and `base-table.tsx`
|
||||
|
||||
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
|
||||
|
||||
Two cut options were considered:
|
||||
|
||||
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
|
||||
|
||||
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
|
||||
|
||||
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
|
||||
|
||||
```ts
|
||||
// NEW: wire the draft hook
|
||||
const { data: user } = useCurrentUser();
|
||||
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
|
||||
useViewDraft({
|
||||
userId: user?.user.id,
|
||||
baseId,
|
||||
viewId: activeView?.id,
|
||||
baselineFilter: activeView?.config?.filter,
|
||||
baselineSorts: activeView?.config?.sorts,
|
||||
});
|
||||
|
||||
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
|
||||
const effectiveView = useMemo(
|
||||
() =>
|
||||
activeView
|
||||
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
|
||||
: undefined,
|
||||
[activeView, effectiveFilter, effectiveSorts],
|
||||
);
|
||||
|
||||
// Row query reads effective filter/sorts.
|
||||
const { data: rowsData, ... } = useBaseRowsQuery(
|
||||
base ? baseId : undefined,
|
||||
effectiveFilter,
|
||||
effectiveSorts,
|
||||
);
|
||||
|
||||
// Table is seeded from effectiveView for rendering, but the auto-persist
|
||||
// write-path uses the real `activeView.config` as the baseline so draft
|
||||
// filter/sort values can never leak into a column-layout save.
|
||||
// See "Filter & sort write-path changes" below for the exact mechanism.
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
|
||||
baselineConfig: activeView?.config,
|
||||
});
|
||||
```
|
||||
|
||||
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
|
||||
|
||||
### Filter & sort write-path changes
|
||||
|
||||
Today, filter/sort editors feed `BaseToolbar`'s handlers:
|
||||
|
||||
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })` → `updateViewMutation.mutate(...)`.
|
||||
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
|
||||
|
||||
Both write directly to the server. That's the exact site to branch.
|
||||
|
||||
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
|
||||
|
||||
```ts
|
||||
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
|
||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||
```
|
||||
|
||||
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
|
||||
|
||||
```ts
|
||||
const handleSortsChange = useCallback(
|
||||
(newSorts: ViewSortConfig[]) => {
|
||||
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
|
||||
},
|
||||
[onDraftSortsChange],
|
||||
);
|
||||
```
|
||||
|
||||
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
|
||||
|
||||
**`base-table.tsx`** wires those callbacks to the draft hook:
|
||||
|
||||
```ts
|
||||
const handleDraftSortsChange = useCallback(
|
||||
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
|
||||
[setSorts],
|
||||
);
|
||||
const handleDraftFiltersChange = useCallback(
|
||||
(filter: FilterGroup | undefined) => setFilter(filter),
|
||||
[setFilter],
|
||||
);
|
||||
```
|
||||
|
||||
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
|
||||
|
||||
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
|
||||
|
||||
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
|
||||
|
||||
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
|
||||
|
||||
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
|
||||
|
||||
```ts
|
||||
const config = buildViewConfigFromTable(table, activeView.config, {
|
||||
sorts: activeView.config?.sorts,
|
||||
filter: activeView.config?.filter,
|
||||
});
|
||||
```
|
||||
|
||||
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
|
||||
|
||||
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
|
||||
|
||||
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
|
||||
|
||||
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
|
||||
|
||||
## Banner component
|
||||
|
||||
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
|
||||
|
||||
```ts
|
||||
type BaseViewDraftBannerProps = {
|
||||
isDirty: boolean;
|
||||
canSave: boolean;
|
||||
onReset: () => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!isDirty) return null;
|
||||
return (
|
||||
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconInfoCircle size={16} />
|
||||
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
|
||||
{canSave && (
|
||||
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
|
||||
|
||||
```ts
|
||||
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!activeView || !base) return;
|
||||
const config = buildPromotedConfig(activeView.config);
|
||||
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||
reset();
|
||||
notifications.show({ message: t("View updated for everyone") });
|
||||
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
|
||||
|
||||
return (
|
||||
<div style={{...}}>
|
||||
<BaseViewDraftBanner
|
||||
isDirty={isDirty}
|
||||
canSave={canSave}
|
||||
onReset={reset}
|
||||
onSave={handleSaveDraft}
|
||||
saving={updateViewMutation.isPending}
|
||||
/>
|
||||
<BaseToolbar ... />
|
||||
<GridContainer ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
|
||||
|
||||
## Cross-tab sync
|
||||
|
||||
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
|
||||
|
||||
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
|
||||
|
||||
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
|
||||
|
||||
## Save flow (pseudocode)
|
||||
|
||||
```ts
|
||||
async function onSaveForEveryone() {
|
||||
if (!activeView || !base) return;
|
||||
// 1. Compose the promoted config from the server baseline + draft values.
|
||||
// baseline is activeView.config (NOT effectiveView.config) because the
|
||||
// baseline might include layout fields (propertyWidths, propertyOrder,
|
||||
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
|
||||
const config: ViewConfig = {
|
||||
...activeView.config,
|
||||
filter: draft.filter ?? activeView.config.filter,
|
||||
sorts: draft.sorts ?? activeView.config.sorts,
|
||||
};
|
||||
// 2. Fire the existing mutation. `updateViewMutation` already:
|
||||
// - optimistically updates the ["bases", baseId] query cache
|
||||
// - rolls back on error
|
||||
// - writes the server response back on success
|
||||
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||
// 3. Clear the draft. Because the baseline has now caught up to what the
|
||||
// draft said, isDirty flips to false and the banner unmounts.
|
||||
reset();
|
||||
notifications.show({ message: t("View updated for everyone") });
|
||||
}
|
||||
```
|
||||
|
||||
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
|
||||
|
||||
## Dirty check
|
||||
|
||||
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
|
||||
|
||||
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
|
||||
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
|
||||
|
||||
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
|
||||
|
||||
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
|
||||
2. Hand-written structural compare — overkill for two types with known finite shapes.
|
||||
|
||||
Go with option 1. Helpers live in `use-view-draft.ts`:
|
||||
|
||||
```ts
|
||||
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
```
|
||||
|
||||
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
|
||||
|
||||
## Testing
|
||||
|
||||
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
|
||||
|
||||
### Unit tests (proposed, Vitest — gated on harness being added)
|
||||
|
||||
`use-view-draft.test.ts`:
|
||||
|
||||
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
|
||||
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
|
||||
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
|
||||
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
|
||||
- **`reset` clears both state and storage.**
|
||||
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
|
||||
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X` — `isDirty` flips to `false` without draft being cleared.
|
||||
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
|
||||
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
|
||||
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
|
||||
|
||||
### Manual QA checklist
|
||||
|
||||
**Single user, single tab.**
|
||||
- Apply a filter. Banner appears. Row list updates locally.
|
||||
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
|
||||
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
|
||||
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
|
||||
|
||||
**Single user, multiple tabs.**
|
||||
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
|
||||
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
|
||||
|
||||
**Multi-user baseline race.**
|
||||
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
|
||||
|
||||
**Permission gating.**
|
||||
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
|
||||
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
|
||||
|
||||
**Reset with popover open.**
|
||||
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
|
||||
|
||||
**Save clears draft + updates server.**
|
||||
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
|
||||
|
||||
**Browser storage cleared.**
|
||||
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **No DB migration.** No server change.
|
||||
- **No feature flag.** Behavior change ships as-is.
|
||||
- **No data migration.** Existing users have no drafts; the system starts empty.
|
||||
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
|
||||
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
|
||||
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 5–10MB 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
@@ -1,6 +1,6 @@
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/dist
|
||||
node_modules
|
||||
.git
|
||||
dist
|
||||
/data
|
||||
**/.env*
|
||||
**/.nx
|
||||
.env*
|
||||
.nx
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
.env.prod
|
||||
data
|
||||
# compiled output
|
||||
dist
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
FROM node:26-slim AS base
|
||||
FROM node:22-slim AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
RUN npm install -g pnpm@11.13.0
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -28,12 +28,11 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
|
||||
# Copy packages
|
||||
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
|
||||
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
COPY --from=builder /app/pnpm*.yaml /app/
|
||||
COPY --from=builder /app/.npmrc /app/.npmrc
|
||||
|
||||
# Copy patches
|
||||
COPY --from=builder /app/patches /app/patches
|
||||
|
||||
+18
-19
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.95.0",
|
||||
"version": "0.90.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": "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",
|
||||
"@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",
|
||||
"@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.14.3",
|
||||
"@tanstack/react-virtual": "3.13.24",
|
||||
"alfaaz": "1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
@@ -43,25 +43,24 @@
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"jotai": "2.20.1",
|
||||
"jotai": "2.18.1",
|
||||
"jotai-optics": "0.4.0",
|
||||
"js-cookie": "3.0.7",
|
||||
"js-cookie": "3.0.5",
|
||||
"jwt-decode": "4.0.0",
|
||||
"katex": "0.16.40",
|
||||
"lowlight": "3.3.0",
|
||||
"mantine-form-zod-resolver": "1.3.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mitt": "3.0.1",
|
||||
"nanoid": "3.3.8",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "19.2.7",
|
||||
"posthog-js": "1.372.2",
|
||||
"react": "18.3.1",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "19.2.7",
|
||||
"react-dom": "^18.3.1",
|
||||
"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.18.0",
|
||||
"react-router-dom": "7.13.1",
|
||||
"semver": "7.7.4",
|
||||
"socket.io-client": "4.8.3",
|
||||
"zod": "4.3.6"
|
||||
@@ -76,8 +75,8 @@
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
@@ -92,7 +91,7 @@
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.16",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"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",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Chat-Menü für {{title}}",
|
||||
"Chat menu": "Chatmenü",
|
||||
"API key menu": "API-Schlüssel-Menü",
|
||||
"Jump to comment selection": "Zur Kommentarauswahl springen",
|
||||
"Slash commands": "Slash-Befehle",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filter",
|
||||
"Page title": "Seitentitel",
|
||||
"Page content": "Seiteninhalt",
|
||||
"Member actions for {{name}}": "Mitgliederaktionen für {{name}}",
|
||||
"Member actions": "Mitgliederaktionen",
|
||||
"Toggle password visibility": "Passwortsichtbarkeit umschalten",
|
||||
"Send comment": "Kommentar senden",
|
||||
"Token actions": "Token-Aktionen",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
|
||||
"Page menu for {{name}}": "Seitenmenü für {{name}}",
|
||||
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
|
||||
"Allow personal spaces": "Persönliche Bereiche erlauben",
|
||||
"Members can create their own personal space.": "Mitglieder können ihren eigenen persönlichen Bereich erstellen.",
|
||||
"Toggle allow personal spaces": "Persönliche Bereiche erlauben umschalten",
|
||||
"Create personal space": "Persönlichen Bereich erstellen",
|
||||
"Personal space": "Persönlicher Bereich",
|
||||
"{{name}}'s space": "Bereich von {{name}}",
|
||||
"Apply": "Anwenden",
|
||||
"Cells that aren't already a page reference will be cleared.": "Zellen, die noch keine Seitenreferenz sind, werden geleert.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Zellen, die keine gültige URL sind, werden geleert.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Zellen, die keine gültige E-Mail-Adresse sind, werden geleert.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Zellen, die nicht als Datum geparst werden können, werden geleert.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Zellen, die nicht als Zahl geparst werden können, werden geleert.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Zellen werden umgewandelt (yes/true/1 werden als ausgewählt markiert; alles andere wird als nicht ausgewählt markiert oder geleert).",
|
||||
"Cells will be reinterpreted under the new type.": "Zellen werden unter dem neuen Typ neu interpretiert.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Zellen werden durch eine kommagetrennte Liste von Dateinamen ersetzt.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Zellen werden durch eine kommagetrennte Liste von Optionsnamen ersetzt.",
|
||||
"Cells will be replaced with the option name.": "Zellen werden durch den Optionsnamen ersetzt.",
|
||||
"Cells will be replaced with the page title.": "Zellen werden durch den Seitentitel ersetzt.",
|
||||
"Cells will be replaced with the person's name.": "Zellen werden durch den Namen der Person ersetzt.",
|
||||
"Change type": "Typ ändern",
|
||||
"Change type to {{label}}?": "Typ in {{label}} ändern?",
|
||||
"Converting…": "Wird umgewandelt…",
|
||||
"Existing values become single-item lists. No data is lost.": "Vorhandene Werte werden zu Einzellisten. Es gehen keine Daten verloren.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Nur das erste ausgewählte Element pro Zeile wird beibehalten; der Rest wird verworfen.",
|
||||
"Previous record": "Vorheriger Datensatz",
|
||||
"Next record": "Nächster Datensatz",
|
||||
"Record actions": "Datensatzaktionen",
|
||||
"Delete record": "Datensatz löschen",
|
||||
"Delete record?": "Datensatz löschen?",
|
||||
"This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"to navigate": "zum Navigieren",
|
||||
"to close": "zum Schließen",
|
||||
"Expand row {{number}}": "Zeile {{number}} erweitern",
|
||||
"Saving…": "Wird gespeichert…",
|
||||
"Read-only": "Schreibgeschützt",
|
||||
"Loading…": "Wird geladen…",
|
||||
"Updated {{when}}": "Aktualisiert {{when}}",
|
||||
"Add property": "Eigenschaft hinzufügen",
|
||||
"Create property": "Eigenschaft erstellen",
|
||||
"Hide properties": "Eigenschaften ausblenden",
|
||||
"Find a property type": "Einen Eigenschaftstyp finden",
|
||||
"Properties": "Eigenschaften",
|
||||
"A property with this name already exists": "Eine Eigenschaft mit diesem Namen existiert bereits",
|
||||
"Add card": "Karte hinzufügen",
|
||||
"Add filter": "Filter hinzufügen",
|
||||
"Add option": "Option hinzufügen",
|
||||
"Add sort": "Sortierung hinzufügen",
|
||||
"Add view": "Ansicht hinzufügen",
|
||||
"All data in this column will be lost.": "Alle Daten in dieser Spalte gehen verloren.",
|
||||
"Allow multiple people": "Mehrere Personen zulassen",
|
||||
"Alphabetize": "Alphabetisch sortieren",
|
||||
"Are you sure you want to delete": "Sind Sie sicher, dass Sie löschen möchten",
|
||||
"Ascending": "Aufsteigend",
|
||||
"Base deleted": "Base gelöscht",
|
||||
"Base name": "Base-Name",
|
||||
"Base table": "Base-Tabelle",
|
||||
"Card properties": "Karteneigenschaften",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Zellen, die das Textlimit überschreiten, werden abgeschnitten und der zusätzliche Inhalt geht dauerhaft verloren.",
|
||||
"Checkbox": "Kontrollkästchen",
|
||||
"Checked by default": "Standardmäßig aktiviert",
|
||||
"Choose a property": "Wählen Sie eine Eigenschaft",
|
||||
"Clear selection": "Auswahl aufheben",
|
||||
"Column options": "Spaltenoptionen",
|
||||
"Comma, period": "Komma, Punkt",
|
||||
"Complete": "Abgeschlossen",
|
||||
"Contains": "Enthält",
|
||||
"Copy link to view": "Link zur Ansicht kopieren",
|
||||
"Create a status property": "Eine Statuseigenschaft erstellen",
|
||||
"Created at": "Erstellt am",
|
||||
"Currency": "Währung",
|
||||
"Decimal places": "Dezimalstellen",
|
||||
"Default value": "Standardwert",
|
||||
"Delete property": "Eigenschaft löschen",
|
||||
"Delete view": "Ansicht löschen",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "Absteigend",
|
||||
"Discard": "Verwerfen",
|
||||
"Doesn't contain": "Enthält nicht",
|
||||
"Done": "Fertig",
|
||||
"Edit formula": "Formel bearbeiten",
|
||||
"Edit property": "Eigenschaft bearbeiten",
|
||||
"Export CSV": "CSV exportieren",
|
||||
"Failed to load base": "Base konnte nicht geladen werden",
|
||||
"False": "Falsch",
|
||||
"File": "Datei",
|
||||
"Filter and sort changes are visible only to you": "Änderungen an Filtern und Sortierungen sind nur für Sie sichtbar",
|
||||
"Filter by": "Filtern nach",
|
||||
"Formula": "Formel",
|
||||
"Get started with": "Erste Schritte mit",
|
||||
"Greater than": "Größer als",
|
||||
"Group by": "Gruppieren nach",
|
||||
"Group this board by a select or status property.": "Gruppieren Sie dieses Board nach einer Auswahl- oder Statuseigenschaft.",
|
||||
"Hide all": "Alle ausblenden",
|
||||
"Hide group": "Gruppe ausblenden",
|
||||
"In Progress": "In Bearbeitung",
|
||||
"In progress": "In Bearbeitung",
|
||||
"Include time": "Uhrzeit einschließen",
|
||||
"Insert a kanban board on this page": "Fügen Sie ein Kanban-Board auf dieser Seite ein",
|
||||
"Insert an inline base on this page": "Fügen Sie eine Inline-Base auf dieser Seite ein",
|
||||
"Is": "Ist",
|
||||
"Is after": "Ist nach",
|
||||
"Is any of": "Ist eines von",
|
||||
"Is before": "Ist vor",
|
||||
"Is empty": "Ist leer",
|
||||
"Is none of": "Ist keines von",
|
||||
"Is not": "Ist nicht",
|
||||
"Is not empty": "Ist nicht leer",
|
||||
"Is on or after": "Ist am oder nach",
|
||||
"Is on or before": "Ist am oder vor",
|
||||
"Is within": "Ist innerhalb von",
|
||||
"Keep editing": "Weiter bearbeiten",
|
||||
"Last edited at": "Zuletzt bearbeitet am",
|
||||
"Last edited by": "Zuletzt bearbeitet von",
|
||||
"Less than": "Kleiner als",
|
||||
"Link copied to clipboard": "Link in die Zwischenablage kopiert",
|
||||
"Local": "Lokal",
|
||||
"Long text": "Langer Text",
|
||||
"Moved card to {{column}}": "Karte nach {{column}} verschoben",
|
||||
"Moved column to {{column}}": "Spalte nach {{column}} verschoben",
|
||||
"Multi-select": "Mehrfachauswahl",
|
||||
"New row": "Neue Zeile",
|
||||
"Next month": "Nächster Monat",
|
||||
"Next week": "Nächste Woche",
|
||||
"Next year": "Nächstes Jahr",
|
||||
"No filters applied": "Keine Filter angewendet",
|
||||
"No match": "Keine Übereinstimmung",
|
||||
"No options for this property type": "Keine Optionen für diesen Eigenschaftstyp",
|
||||
"No sorts applied": "Keine Sortierungen angewendet",
|
||||
"No value": "Kein Wert",
|
||||
"None": "Keine",
|
||||
"Not started": "Nicht begonnen",
|
||||
"Number": "Zahl",
|
||||
"One month ago": "Vor einem Monat",
|
||||
"One month from now": "In einem Monat",
|
||||
"One week ago": "Vor einer Woche",
|
||||
"One week from now": "In einer Woche",
|
||||
"Open as page": "Als Seite öffnen",
|
||||
"Options": "Optionen",
|
||||
"Past month": "Letzter Monat",
|
||||
"Past week": "Letzte Woche",
|
||||
"Past year": "Letztes Jahr",
|
||||
"Percent": "Prozent",
|
||||
"Period, comma": "Punkt, Komma",
|
||||
"Person": "Person",
|
||||
"Please enter a valid email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"Progress": "Fortschritt",
|
||||
"Property options": "Eigenschaftsoptionen",
|
||||
"Relative": "Relativ",
|
||||
"Rename property": "Eigenschaft umbenennen",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Save for everyone": "Für alle speichern",
|
||||
"Select": "Auswahl",
|
||||
"Select a property": "Eigenschaft auswählen",
|
||||
"Select all loaded rows": "Alle geladenen Zeilen auswählen",
|
||||
"Show all": "Alle anzeigen",
|
||||
"Sort": "Sortieren",
|
||||
"Sort by": "Sortieren nach",
|
||||
"Space, comma": "Leerzeichen, Komma",
|
||||
"Space, period": "Leerzeichen, Punkt",
|
||||
"This board has no grouping property yet.": "Dieses Board hat noch keine Gruppierungseigenschaft.",
|
||||
"This month": "Diesen Monat",
|
||||
"This year": "Dieses Jahr",
|
||||
"Thousands and decimal separators": "Tausender- und Dezimaltrennzeichen",
|
||||
"Time format": "Zeitformat",
|
||||
"To Do": "Zu erledigen",
|
||||
"Tomorrow": "Morgen",
|
||||
"True": "Wahr",
|
||||
"Unsaved changes": "Nicht gespeicherte Änderungen",
|
||||
"Untitled base": "Unbenannte Base",
|
||||
"URL": "URL",
|
||||
"Value": "Wert",
|
||||
"View updated for everyone": "Ansicht für alle aktualisiert",
|
||||
"You have unsaved changes. Do you want to discard them?": "Sie haben nicht gespeicherte Änderungen. Möchten Sie sie verwerfen?",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "Dark",
|
||||
"Date": "Date",
|
||||
"Delete": "Delete",
|
||||
"Remove from page": "Remove from page",
|
||||
"Base options": "Base options",
|
||||
"Delete group": "Delete group",
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||
"Description": "Description",
|
||||
@@ -78,24 +76,6 @@
|
||||
"Failed to import pages": "Failed to import pages",
|
||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||
"Failed to update data": "Failed to update data",
|
||||
"Failed to create base": "Failed to create base",
|
||||
"Failed to update base": "Failed to update base",
|
||||
"Failed to delete base": "Failed to delete base",
|
||||
"Failed to create property": "Failed to create property",
|
||||
"Failed to update property": "Failed to update property",
|
||||
"Failed to delete property": "Failed to delete property",
|
||||
"Failed to reorder property": "Failed to reorder property",
|
||||
"Failed to create view": "Failed to create view",
|
||||
"Failed to update view": "Failed to update view",
|
||||
"Failed to delete view": "Failed to delete view",
|
||||
"Failed to create row": "Failed to create row",
|
||||
"Failed to update row": "Failed to update row",
|
||||
"Failed to delete row": "Failed to delete row",
|
||||
"Failed to delete rows": "Failed to delete rows",
|
||||
"Failed to reorder row": "Failed to reorder row",
|
||||
"Failed to move card": "Failed to move card",
|
||||
"Failed to add card": "Failed to add card",
|
||||
"Failed to export CSV": "Failed to export CSV",
|
||||
"Favorite spaces": "Favorite spaces",
|
||||
"Favorite spaces appear here": "Favorite spaces appear here",
|
||||
"Favorites": "Favorites",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "Names do not match",
|
||||
"Today, {{time}}": "Today, {{time}}",
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||
"now": "now",
|
||||
"Space created successfully": "Space created successfully",
|
||||
"Space updated successfully": "Space updated successfully",
|
||||
"Space deleted successfully": "Space deleted successfully",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "Deleted by",
|
||||
"Deleted at": "Deleted at",
|
||||
"Preview": "Preview",
|
||||
"Base preview unavailable": "Base preview unavailable",
|
||||
"Restore this base to view its contents.": "Restore this base to view its contents.",
|
||||
"Subpages": "Subpages",
|
||||
"Failed to load subpages": "Failed to load subpages",
|
||||
"No subpages": "No subpages",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Chat menu for {{title}}",
|
||||
"Chat menu": "Chat menu",
|
||||
"API key menu": "API key menu",
|
||||
"Jump to comment selection": "Jump to comment selection",
|
||||
"Slash commands": "Slash commands",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filter",
|
||||
"Page title": "Page title",
|
||||
"Page content": "Page content",
|
||||
"Member actions for {{name}}": "Member actions for {{name}}",
|
||||
"Member actions": "Member actions",
|
||||
"Toggle password visibility": "Toggle password visibility",
|
||||
"Send comment": "Send comment",
|
||||
"Token actions": "Token actions",
|
||||
@@ -1112,12 +1085,6 @@
|
||||
"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.",
|
||||
@@ -1135,159 +1102,5 @@
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.",
|
||||
"Previous record": "Previous record",
|
||||
"Next record": "Next record",
|
||||
"Record actions": "Record actions",
|
||||
"Delete record": "Delete record",
|
||||
"Delete record?": "Delete record?",
|
||||
"This action cannot be undone.": "This action cannot be undone.",
|
||||
"to navigate": "to navigate",
|
||||
"to close": "to close",
|
||||
"Expand row {{number}}": "Expand row {{number}}",
|
||||
"Saving…": "Saving…",
|
||||
"Read-only": "Read-only",
|
||||
"Loading…": "Loading…",
|
||||
"Updated {{when}}": "Updated {{when}}",
|
||||
"Add property": "Add property",
|
||||
"Create property": "Create property",
|
||||
"Hide properties": "Hide properties",
|
||||
"Find a property type": "Find a property type",
|
||||
"Properties": "Properties",
|
||||
"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"
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"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",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Menú del chat de {{title}}",
|
||||
"Chat menu": "Menú del chat",
|
||||
"API key menu": "Menú de la clave API",
|
||||
"Jump to comment selection": "Ir a la selección de comentarios",
|
||||
"Slash commands": "Comandos de barra",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filtrar",
|
||||
"Page title": "Título de la página",
|
||||
"Page content": "Contenido de la página",
|
||||
"Member actions for {{name}}": "Acciones de miembro para {{name}}",
|
||||
"Member actions": "Acciones de miembro",
|
||||
"Toggle password visibility": "Alternar visibilidad de la contraseña",
|
||||
"Send comment": "Enviar comentario",
|
||||
"Token actions": "Acciones de token",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"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}}",
|
||||
"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"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "Les noms ne correspondent pas",
|
||||
"Today, {{time}}": "Aujourd’hui, {{time}}",
|
||||
"Yesterday, {{time}}": "Hier, {{time}}",
|
||||
"now": "maintenant",
|
||||
"Space created successfully": "Espace créé avec succès",
|
||||
"Space updated successfully": "Espace mis à jour avec succès",
|
||||
"Space deleted successfully": "Espace supprimé avec succès",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"Are you sure you want to delete this template?": "Êtes-vous sûr de vouloir supprimer ce modèle ?",
|
||||
"Template scope updated": "Portée du modèle mise à jour",
|
||||
"Choose which space this template belongs to": "Choisissez à quel espace appartient ce modèle",
|
||||
"Only workspace admins can create global templates.": "Seuls les administrateurs de l’espace de travail peuvent créer des modèles globaux.",
|
||||
"You need edit access to this space to create templates.": "Vous devez disposer d’un accès en modification à cet espace pour créer des modèles.",
|
||||
"Scope": "Portée",
|
||||
"Select scope": "Sélectionner la portée",
|
||||
"Title": "Titre",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "Rechercher des pages et des espaces...",
|
||||
"No results found": "Aucun résultat trouvé",
|
||||
"You don't have permission to create pages here": "Vous n’avez pas l’autorisation de créer des pages ici",
|
||||
"Chat menu for {{title}}": "Menu de discussion pour {{title}}",
|
||||
"Chat menu": "Menu du chat",
|
||||
"API key menu": "Menu de la clé API",
|
||||
"Jump to comment selection": "Aller à la sélection de commentaires",
|
||||
"Slash commands": "Commandes slash",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filtrer",
|
||||
"Page title": "Titre de la page",
|
||||
"Page content": "Contenu de la page",
|
||||
"Member actions for {{name}}": "Actions du membre pour {{name}}",
|
||||
"Member actions": "Actions des membres",
|
||||
"Toggle password visibility": "Afficher/masquer le mot de passe",
|
||||
"Send comment": "Envoyer le commentaire",
|
||||
"Token actions": "Actions du jeton",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"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}}",
|
||||
"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"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"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",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Menu chat per {{title}}",
|
||||
"Chat menu": "Menu della chat",
|
||||
"API key menu": "Menu della chiave API",
|
||||
"Jump to comment selection": "Vai alla selezione dei commenti",
|
||||
"Slash commands": "Comandi slash",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filtro",
|
||||
"Page title": "Titolo pagina",
|
||||
"Page content": "Contenuto della pagina",
|
||||
"Member actions for {{name}}": "Azioni membro per {{name}}",
|
||||
"Member actions": "Azioni membro",
|
||||
"Toggle password visibility": "Attiva/disattiva visibilità password",
|
||||
"Send comment": "Invia commento",
|
||||
"Token actions": "Azioni token",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"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}}",
|
||||
"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"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "ダーク",
|
||||
"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": "説明",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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": "お気に入り",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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": "変換する",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "名前が一致しません",
|
||||
"Today, {{time}}": "今日 {{time}}",
|
||||
"Yesterday, {{time}}": "昨日 {{time}}",
|
||||
"now": "今すぐ",
|
||||
"Space created successfully": "スペースが正常に作成されました",
|
||||
"Space updated successfully": "スペースが正常に更新されました",
|
||||
"Space deleted successfully": "スペースが正常に削除されました",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "削除者",
|
||||
"Deleted at": "削除日時",
|
||||
"Preview": "プレビュー",
|
||||
"Base preview unavailable": "ベースのプレビューを利用できません",
|
||||
"Restore this base to view its contents.": "内容を表示するには、このベースを復元してください。",
|
||||
"Subpages": "サブページ",
|
||||
"Failed to load subpages": "サブページの読み込みに失敗しました",
|
||||
"No subpages": "サブページはありません",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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": "タイトル",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "ページとスペースを検索…",
|
||||
"No results found": "結果が見つかりません",
|
||||
"You don't have permission to create pages here": "ここにページを作成する権限がありません",
|
||||
"Chat menu for {{title}}": "{{title}} のチャットメニュー",
|
||||
"Chat menu": "チャットメニュー",
|
||||
"API key menu": "API キーメニュー",
|
||||
"Jump to comment selection": "コメント選択に移動",
|
||||
"Slash commands": "スラッシュコマンド",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "フィルター",
|
||||
"Page title": "ページタイトル",
|
||||
"Page content": "ページ内容",
|
||||
"Member actions for {{name}}": "{{name}} のメンバー操作",
|
||||
"Member actions": "メンバーの操作",
|
||||
"Toggle password visibility": "パスワードの表示を切り替え",
|
||||
"Send comment": "コメントを送信",
|
||||
"Token actions": "トークンの操作",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
|
||||
"Page menu for {{name}}": "{{name}} のページメニュー",
|
||||
"Create subpage of {{name}}": "{{name}} のサブページを作成",
|
||||
"Allow personal spaces": "パーソナルスペースを許可",
|
||||
"Members can create their own personal space.": "メンバーは自分専用のパーソナルスペースを作成できます。",
|
||||
"Toggle allow personal spaces": "パーソナルスペースの許可を切り替える",
|
||||
"Create personal space": "パーソナルスペースを作成",
|
||||
"Personal space": "パーソナルスペース",
|
||||
"{{name}}'s space": "{{name}} のスペース",
|
||||
"Apply": "適用",
|
||||
"Cells that aren't already a page reference will be cleared.": "すでにページ参照になっているセル以外は消去されます。",
|
||||
"Cells that aren't a valid URL will be cleared.": "有効な URL ではないセルは消去されます。",
|
||||
"Cells that aren't a valid email address will be cleared.": "有効なメールアドレスではないセルは消去されます。",
|
||||
"Cells that can't be parsed as a date will be cleared.": "日付として解析できないセルは消去されます。",
|
||||
"Cells that can't be parsed as a number will be cleared.": "数値として解析できないセルは消去されます。",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "セルは強制変換されます(yes/true/1 はチェック済みになり、それ以外は未チェックまたは空になります)。",
|
||||
"Cells will be reinterpreted under the new type.": "セルは新しい型で再解釈されます。",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "セルは、ファイル名のカンマ区切りリストに置き換えられます。",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "セルは、オプション名のカンマ区切りリストに置き換えられます。",
|
||||
"Cells will be replaced with the option name.": "セルはオプション名に置き換えられます。",
|
||||
"Cells will be replaced with the page title.": "セルはページタイトルに置き換えられます。",
|
||||
"Cells will be replaced with the person's name.": "セルはその人の名前に置き換えられます。",
|
||||
"Change type": "型を変更",
|
||||
"Change type to {{label}}?": "型を {{label}} に変更しますか?",
|
||||
"Converting…": "変換中…",
|
||||
"Existing values become single-item lists. No data is lost.": "既存の値は単一項目のリストになります。データは失われません。",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "各行で最初に選択された項目のみ保持され、残りは破棄されます。",
|
||||
"Previous record": "前のレコード",
|
||||
"Next record": "次のレコード",
|
||||
"Record actions": "レコード操作",
|
||||
"Delete record": "レコードを削除",
|
||||
"Delete record?": "レコードを削除しますか?",
|
||||
"This action cannot be undone.": "この操作は元に戻せません。",
|
||||
"to navigate": "移動するには",
|
||||
"to close": "閉じるには",
|
||||
"Expand row {{number}}": "行 {{number}} を展開",
|
||||
"Saving…": "保存中…",
|
||||
"Read-only": "読み取り専用",
|
||||
"Loading…": "読み込み中…",
|
||||
"Updated {{when}}": "{{when}} に更新",
|
||||
"Add property": "プロパティを追加",
|
||||
"Create property": "プロパティを作成",
|
||||
"Hide properties": "プロパティを非表示",
|
||||
"Find a property type": "プロパティの種類を検索",
|
||||
"Properties": "プロパティ",
|
||||
"A property with this name already exists": "この名前のプロパティはすでに存在します",
|
||||
"Add card": "カードを追加",
|
||||
"Add filter": "フィルターを追加",
|
||||
"Add option": "オプションを追加",
|
||||
"Add sort": "並べ替えを追加",
|
||||
"Add view": "ビューを追加",
|
||||
"All data in this column will be lost.": "この列内のすべてのデータは失われます。",
|
||||
"Allow multiple people": "複数のユーザーを許可",
|
||||
"Alphabetize": "アルファベット順",
|
||||
"Are you sure you want to delete": "削除してもよろしいですか",
|
||||
"Ascending": "昇順",
|
||||
"Base deleted": "ベースを削除しました",
|
||||
"Base name": "ベース名",
|
||||
"Base table": "ベーステーブル",
|
||||
"Card properties": "カードのプロパティ",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "テキストの制限を超える長さのセルは切り詰められ、超過分の内容は完全に失われます。",
|
||||
"Checkbox": "チェックボックス",
|
||||
"Checked by default": "デフォルトでチェックを入れる",
|
||||
"Choose a property": "プロパティを選択",
|
||||
"Clear selection": "選択をクリア",
|
||||
"Column options": "列のオプション",
|
||||
"Comma, period": "カンマ、小数点",
|
||||
"Complete": "完了",
|
||||
"Contains": "含む",
|
||||
"Copy link to view": "ビューへのリンクをコピー",
|
||||
"Create a status property": "ステータスプロパティを作成",
|
||||
"Created at": "作成日時",
|
||||
"Currency": "通貨",
|
||||
"Decimal places": "小数点以下の桁数",
|
||||
"Default value": "デフォルト値",
|
||||
"Delete property": "プロパティを削除",
|
||||
"Delete view": "ビューを削除",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "降順",
|
||||
"Discard": "破棄",
|
||||
"Doesn't contain": "含まない",
|
||||
"Done": "完了",
|
||||
"Edit formula": "数式を編集",
|
||||
"Edit property": "プロパティを編集",
|
||||
"Export CSV": "CSVをエクスポート",
|
||||
"Failed to load base": "ベースの読み込みに失敗しました",
|
||||
"False": "偽",
|
||||
"File": "ファイル",
|
||||
"Filter and sort changes are visible only to you": "フィルターと並べ替えの変更は自分にのみ表示されます",
|
||||
"Filter by": "条件",
|
||||
"Formula": "数式",
|
||||
"Get started with": "次から始める",
|
||||
"Greater than": "より大きい",
|
||||
"Group by": "グループ化",
|
||||
"Group this board by a select or status property.": "このボードをセレクトまたはステータスのプロパティでグループ化します。",
|
||||
"Hide all": "すべて非表示",
|
||||
"Hide group": "グループを非表示",
|
||||
"In Progress": "進行中",
|
||||
"In progress": "進行中",
|
||||
"Include time": "時刻を含める",
|
||||
"Insert a kanban board on this page": "このページにカンバンボードを挿入",
|
||||
"Insert an inline base on this page": "このページにインラインベースを挿入",
|
||||
"Is": "次に等しい",
|
||||
"Is after": "次より後",
|
||||
"Is any of": "次のいずれか",
|
||||
"Is before": "次より前",
|
||||
"Is empty": "空である",
|
||||
"Is none of": "次のいずれでもない",
|
||||
"Is not": "次に等しくない",
|
||||
"Is not empty": "空ではない",
|
||||
"Is on or after": "次以降",
|
||||
"Is on or before": "次以前",
|
||||
"Is within": "次の期間内",
|
||||
"Keep editing": "編集を続ける",
|
||||
"Last edited at": "最終編集日時",
|
||||
"Last edited by": "最終編集者",
|
||||
"Less than": "より小さい",
|
||||
"Link copied to clipboard": "リンクをクリップボードにコピーしました",
|
||||
"Local": "ローカル",
|
||||
"Long text": "長いテキスト",
|
||||
"Moved card to {{column}}": "カードを{{column}}に移動しました",
|
||||
"Moved column to {{column}}": "列を{{column}}に移動しました",
|
||||
"Multi-select": "複数選択",
|
||||
"New row": "新規行",
|
||||
"Next month": "来月",
|
||||
"Next week": "来週",
|
||||
"Next year": "来年",
|
||||
"No filters applied": "フィルターは適用されていません",
|
||||
"No match": "一致なし",
|
||||
"No options for this property type": "このプロパティタイプにはオプションがありません",
|
||||
"No sorts applied": "並べ替えは適用されていません",
|
||||
"No value": "値なし",
|
||||
"None": "なし",
|
||||
"Not started": "未着手",
|
||||
"Number": "数値",
|
||||
"One month ago": "1か月前",
|
||||
"One month from now": "1か月後",
|
||||
"One week ago": "1週間前",
|
||||
"One week from now": "1週間後",
|
||||
"Open as page": "ページとして開く",
|
||||
"Options": "オプション",
|
||||
"Past month": "先月",
|
||||
"Past week": "先週",
|
||||
"Past year": "昨年",
|
||||
"Percent": "パーセント",
|
||||
"Period, comma": "小数点、カンマ",
|
||||
"Person": "ユーザー",
|
||||
"Please enter a valid email": "有効なメールアドレスを入力してください",
|
||||
"Progress": "進捗",
|
||||
"Property options": "プロパティのオプション",
|
||||
"Relative": "相対",
|
||||
"Rename property": "プロパティ名を変更",
|
||||
"Reset": "リセット",
|
||||
"Save for everyone": "全員に保存",
|
||||
"Select": "選択",
|
||||
"Select a property": "プロパティを選択",
|
||||
"Select all loaded rows": "読み込まれているすべての行を選択",
|
||||
"Show all": "すべて表示",
|
||||
"Sort": "並べ替え",
|
||||
"Sort by": "並べ替え条件",
|
||||
"Space, comma": "スペース、カンマ",
|
||||
"Space, period": "スペース、小数点",
|
||||
"This board has no grouping property yet.": "このボードにはまだグループ化プロパティがありません。",
|
||||
"This month": "今月",
|
||||
"This year": "今年",
|
||||
"Thousands and decimal separators": "桁区切りと小数点区切り",
|
||||
"Time format": "時刻形式",
|
||||
"To Do": "To Do",
|
||||
"Tomorrow": "明日",
|
||||
"True": "真",
|
||||
"Unsaved changes": "未保存の変更",
|
||||
"Untitled base": "無題のベース",
|
||||
"URL": "URL",
|
||||
"Value": "値",
|
||||
"View updated for everyone": "ビューが全員向けに更新されました",
|
||||
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "어두운",
|
||||
"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": "설명",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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": "즐겨찾기",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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": "변경하기",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "이름이 일치하지 않습니다",
|
||||
"Today, {{time}}": "오늘, {{time}}",
|
||||
"Yesterday, {{time}}": "어제, {{time}}",
|
||||
"now": "지금",
|
||||
"Space created successfully": "스페이스가 성공적으로 생성되었습니다",
|
||||
"Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
|
||||
"Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "삭제한 사람",
|
||||
"Deleted at": "삭제 시간",
|
||||
"Preview": "미리보기",
|
||||
"Base preview unavailable": "베이스 미리보기를 사용할 수 없음",
|
||||
"Restore this base to view its contents.": "이 베이스를 복원하여 내용을 보세요.",
|
||||
"Subpages": "하위 페이지",
|
||||
"Failed to load subpages": "하위 페이지를 불러오지 못했습니다",
|
||||
"No subpages": "하위 페이지가 없습니다",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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": "제목",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "페이지와 스페이스 검색...",
|
||||
"No results found": "결과를 찾을 수 없습니다",
|
||||
"You don't have permission to create pages here": "여기에서 페이지를 만들 권한이 없습니다",
|
||||
"Chat menu for {{title}}": "{{title}}의 채팅 메뉴",
|
||||
"Chat menu": "채팅 메뉴",
|
||||
"API key menu": "API 키 메뉴",
|
||||
"Jump to comment selection": "댓글 선택으로 이동",
|
||||
"Slash commands": "슬래시 명령어",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "필터",
|
||||
"Page title": "페이지 제목",
|
||||
"Page content": "페이지 내용",
|
||||
"Member actions for {{name}}": "{{name}}에 대한 멤버 작업",
|
||||
"Member actions": "멤버 작업",
|
||||
"Toggle password visibility": "비밀번호 표시 전환",
|
||||
"Send comment": "댓글 보내기",
|
||||
"Token actions": "토큰 작업",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
|
||||
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
|
||||
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
|
||||
"Allow personal spaces": "개인 공간 허용",
|
||||
"Members can create their own personal space.": "멤버는 자신만의 개인 공간을 만들 수 있습니다.",
|
||||
"Toggle allow personal spaces": "개인 공간 허용 전환",
|
||||
"Create personal space": "개인 공간 만들기",
|
||||
"Personal space": "개인 공간",
|
||||
"{{name}}'s space": "{{name}}의 공간",
|
||||
"Apply": "적용",
|
||||
"Cells that aren't already a page reference will be cleared.": "이미 페이지 참조인 셀이 아닌 경우 지워집니다.",
|
||||
"Cells that aren't a valid URL will be cleared.": "유효한 URL이 아닌 셀은 지워집니다.",
|
||||
"Cells that aren't a valid email address will be cleared.": "유효한 이메일 주소가 아닌 셀은 지워집니다.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "날짜로 해석할 수 없는 셀은 지워집니다.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "숫자로 해석할 수 없는 셀은 지워집니다.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "셀 값은 강제로 변환됩니다(yes/true/1은 선택됨이 되고, 그 외의 모든 값은 선택 해제되거나 지워집니다).",
|
||||
"Cells will be reinterpreted under the new type.": "셀은 새 유형에 따라 다시 해석됩니다.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "셀은 쉼표로 구분된 파일 이름 목록으로 대체됩니다.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "셀은 쉼표로 구분된 옵션 이름 목록으로 대체됩니다.",
|
||||
"Cells will be replaced with the option name.": "셀은 옵션 이름으로 대체됩니다.",
|
||||
"Cells will be replaced with the page title.": "셀은 페이지 제목으로 대체됩니다.",
|
||||
"Cells will be replaced with the person's name.": "셀은 사람 이름으로 대체됩니다.",
|
||||
"Change type": "유형 변경",
|
||||
"Change type to {{label}}?": "유형을 {{label}}로 변경할까요?",
|
||||
"Converting…": "변환 중…",
|
||||
"Existing values become single-item lists. No data is lost.": "기존 값은 단일 항목 목록이 됩니다. 데이터는 손실되지 않습니다.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "각 행에서 처음 선택된 항목만 유지되며 나머지는 삭제됩니다.",
|
||||
"Previous record": "이전 레코드",
|
||||
"Next record": "다음 레코드",
|
||||
"Record actions": "레코드 작업",
|
||||
"Delete record": "레코드 삭제",
|
||||
"Delete record?": "레코드를 삭제할까요?",
|
||||
"This action cannot be undone.": "이 작업은 되돌릴 수 없습니다.",
|
||||
"to navigate": "탐색하려면",
|
||||
"to close": "닫으려면",
|
||||
"Expand row {{number}}": "행 {{number}} 펼치기",
|
||||
"Saving…": "저장 중…",
|
||||
"Read-only": "읽기 전용",
|
||||
"Loading…": "불러오는 중…",
|
||||
"Updated {{when}}": "{{when}}에 업데이트됨",
|
||||
"Add property": "속성 추가",
|
||||
"Create property": "속성 만들기",
|
||||
"Hide properties": "속성 숨기기",
|
||||
"Find a property type": "속성 유형 찾기",
|
||||
"Properties": "속성",
|
||||
"A property with this name already exists": "이 이름의 속성이 이미 있습니다",
|
||||
"Add card": "카드 추가",
|
||||
"Add filter": "필터 추가",
|
||||
"Add option": "옵션 추가",
|
||||
"Add sort": "정렬 추가",
|
||||
"Add view": "보기 추가",
|
||||
"All data in this column will be lost.": "이 열의 모든 데이터가 손실됩니다.",
|
||||
"Allow multiple people": "여러 사람 허용",
|
||||
"Alphabetize": "가나다순",
|
||||
"Are you sure you want to delete": "삭제하시겠습니까",
|
||||
"Ascending": "오름차순",
|
||||
"Base deleted": "베이스가 삭제됨",
|
||||
"Base name": "베이스 이름",
|
||||
"Base table": "베이스 테이블",
|
||||
"Card properties": "카드 속성",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "텍스트 제한보다 긴 셀은 잘리며, 초과된 콘텐츠는 영구적으로 손실됩니다.",
|
||||
"Checkbox": "체크박스",
|
||||
"Checked by default": "기본적으로 선택됨",
|
||||
"Choose a property": "속성 선택",
|
||||
"Clear selection": "선택 지우기",
|
||||
"Column options": "열 옵션",
|
||||
"Comma, period": "쉼표, 마침표",
|
||||
"Complete": "완료",
|
||||
"Contains": "포함",
|
||||
"Copy link to view": "보기 링크 복사",
|
||||
"Create a status property": "상태 속성 만들기",
|
||||
"Created at": "작성 시간",
|
||||
"Currency": "통화",
|
||||
"Decimal places": "소수 자릿수",
|
||||
"Default value": "기본값",
|
||||
"Delete property": "속성 삭제",
|
||||
"Delete view": "보기 삭제",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "내림차순",
|
||||
"Discard": "삭제",
|
||||
"Doesn't contain": "포함하지 않음",
|
||||
"Done": "완료",
|
||||
"Edit formula": "수식 편집",
|
||||
"Edit property": "속성 편집",
|
||||
"Export CSV": "CSV 내보내기",
|
||||
"Failed to load base": "베이스를 불러오지 못했습니다",
|
||||
"False": "거짓",
|
||||
"File": "파일",
|
||||
"Filter and sort changes are visible only to you": "필터 및 정렬 변경 사항은 나에게만 표시됩니다",
|
||||
"Filter by": "필터 기준",
|
||||
"Formula": "수식",
|
||||
"Get started with": "시작하기",
|
||||
"Greater than": "보다 큼",
|
||||
"Group by": "그룹화 기준",
|
||||
"Group this board by a select or status property.": "선택 또는 상태 속성으로 이 보드를 그룹화하세요.",
|
||||
"Hide all": "모두 숨기기",
|
||||
"Hide group": "그룹 숨기기",
|
||||
"In Progress": "진행 중",
|
||||
"In progress": "진행 중",
|
||||
"Include time": "시간 포함",
|
||||
"Insert a kanban board on this page": "이 페이지에 칸반 보드 삽입",
|
||||
"Insert an inline base on this page": "이 페이지에 인라인 베이스 삽입",
|
||||
"Is": "같음",
|
||||
"Is after": "이후",
|
||||
"Is any of": "다음 중 하나와 같음",
|
||||
"Is before": "이전",
|
||||
"Is empty": "비어 있음",
|
||||
"Is none of": "다음 중 어느 것과도 같지 않음",
|
||||
"Is not": "같지 않음",
|
||||
"Is not empty": "비어 있지 않음",
|
||||
"Is on or after": "해당 날짜 또는 이후",
|
||||
"Is on or before": "해당 날짜 또는 이전",
|
||||
"Is within": "다음 범위 내",
|
||||
"Keep editing": "계속 편집",
|
||||
"Last edited at": "마지막 편집 시간",
|
||||
"Last edited by": "마지막 편집자",
|
||||
"Less than": "보다 작음",
|
||||
"Link copied to clipboard": "링크가 클립보드에 복사됨",
|
||||
"Local": "로컬",
|
||||
"Long text": "긴 텍스트",
|
||||
"Moved card to {{column}}": "카드를 {{column}}(으)로 이동함",
|
||||
"Moved column to {{column}}": "열을 {{column}}(으)로 이동함",
|
||||
"Multi-select": "다중 선택",
|
||||
"New row": "새 행",
|
||||
"Next month": "다음 달",
|
||||
"Next week": "다음 주",
|
||||
"Next year": "내년",
|
||||
"No filters applied": "적용된 필터 없음",
|
||||
"No match": "일치하는 항목 없음",
|
||||
"No options for this property type": "이 속성 유형에는 옵션이 없습니다",
|
||||
"No sorts applied": "적용된 정렬 없음",
|
||||
"No value": "값 없음",
|
||||
"None": "없음",
|
||||
"Not started": "시작 전",
|
||||
"Number": "숫자",
|
||||
"One month ago": "한 달 전",
|
||||
"One month from now": "한 달 후",
|
||||
"One week ago": "일주일 전",
|
||||
"One week from now": "일주일 후",
|
||||
"Open as page": "페이지로 열기",
|
||||
"Options": "옵션",
|
||||
"Past month": "지난달",
|
||||
"Past week": "지난주",
|
||||
"Past year": "작년",
|
||||
"Percent": "백분율",
|
||||
"Period, comma": "마침표, 쉼표",
|
||||
"Person": "사람",
|
||||
"Please enter a valid email": "유효한 이메일을 입력하세요",
|
||||
"Progress": "진행률",
|
||||
"Property options": "속성 옵션",
|
||||
"Relative": "상대값",
|
||||
"Rename property": "속성 이름 변경",
|
||||
"Reset": "재설정",
|
||||
"Save for everyone": "모든 사용자에게 저장",
|
||||
"Select": "선택",
|
||||
"Select a property": "속성 선택",
|
||||
"Select all loaded rows": "불러온 모든 행 선택",
|
||||
"Show all": "모두 보기",
|
||||
"Sort": "정렬",
|
||||
"Sort by": "정렬 기준",
|
||||
"Space, comma": "공백, 쉼표",
|
||||
"Space, period": "공백, 마침표",
|
||||
"This board has no grouping property yet.": "이 보드에는 아직 그룹화 속성이 없습니다.",
|
||||
"This month": "이번 달",
|
||||
"This year": "올해",
|
||||
"Thousands and decimal separators": "천 단위 및 소수 구분 기호",
|
||||
"Time format": "시간 형식",
|
||||
"To Do": "할 일",
|
||||
"Tomorrow": "내일",
|
||||
"True": "참",
|
||||
"Unsaved changes": "저장되지 않은 변경 사항",
|
||||
"Untitled base": "제목 없는 베이스",
|
||||
"URL": "URL",
|
||||
"Value": "값",
|
||||
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
|
||||
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"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",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Chatmenu voor {{title}}",
|
||||
"Chat menu": "Chatmenu",
|
||||
"API key menu": "API-sleutelmenu",
|
||||
"Jump to comment selection": "Naar reactieselectie springen",
|
||||
"Slash commands": "Slash-opdrachten",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filter",
|
||||
"Page title": "Paginatitel",
|
||||
"Page content": "Pagina-inhoud",
|
||||
"Member actions for {{name}}": "Lidacties voor {{name}}",
|
||||
"Member actions": "Lidacties",
|
||||
"Toggle password visibility": "Wachtwoordzichtbaarheid in-/uitschakelen",
|
||||
"Send comment": "Reactie verzenden",
|
||||
"Token actions": "Tokenacties",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
|
||||
"Page menu for {{name}}": "Paginamenu voor {{name}}",
|
||||
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
|
||||
"Allow personal spaces": "Persoonlijke ruimtes toestaan",
|
||||
"Members can create their own personal space.": "Leden kunnen hun eigen persoonlijke ruimte aanmaken.",
|
||||
"Toggle allow personal spaces": "Persoonlijke ruimtes toestaan in- of uitschakelen",
|
||||
"Create personal space": "Persoonlijke ruimte aanmaken",
|
||||
"Personal space": "Persoonlijke ruimte",
|
||||
"{{name}}'s space": "ruimte van {{name}}",
|
||||
"Apply": "Toepassen",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cellen die nog geen paginaverwijzing zijn, worden leeggemaakt.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cellen die geen geldige URL zijn, worden leeggemaakt.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cellen die geen geldig e-mailadres zijn, worden leeggemaakt.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cellen die niet als een datum kunnen worden geïnterpreteerd, worden leeggemaakt.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cellen die niet als een getal kunnen worden geïnterpreteerd, worden leeggemaakt.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cellen worden geconverteerd (yes/true/1 worden aangevinkt; al het andere wordt uitgevinkt of leeggemaakt).",
|
||||
"Cells will be reinterpreted under the new type.": "Cellen worden opnieuw geïnterpreteerd volgens het nieuwe type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cellen worden vervangen door een door komma's gescheiden lijst met bestandsnamen.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cellen worden vervangen door een door komma's gescheiden lijst met optienamen.",
|
||||
"Cells will be replaced with the option name.": "Cellen worden vervangen door de optienaam.",
|
||||
"Cells will be replaced with the page title.": "Cellen worden vervangen door de paginatitel.",
|
||||
"Cells will be replaced with the person's name.": "Cellen worden vervangen door de naam van de persoon.",
|
||||
"Change type": "Type wijzigen",
|
||||
"Change type to {{label}}?": "Type wijzigen naar {{label}}?",
|
||||
"Converting…": "Converteren…",
|
||||
"Existing values become single-item lists. No data is lost.": "Bestaande waarden worden lijsten met één item. Er gaan geen gegevens verloren.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Alleen het eerste geselecteerde item per rij blijft behouden; de rest wordt verwijderd.",
|
||||
"Previous record": "Vorige record",
|
||||
"Next record": "Volgende record",
|
||||
"Record actions": "Recordacties",
|
||||
"Delete record": "Record verwijderen",
|
||||
"Delete record?": "Record verwijderen?",
|
||||
"This action cannot be undone.": "Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"to navigate": "om te navigeren",
|
||||
"to close": "om te sluiten",
|
||||
"Expand row {{number}}": "Rij {{number}} uitvouwen",
|
||||
"Saving…": "Opslaan…",
|
||||
"Read-only": "Alleen-lezen",
|
||||
"Loading…": "Laden…",
|
||||
"Updated {{when}}": "Bijgewerkt {{when}}",
|
||||
"Add property": "Eigenschap toevoegen",
|
||||
"Create property": "Eigenschap aanmaken",
|
||||
"Hide properties": "Eigenschappen verbergen",
|
||||
"Find a property type": "Een eigenschapstype zoeken",
|
||||
"Properties": "Eigenschappen",
|
||||
"A property with this name already exists": "Er bestaat al een eigenschap met deze naam",
|
||||
"Add card": "Kaart toevoegen",
|
||||
"Add filter": "Filter toevoegen",
|
||||
"Add option": "Optie toevoegen",
|
||||
"Add sort": "Sortering toevoegen",
|
||||
"Add view": "Weergave toevoegen",
|
||||
"All data in this column will be lost.": "Alle gegevens in deze kolom gaan verloren.",
|
||||
"Allow multiple people": "Meerdere personen toestaan",
|
||||
"Alphabetize": "Alfabetisch sorteren",
|
||||
"Are you sure you want to delete": "Weet je zeker dat je wilt verwijderen",
|
||||
"Ascending": "Oplopend",
|
||||
"Base deleted": "Base verwijderd",
|
||||
"Base name": "Basenaam",
|
||||
"Base table": "Basistabel",
|
||||
"Card properties": "Kaarteigenschappen",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cellen die langer zijn dan de tekstlimiet worden afgekapt en de extra inhoud gaat permanent verloren.",
|
||||
"Checkbox": "Selectievakje",
|
||||
"Checked by default": "Standaard aangevinkt",
|
||||
"Choose a property": "Kies een eigenschap",
|
||||
"Clear selection": "Selectie wissen",
|
||||
"Column options": "Kolomopties",
|
||||
"Comma, period": "Komma, punt",
|
||||
"Complete": "Voltooid",
|
||||
"Contains": "Bevat",
|
||||
"Copy link to view": "Link naar weergave kopiëren",
|
||||
"Create a status property": "Een statuseigenschap maken",
|
||||
"Created at": "Aangemaakt op",
|
||||
"Currency": "Valuta",
|
||||
"Decimal places": "Decimalen",
|
||||
"Default value": "Standaardwaarde",
|
||||
"Delete property": "Eigenschap verwijderen",
|
||||
"Delete view": "Weergave verwijderen",
|
||||
"Delete {{count}} rows?_one": "1 rij verwijderen?",
|
||||
"Delete {{count}} rows?_other": "{{count}} rijen verwijderen?",
|
||||
"Descending": "Aflopend",
|
||||
"Discard": "Negeren",
|
||||
"Doesn't contain": "Bevat niet",
|
||||
"Done": "Klaar",
|
||||
"Edit formula": "Formule bewerken",
|
||||
"Edit property": "Eigenschap bewerken",
|
||||
"Export CSV": "CSV exporteren",
|
||||
"Failed to load base": "Base laden mislukt",
|
||||
"False": "Onwaar",
|
||||
"File": "Bestand",
|
||||
"Filter and sort changes are visible only to you": "Wijzigingen in filters en sortering zijn alleen zichtbaar voor jou",
|
||||
"Filter by": "Filteren op",
|
||||
"Formula": "Formule",
|
||||
"Get started with": "Aan de slag met",
|
||||
"Greater than": "Groter dan",
|
||||
"Group by": "Groeperen op",
|
||||
"Group this board by a select or status property.": "Groepeer dit bord op een selectie- of statuseigenschap.",
|
||||
"Hide all": "Alles verbergen",
|
||||
"Hide group": "Groep verbergen",
|
||||
"In Progress": "In uitvoering",
|
||||
"In progress": "In uitvoering",
|
||||
"Include time": "Tijd opnemen",
|
||||
"Insert a kanban board on this page": "Voeg een kanbanbord in op deze pagina",
|
||||
"Insert an inline base on this page": "Voeg een inline base in op deze pagina",
|
||||
"Is": "Is",
|
||||
"Is after": "Is na",
|
||||
"Is any of": "Is een van",
|
||||
"Is before": "Is vóór",
|
||||
"Is empty": "Is leeg",
|
||||
"Is none of": "Is geen van",
|
||||
"Is not": "Is niet",
|
||||
"Is not empty": "Is niet leeg",
|
||||
"Is on or after": "Is op of na",
|
||||
"Is on or before": "Is op of vóór",
|
||||
"Is within": "Is binnen",
|
||||
"Keep editing": "Doorgaan met bewerken",
|
||||
"Last edited at": "Laatst bewerkt op",
|
||||
"Last edited by": "Laatst bewerkt door",
|
||||
"Less than": "Kleiner dan",
|
||||
"Link copied to clipboard": "Link gekopieerd naar klembord",
|
||||
"Local": "Lokaal",
|
||||
"Long text": "Lange tekst",
|
||||
"Moved card to {{column}}": "Kaart verplaatst naar {{column}}",
|
||||
"Moved column to {{column}}": "Kolom verplaatst naar {{column}}",
|
||||
"Multi-select": "Meervoudige selectie",
|
||||
"New row": "Nieuwe rij",
|
||||
"Next month": "Volgende maand",
|
||||
"Next week": "Volgende week",
|
||||
"Next year": "Volgend jaar",
|
||||
"No filters applied": "Geen filters toegepast",
|
||||
"No match": "Geen overeenkomst",
|
||||
"No options for this property type": "Geen opties voor dit eigenschapstype",
|
||||
"No sorts applied": "Geen sorteringen toegepast",
|
||||
"No value": "Geen waarde",
|
||||
"None": "Geen",
|
||||
"Not started": "Niet gestart",
|
||||
"Number": "Getal",
|
||||
"One month ago": "Een maand geleden",
|
||||
"One month from now": "Over een maand",
|
||||
"One week ago": "Een week geleden",
|
||||
"One week from now": "Over een week",
|
||||
"Open as page": "Openen als pagina",
|
||||
"Options": "Opties",
|
||||
"Past month": "Afgelopen maand",
|
||||
"Past week": "Afgelopen week",
|
||||
"Past year": "Afgelopen jaar",
|
||||
"Percent": "Procent",
|
||||
"Period, comma": "Punt, komma",
|
||||
"Person": "Persoon",
|
||||
"Please enter a valid email": "Voer een geldig e-mailadres in",
|
||||
"Progress": "Voortgang",
|
||||
"Property options": "Eigenschapopties",
|
||||
"Relative": "Relatief",
|
||||
"Rename property": "Eigenschap hernoemen",
|
||||
"Reset": "Opnieuw instellen",
|
||||
"Save for everyone": "Opslaan voor iedereen",
|
||||
"Select": "Selecteren",
|
||||
"Select a property": "Selecteer een eigenschap",
|
||||
"Select all loaded rows": "Alle geladen rijen selecteren",
|
||||
"Show all": "Alles tonen",
|
||||
"Sort": "Sorteren",
|
||||
"Sort by": "Sorteren op",
|
||||
"Space, comma": "Spatie, komma",
|
||||
"Space, period": "Spatie, punt",
|
||||
"This board has no grouping property yet.": "Dit bord heeft nog geen groeperingseigenschap.",
|
||||
"This month": "Deze maand",
|
||||
"This year": "Dit jaar",
|
||||
"Thousands and decimal separators": "Scheidingstekens voor duizendtallen en decimalen",
|
||||
"Time format": "Tijdnotatie",
|
||||
"To Do": "Te doen",
|
||||
"Tomorrow": "Morgen",
|
||||
"True": "Waar",
|
||||
"Unsaved changes": "Niet-opgeslagen wijzigingen",
|
||||
"Untitled base": "Base zonder titel",
|
||||
"URL": "URL",
|
||||
"Value": "Waarde",
|
||||
"View updated for everyone": "Weergave bijgewerkt voor iedereen",
|
||||
"You have unsaved changes. Do you want to discard them?": "Je hebt niet-opgeslagen wijzigingen. Wil je deze negeren?",
|
||||
"{{count}} rows deleted_one": "1 rij verwijderd",
|
||||
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
|
||||
"{{count}} selected_one": "1 geselecteerd",
|
||||
"{{count}} selected_other": "{{count}} geselecteerd"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "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",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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",
|
||||
@@ -446,7 +424,6 @@
|
||||
"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",
|
||||
@@ -619,8 +596,6 @@
|
||||
"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",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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",
|
||||
@@ -1004,7 +977,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 for {{title}}": "Menu do chat para {{title}}",
|
||||
"Chat menu": "Menu do chat",
|
||||
"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",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Filtrar",
|
||||
"Page title": "Título da página",
|
||||
"Page content": "Conteúdo da página",
|
||||
"Member actions for {{name}}": "Ações do membro para {{name}}",
|
||||
"Member actions": "Ações do membro",
|
||||
"Toggle password visibility": "Alternar visibilidade da senha",
|
||||
"Send comment": "Enviar comentário",
|
||||
"Token actions": "Ações do token",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"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}}",
|
||||
"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"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "Темная",
|
||||
"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": "Описание",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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": "Избранное",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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": "Преобразовать в",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"now": "сейчас",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "Удалено пользователем",
|
||||
"Deleted at": "Удалено",
|
||||
"Preview": "Предпросмотр",
|
||||
"Base preview unavailable": "Предпросмотр базы недоступен",
|
||||
"Restore this base to view its contents.": "Восстановите эту базу, чтобы просмотреть её содержимое.",
|
||||
"Subpages": "Подстраницы",
|
||||
"Failed to load subpages": "Не удалось загрузить подстраницы",
|
||||
"No subpages": "Нет подстраниц",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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": "Заголовок",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||
"No results found": "Результаты не найдены",
|
||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||
"Chat menu": "Меню чата",
|
||||
"API key menu": "Меню API-ключа",
|
||||
"Jump to comment selection": "Перейти к выбору комментария",
|
||||
"Slash commands": "Команды со слешем",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Фильтр",
|
||||
"Page title": "Заголовок страницы",
|
||||
"Page content": "Содержимое страницы",
|
||||
"Member actions for {{name}}": "Действия с участниками для {{name}}",
|
||||
"Member actions": "Действия с участником",
|
||||
"Toggle password visibility": "Переключить видимость пароля",
|
||||
"Send comment": "Отправить комментарий",
|
||||
"Token actions": "Действия с токеном",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"Allow personal spaces": "Разрешить личные пространства",
|
||||
"Members can create their own personal space.": "Участники могут создавать свои собственные личные пространства.",
|
||||
"Toggle allow personal spaces": "Переключить разрешение личных пространств",
|
||||
"Create personal space": "Создать личное пространство",
|
||||
"Personal space": "Личное пространство",
|
||||
"{{name}}'s space": "Пространство {{name}}",
|
||||
"Apply": "Применить",
|
||||
"Cells that aren't already a page reference will be cleared.": "Ячейки, которые ещё не являются ссылкой на страницу, будут очищены.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Ячейки, которые не содержат допустимый URL, будут очищены.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Ячейки, которые не содержат допустимый адрес электронной почты, будут очищены.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Ячейки, которые нельзя распознать как дату, будут очищены.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Ячейки, которые нельзя распознать как число, будут очищены.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Значения в ячейках будут приведены к типу (yes/true/1 станут отмеченными; всё остальное станет неотмеченным или будет очищено).",
|
||||
"Cells will be reinterpreted under the new type.": "Значения в ячейках будут заново интерпретированы в соответствии с новым типом.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Ячейки будут заменены списком имён файлов, разделённых запятыми.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Ячейки будут заменены списком названий параметров, разделённых запятыми.",
|
||||
"Cells will be replaced with the option name.": "Ячейки будут заменены названием параметра.",
|
||||
"Cells will be replaced with the page title.": "Ячейки будут заменены заголовком страницы.",
|
||||
"Cells will be replaced with the person's name.": "Ячейки будут заменены именем человека.",
|
||||
"Change type": "Изменить тип",
|
||||
"Change type to {{label}}?": "Изменить тип на {{label}}?",
|
||||
"Converting…": "Преобразование…",
|
||||
"Existing values become single-item lists. No data is lost.": "Существующие значения станут одноэлементными списками. Данные не будут потеряны.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Для каждой строки будет сохранён только первый выбранный элемент; остальные будут отброшены.",
|
||||
"Previous record": "Предыдущая запись",
|
||||
"Next record": "Следующая запись",
|
||||
"Record actions": "Действия с записью",
|
||||
"Delete record": "Удалить запись",
|
||||
"Delete record?": "Удалить запись?",
|
||||
"This action cannot be undone.": "Это действие нельзя отменить.",
|
||||
"to navigate": "для навигации",
|
||||
"to close": "для закрытия",
|
||||
"Expand row {{number}}": "Развернуть строку {{number}}",
|
||||
"Saving…": "Сохранение…",
|
||||
"Read-only": "Только для чтения",
|
||||
"Loading…": "Загрузка…",
|
||||
"Updated {{when}}": "Обновлено {{when}}",
|
||||
"Add property": "Добавить свойство",
|
||||
"Create property": "Создать свойство",
|
||||
"Hide properties": "Скрыть свойства",
|
||||
"Find a property type": "Найти тип свойства",
|
||||
"Properties": "Свойства",
|
||||
"A property with this name already exists": "Свойство с таким именем уже существует",
|
||||
"Add card": "Добавить карточку",
|
||||
"Add filter": "Добавить фильтр",
|
||||
"Add option": "Добавить вариант",
|
||||
"Add sort": "Добавить сортировку",
|
||||
"Add view": "Добавить представление",
|
||||
"All data in this column will be lost.": "Все данные в этом столбце будут потеряны.",
|
||||
"Allow multiple people": "Разрешить нескольких пользователей",
|
||||
"Alphabetize": "По алфавиту",
|
||||
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
|
||||
"Ascending": "По возрастанию",
|
||||
"Base deleted": "База удалена",
|
||||
"Base name": "Название базы",
|
||||
"Base table": "Таблица базы",
|
||||
"Card properties": "Свойства карточки",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Ячейки, длина которых превышает ограничение для текста, будут обрезаны, а лишнее содержимое будет безвозвратно потеряно.",
|
||||
"Checkbox": "Флажок",
|
||||
"Checked by default": "Отмечено по умолчанию",
|
||||
"Choose a property": "Выберите свойство",
|
||||
"Clear selection": "Очистить выбор",
|
||||
"Column options": "Параметры столбца",
|
||||
"Comma, period": "Запятая, точка",
|
||||
"Complete": "Завершено",
|
||||
"Contains": "Содержит",
|
||||
"Copy link to view": "Скопировать ссылку на представление",
|
||||
"Create a status property": "Создать свойство статуса",
|
||||
"Created at": "Создано",
|
||||
"Currency": "Валюта",
|
||||
"Decimal places": "Десятичные знаки",
|
||||
"Default value": "Значение по умолчанию",
|
||||
"Delete property": "Удалить свойство",
|
||||
"Delete view": "Удалить представление",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "По убыванию",
|
||||
"Discard": "Отменить",
|
||||
"Doesn't contain": "Не содержит",
|
||||
"Done": "Готово",
|
||||
"Edit formula": "Редактировать формулу",
|
||||
"Edit property": "Редактировать свойство",
|
||||
"Export CSV": "Экспорт CSV",
|
||||
"Failed to load base": "Не удалось загрузить базу",
|
||||
"False": "Ложь",
|
||||
"File": "Файл",
|
||||
"Filter and sort changes are visible only to you": "Изменения фильтра и сортировки видны только вам",
|
||||
"Filter by": "Фильтр по",
|
||||
"Formula": "Формула",
|
||||
"Get started with": "Начать работу с",
|
||||
"Greater than": "Больше чем",
|
||||
"Group by": "Группировать по",
|
||||
"Group this board by a select or status property.": "Сгруппируйте эту доску по свойству выбора или статуса.",
|
||||
"Hide all": "Скрыть все",
|
||||
"Hide group": "Скрыть группу",
|
||||
"In Progress": "В процессе",
|
||||
"In progress": "В процессе",
|
||||
"Include time": "Включить время",
|
||||
"Insert a kanban board on this page": "Вставить канбан-доску на эту страницу",
|
||||
"Insert an inline base on this page": "Вставить встроенную базу на эту страницу",
|
||||
"Is": "Равно",
|
||||
"Is after": "После",
|
||||
"Is any of": "Совпадает с любым из",
|
||||
"Is before": "До",
|
||||
"Is empty": "Пусто",
|
||||
"Is none of": "Не совпадает ни с одним из",
|
||||
"Is not": "Не равно",
|
||||
"Is not empty": "Не пусто",
|
||||
"Is on or after": "В этот день или позже",
|
||||
"Is on or before": "В этот день или раньше",
|
||||
"Is within": "Находится в пределах",
|
||||
"Keep editing": "Продолжить редактирование",
|
||||
"Last edited at": "Последнее изменение",
|
||||
"Last edited by": "Последний изменивший",
|
||||
"Less than": "Меньше чем",
|
||||
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
|
||||
"Local": "Локальный",
|
||||
"Long text": "Длинный текст",
|
||||
"Moved card to {{column}}": "Карточка перемещена в {{column}}",
|
||||
"Moved column to {{column}}": "Столбец перемещён в {{column}}",
|
||||
"Multi-select": "Множественный выбор",
|
||||
"New row": "Новая строка",
|
||||
"Next month": "Следующий месяц",
|
||||
"Next week": "Следующая неделя",
|
||||
"Next year": "Следующий год",
|
||||
"No filters applied": "Фильтры не применены",
|
||||
"No match": "Нет совпадений",
|
||||
"No options for this property type": "Для этого типа свойства нет параметров",
|
||||
"No sorts applied": "Сортировка не применена",
|
||||
"No value": "Нет значения",
|
||||
"None": "Нет",
|
||||
"Not started": "Не начато",
|
||||
"Number": "Число",
|
||||
"One month ago": "Месяц назад",
|
||||
"One month from now": "Через месяц",
|
||||
"One week ago": "Неделю назад",
|
||||
"One week from now": "Через неделю",
|
||||
"Open as page": "Открыть как страницу",
|
||||
"Options": "Параметры",
|
||||
"Past month": "Прошлый месяц",
|
||||
"Past week": "Прошлая неделя",
|
||||
"Past year": "Прошлый год",
|
||||
"Percent": "Процент",
|
||||
"Period, comma": "Точка, запятая",
|
||||
"Person": "Пользователь",
|
||||
"Please enter a valid email": "Пожалуйста, введите корректный адрес электронной почты",
|
||||
"Progress": "Прогресс",
|
||||
"Property options": "Параметры свойства",
|
||||
"Relative": "Относительный",
|
||||
"Rename property": "Переименовать свойство",
|
||||
"Reset": "Сбросить",
|
||||
"Save for everyone": "Сохранить для всех",
|
||||
"Select": "Выбор",
|
||||
"Select a property": "Выберите свойство",
|
||||
"Select all loaded rows": "Выбрать все загруженные строки",
|
||||
"Show all": "Показать все",
|
||||
"Sort": "Сортировка",
|
||||
"Sort by": "Сортировать по",
|
||||
"Space, comma": "Пробел, запятая",
|
||||
"Space, period": "Пробел, точка",
|
||||
"This board has no grouping property yet.": "У этой доски пока нет свойства для группировки.",
|
||||
"This month": "Этот месяц",
|
||||
"This year": "Этот год",
|
||||
"Thousands and decimal separators": "Разделители тысяч и десятичных знаков",
|
||||
"Time format": "Формат времени",
|
||||
"To Do": "К выполнению",
|
||||
"Tomorrow": "Завтра",
|
||||
"True": "Истина",
|
||||
"Unsaved changes": "Несохранённые изменения",
|
||||
"Untitled base": "База без названия",
|
||||
"URL": "URL",
|
||||
"Value": "Значение",
|
||||
"View updated for everyone": "Представление обновлено для всех",
|
||||
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "Темна",
|
||||
"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": "Опис",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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": "Обране",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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": "Перетворити",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "Назви не збігаються",
|
||||
"Today, {{time}}": "Сьогодні, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчора, {{time}}",
|
||||
"now": "зараз",
|
||||
"Space created successfully": "Простір успішно створено",
|
||||
"Space updated successfully": "Простір успішно оновлено",
|
||||
"Space deleted successfully": "Простір успішно видалено",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "Видалив",
|
||||
"Deleted at": "Видалено",
|
||||
"Preview": "Попередній перегляд",
|
||||
"Base preview unavailable": "Попередній перегляд бази недоступний",
|
||||
"Restore this base to view its contents.": "Відновіть цю базу, щоб переглянути її вміст.",
|
||||
"Subpages": "Підсторінки",
|
||||
"Failed to load subpages": "Не вдалося завантажити підсторінки",
|
||||
"No subpages": "Немає підсторінок",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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": "Назва",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "Шукати сторінки та простори...",
|
||||
"No results found": "Результати не знайдено",
|
||||
"You don't have permission to create pages here": "У вас немає дозволу на створення сторінок тут",
|
||||
"Chat menu for {{title}}": "Меню чату для {{title}}",
|
||||
"Chat menu": "Меню чату",
|
||||
"API key menu": "Меню ключа API",
|
||||
"Jump to comment selection": "Перейти до вибору коментаря",
|
||||
"Slash commands": "Слеш-команди",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "Фільтр",
|
||||
"Page title": "Назва сторінки",
|
||||
"Page content": "Вміст сторінки",
|
||||
"Member actions for {{name}}": "Дії учасника для {{name}}",
|
||||
"Member actions": "Дії з учасником",
|
||||
"Toggle password visibility": "Перемкнути видимість пароля",
|
||||
"Send comment": "Надіслати коментар",
|
||||
"Token actions": "Дії з токеном",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
|
||||
"Page menu for {{name}}": "Меню сторінки для {{name}}",
|
||||
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
|
||||
"Allow personal spaces": "Дозволити особисті простори",
|
||||
"Members can create their own personal space.": "Учасники можуть створювати власний особистий простір.",
|
||||
"Toggle allow personal spaces": "Перемкнути дозвіл на особисті простори",
|
||||
"Create personal space": "Створити особистий простір",
|
||||
"Personal space": "Особистий простір",
|
||||
"{{name}}'s space": "Простір {{name}}",
|
||||
"Apply": "Застосувати",
|
||||
"Cells that aren't already a page reference will be cleared.": "Клітинки, які ще не є посиланням на сторінку, буде очищено.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Клітинки, які не є дійсною URL-адресою, буде очищено.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Клітинки, які не є дійсною електронною адресою, буде очищено.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Клітинки, які не вдається розпізнати як дату, буде очищено.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Клітинки, які не вдається розпізнати як число, буде очищено.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Клітинки буде приведено до потрібного формату (yes/true/1 стануть позначеними; усе інше стане непозначеним або буде очищено).",
|
||||
"Cells will be reinterpreted under the new type.": "Клітинки буде повторно інтерпретовано відповідно до нового типу.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Клітинки буде замінено списком імен файлів, розділених комами.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Клітинки буде замінено списком назв параметрів, розділених комами.",
|
||||
"Cells will be replaced with the option name.": "Клітинки буде замінено назвою параметра.",
|
||||
"Cells will be replaced with the page title.": "Клітинки буде замінено заголовком сторінки.",
|
||||
"Cells will be replaced with the person's name.": "Клітинки буде замінено іменем особи.",
|
||||
"Change type": "Змінити тип",
|
||||
"Change type to {{label}}?": "Змінити тип на {{label}}?",
|
||||
"Converting…": "Перетворення…",
|
||||
"Existing values become single-item lists. No data is lost.": "Наявні значення стануть одноелементними списками. Дані не буде втрачено.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Для кожного рядка буде збережено лише перший вибраний елемент; решту буде відкинуто.",
|
||||
"Previous record": "Попередній запис",
|
||||
"Next record": "Наступний запис",
|
||||
"Record actions": "Дії із записом",
|
||||
"Delete record": "Видалити запис",
|
||||
"Delete record?": "Видалити запис?",
|
||||
"This action cannot be undone.": "Цю дію неможливо скасувати.",
|
||||
"to navigate": "для навігації",
|
||||
"to close": "для закриття",
|
||||
"Expand row {{number}}": "Розгорнути рядок {{number}}",
|
||||
"Saving…": "Збереження…",
|
||||
"Read-only": "Лише читання",
|
||||
"Loading…": "Завантаження…",
|
||||
"Updated {{when}}": "Оновлено {{when}}",
|
||||
"Add property": "Додати властивість",
|
||||
"Create property": "Створити властивість",
|
||||
"Hide properties": "Приховати властивості",
|
||||
"Find a property type": "Знайти тип властивості",
|
||||
"Properties": "Властивості",
|
||||
"A property with this name already exists": "Властивість із такою назвою вже існує",
|
||||
"Add card": "Додати картку",
|
||||
"Add filter": "Додати фільтр",
|
||||
"Add option": "Додати опцію",
|
||||
"Add sort": "Додати сортування",
|
||||
"Add view": "Додати подання",
|
||||
"All data in this column will be lost.": "Усі дані в цьому стовпці буде втрачено.",
|
||||
"Allow multiple people": "Дозволити кількох людей",
|
||||
"Alphabetize": "За алфавітом",
|
||||
"Are you sure you want to delete": "Ви впевнені, що хочете видалити",
|
||||
"Ascending": "За зростанням",
|
||||
"Base deleted": "Базу видалено",
|
||||
"Base name": "Назва бази",
|
||||
"Base table": "Таблиця бази",
|
||||
"Card properties": "Властивості картки",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Клітинки, довші за обмеження для тексту, буде обрізано, а зайвий вміст буде безповоротно втрачено.",
|
||||
"Checkbox": "Прапорець",
|
||||
"Checked by default": "Позначено за замовчуванням",
|
||||
"Choose a property": "Оберіть властивість",
|
||||
"Clear selection": "Очистити вибір",
|
||||
"Column options": "Параметри стовпця",
|
||||
"Comma, period": "Кома, крапка",
|
||||
"Complete": "Завершено",
|
||||
"Contains": "Містить",
|
||||
"Copy link to view": "Скопіювати посилання на подання",
|
||||
"Create a status property": "Створити властивість статусу",
|
||||
"Created at": "Створено",
|
||||
"Currency": "Валюта",
|
||||
"Decimal places": "Десяткові знаки",
|
||||
"Default value": "Значення за замовчуванням",
|
||||
"Delete property": "Видалити властивість",
|
||||
"Delete view": "Видалити подання",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "За спаданням",
|
||||
"Discard": "Скасувати",
|
||||
"Doesn't contain": "Не містить",
|
||||
"Done": "Готово",
|
||||
"Edit formula": "Редагувати формулу",
|
||||
"Edit property": "Редагувати властивість",
|
||||
"Export CSV": "Експорт CSV",
|
||||
"Failed to load base": "Не вдалося завантажити базу",
|
||||
"False": "Хибність",
|
||||
"File": "Файл",
|
||||
"Filter and sort changes are visible only to you": "Зміни фільтрів і сортування видимі лише вам",
|
||||
"Filter by": "Фільтрувати за",
|
||||
"Formula": "Формула",
|
||||
"Get started with": "Почніть із",
|
||||
"Greater than": "Більше ніж",
|
||||
"Group by": "Групувати за",
|
||||
"Group this board by a select or status property.": "Згрупуйте цю дошку за властивістю вибору або статусу.",
|
||||
"Hide all": "Приховати все",
|
||||
"Hide group": "Приховати групу",
|
||||
"In Progress": "У процесі",
|
||||
"In progress": "У процесі",
|
||||
"Include time": "Включити час",
|
||||
"Insert a kanban board on this page": "Вставити канбан-дошку на цю сторінку",
|
||||
"Insert an inline base on this page": "Вставити вбудовану базу на цю сторінку",
|
||||
"Is": "Є",
|
||||
"Is after": "Після",
|
||||
"Is any of": "Є одним із",
|
||||
"Is before": "До",
|
||||
"Is empty": "Порожнє",
|
||||
"Is none of": "Не є жодним із",
|
||||
"Is not": "Не є",
|
||||
"Is not empty": "Не порожнє",
|
||||
"Is on or after": "У цей день або пізніше",
|
||||
"Is on or before": "У цей день або раніше",
|
||||
"Is within": "У межах",
|
||||
"Keep editing": "Продовжити редагування",
|
||||
"Last edited at": "Востаннє відредаговано",
|
||||
"Last edited by": "Востаннє відредагував",
|
||||
"Less than": "Менше ніж",
|
||||
"Link copied to clipboard": "Посилання скопійовано в буфер обміну",
|
||||
"Local": "Локальний",
|
||||
"Long text": "Довгий текст",
|
||||
"Moved card to {{column}}": "Картку переміщено до {{column}}",
|
||||
"Moved column to {{column}}": "Стовпець переміщено до {{column}}",
|
||||
"Multi-select": "Мультивибір",
|
||||
"New row": "Новий рядок",
|
||||
"Next month": "Наступного місяця",
|
||||
"Next week": "Наступного тижня",
|
||||
"Next year": "Наступного року",
|
||||
"No filters applied": "Фільтри не застосовано",
|
||||
"No match": "Немає збігів",
|
||||
"No options for this property type": "Немає опцій для цього типу властивості",
|
||||
"No sorts applied": "Сортування не застосовано",
|
||||
"No value": "Немає значення",
|
||||
"None": "Немає",
|
||||
"Not started": "Не розпочато",
|
||||
"Number": "Число",
|
||||
"One month ago": "Місяць тому",
|
||||
"One month from now": "Через місяць",
|
||||
"One week ago": "Тиждень тому",
|
||||
"One week from now": "Через тиждень",
|
||||
"Open as page": "Відкрити як сторінку",
|
||||
"Options": "Опції",
|
||||
"Past month": "За минулий місяць",
|
||||
"Past week": "За минулий тиждень",
|
||||
"Past year": "За минулий рік",
|
||||
"Percent": "Відсоток",
|
||||
"Period, comma": "Крапка, кома",
|
||||
"Person": "Особа",
|
||||
"Please enter a valid email": "Будь ласка, введіть коректну електронну адресу",
|
||||
"Progress": "Прогрес",
|
||||
"Property options": "Параметри властивості",
|
||||
"Relative": "Відносний",
|
||||
"Rename property": "Перейменувати властивість",
|
||||
"Reset": "Скинути",
|
||||
"Save for everyone": "Зберегти для всіх",
|
||||
"Select": "Вибір",
|
||||
"Select a property": "Виберіть властивість",
|
||||
"Select all loaded rows": "Вибрати всі завантажені рядки",
|
||||
"Show all": "Показати все",
|
||||
"Sort": "Сортування",
|
||||
"Sort by": "Сортувати за",
|
||||
"Space, comma": "Пробіл, кома",
|
||||
"Space, period": "Пробіл, крапка",
|
||||
"This board has no grouping property yet.": "Ця дошка ще не має властивості групування.",
|
||||
"This month": "Цього місяця",
|
||||
"This year": "Цього року",
|
||||
"Thousands and decimal separators": "Роздільники тисяч і десяткових знаків",
|
||||
"Time format": "Формат часу",
|
||||
"To Do": "До виконання",
|
||||
"Tomorrow": "Завтра",
|
||||
"True": "Істина",
|
||||
"Unsaved changes": "Незбережені зміни",
|
||||
"Untitled base": "База без назви",
|
||||
"URL": "URL",
|
||||
"Value": "Значення",
|
||||
"View updated for everyone": "Подання оновлено для всіх",
|
||||
"You have unsaved changes. Do you want to discard them?": "У вас є незбережені зміни. Хочете їх скасувати?",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
"Apply": "Apply",
|
||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||
"Change type": "Change type",
|
||||
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||
"Converting…": "Converting…",
|
||||
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
"Dark": "深色",
|
||||
"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": "描述",
|
||||
@@ -78,24 +76,6 @@
|
||||
"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": "收藏",
|
||||
@@ -418,8 +398,6 @@
|
||||
"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": "变成",
|
||||
@@ -446,7 +424,6 @@
|
||||
"Names do not match": "名称不匹配",
|
||||
"Today, {{time}}": "今天,{{time}}",
|
||||
"Yesterday, {{time}}": "昨天,{{time}}",
|
||||
"now": "现在",
|
||||
"Space created successfully": "空间创建成功",
|
||||
"Space updated successfully": "空间更新成功",
|
||||
"Space deleted successfully": "空间删除成功",
|
||||
@@ -619,8 +596,6 @@
|
||||
"Deleted by": "删除者",
|
||||
"Deleted at": "删除于",
|
||||
"Preview": "预览",
|
||||
"Base preview unavailable": "无法预览数据库",
|
||||
"Restore this base to view its contents.": "恢复此数据库以查看其内容。",
|
||||
"Subpages": "子页面",
|
||||
"Failed to load subpages": "加载子页面失败",
|
||||
"No subpages": "没有子页面",
|
||||
@@ -990,8 +965,6 @@
|
||||
"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": "标题",
|
||||
@@ -1004,7 +977,7 @@
|
||||
"Search pages and spaces...": "搜索页面和空间……",
|
||||
"No results found": "未找到结果",
|
||||
"You don't have permission to create pages here": "你无权在此处创建页面",
|
||||
"Chat menu for {{title}}": "{{title}} 的聊天菜单",
|
||||
"Chat menu": "聊天菜单",
|
||||
"API key menu": "API 密钥菜单",
|
||||
"Jump to comment selection": "跳转到评论选择",
|
||||
"Slash commands": "斜杠命令",
|
||||
@@ -1090,7 +1063,7 @@
|
||||
"Filter": "筛选",
|
||||
"Page title": "页面标题",
|
||||
"Page content": "页面内容",
|
||||
"Member actions for {{name}}": "{{name}} 的成员操作",
|
||||
"Member actions": "成员操作",
|
||||
"Toggle password visibility": "切换密码可见性",
|
||||
"Send comment": "发送评论",
|
||||
"Token actions": "令牌操作",
|
||||
@@ -1112,182 +1085,22 @@
|
||||
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
|
||||
"Page menu for {{name}}": "{{name}} 的页面菜单",
|
||||
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
|
||||
"Allow personal spaces": "允许个人空间",
|
||||
"Members can create their own personal space.": "成员可以创建自己的个人空间。",
|
||||
"Toggle allow personal spaces": "切换是否允许个人空间",
|
||||
"Create personal space": "创建个人空间",
|
||||
"Personal space": "个人空间",
|
||||
"{{name}}'s space": "{{name}} 的空间",
|
||||
"Apply": "应用",
|
||||
"Cells that aren't already a page reference will be cleared.": "尚未是页面引用的单元格将被清空。",
|
||||
"Cells that aren't a valid URL will be cleared.": "不是有效 URL 的单元格将被清空。",
|
||||
"Cells that aren't a valid email address will be cleared.": "不是有效电子邮件地址的单元格将被清空。",
|
||||
"Cells that can't be parsed as a date will be cleared.": "无法解析为日期的单元格将被清空。",
|
||||
"Cells that can't be parsed as a number will be cleared.": "无法解析为数字的单元格将被清空。",
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "单元格将被强制转换(yes/true/1 将变为选中;其他所有值将变为未选中或被清空)。",
|
||||
"Cells will be reinterpreted under the new type.": "单元格将在新类型下重新解释。",
|
||||
"Cells will be replaced with a comma-separated list of file names.": "单元格将被替换为以逗号分隔的文件名列表。",
|
||||
"Cells will be replaced with a comma-separated list of option names.": "单元格将被替换为以逗号分隔的选项名称列表。",
|
||||
"Cells will be replaced with the option name.": "单元格将被替换为选项名称。",
|
||||
"Cells will be replaced with the page title.": "单元格将被替换为页面标题。",
|
||||
"Cells will be replaced with the person's name.": "单元格将被替换为此人的姓名。",
|
||||
"Change type": "更改类型",
|
||||
"Change type to {{label}}?": "将类型更改为 {{label}}?",
|
||||
"Converting…": "正在转换…",
|
||||
"Existing values become single-item lists. No data is lost.": "现有值将变为只包含一项的列表。不会丢失数据。",
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.": "每行仅保留第一个已选项目;其余项目将被丢弃。",
|
||||
"Previous record": "上一条记录",
|
||||
"Next record": "下一条记录",
|
||||
"Record actions": "记录操作",
|
||||
"Delete record": "删除记录",
|
||||
"Delete record?": "删除记录?",
|
||||
"This action cannot be undone.": "此操作无法撤销。",
|
||||
"to navigate": "用于导航",
|
||||
"to close": "用于关闭",
|
||||
"Expand row {{number}}": "展开第 {{number}} 行",
|
||||
"Saving…": "正在保存…",
|
||||
"Read-only": "只读",
|
||||
"Loading…": "正在加载…",
|
||||
"Updated {{when}}": "更新于 {{when}}",
|
||||
"Add property": "添加属性",
|
||||
"Create property": "创建属性",
|
||||
"Hide properties": "隐藏属性",
|
||||
"Find a property type": "查找属性类型",
|
||||
"Properties": "属性",
|
||||
"A property with this name already exists": "已存在同名属性",
|
||||
"Add card": "添加卡片",
|
||||
"Add filter": "添加筛选",
|
||||
"Add option": "添加选项",
|
||||
"Add sort": "添加排序",
|
||||
"Add view": "添加视图",
|
||||
"All data in this column will be lost.": "此列中的所有数据都将丢失。",
|
||||
"Allow multiple people": "允许多人",
|
||||
"Alphabetize": "按字母顺序",
|
||||
"Are you sure you want to delete": "您确定要删除",
|
||||
"Ascending": "升序",
|
||||
"Base deleted": "资料库已删除",
|
||||
"Base name": "资料库名称",
|
||||
"Base table": "资料库表",
|
||||
"Card properties": "卡片属性",
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "超出文本长度限制的单元格内容将被截断,额外内容将永久丢失。",
|
||||
"Checkbox": "复选框",
|
||||
"Checked by default": "默认选中",
|
||||
"Choose a property": "选择一个属性",
|
||||
"Clear selection": "清除选择",
|
||||
"Column options": "列选项",
|
||||
"Comma, period": "逗号、句点",
|
||||
"Complete": "完成",
|
||||
"Contains": "包含",
|
||||
"Copy link to view": "复制视图链接",
|
||||
"Create a status property": "创建状态属性",
|
||||
"Created at": "创建于",
|
||||
"Currency": "货币",
|
||||
"Decimal places": "小数位数",
|
||||
"Default value": "默认值",
|
||||
"Delete property": "删除属性",
|
||||
"Delete view": "删除视图",
|
||||
"Delete {{count}} rows?_one": "删除 1 行?",
|
||||
"Delete {{count}} rows?_other": "删除 {{count}} 行?",
|
||||
"Descending": "降序",
|
||||
"Discard": "放弃",
|
||||
"Doesn't contain": "不包含",
|
||||
"Done": "完成",
|
||||
"Edit formula": "编辑公式",
|
||||
"Edit property": "编辑属性",
|
||||
"Export CSV": "导出 CSV",
|
||||
"Failed to load base": "加载资料库失败",
|
||||
"False": "假",
|
||||
"File": "文件",
|
||||
"Filter and sort changes are visible only to you": "筛选和排序更改仅对您可见",
|
||||
"Filter by": "筛选条件",
|
||||
"Formula": "公式",
|
||||
"Get started with": "开始使用",
|
||||
"Greater than": "大于",
|
||||
"Group by": "分组依据",
|
||||
"Group this board by a select or status property.": "按选择或状态属性对此看板进行分组。",
|
||||
"Hide all": "全部隐藏",
|
||||
"Hide group": "隐藏分组",
|
||||
"In Progress": "进行中",
|
||||
"In progress": "进行中",
|
||||
"Include time": "包含时间",
|
||||
"Insert a kanban board on this page": "在此页面插入一个看板",
|
||||
"Insert an inline base on this page": "在此页面插入一个内联资料库",
|
||||
"Is": "是",
|
||||
"Is after": "在此之后",
|
||||
"Is any of": "是其中之一",
|
||||
"Is before": "在此之前",
|
||||
"Is empty": "为空",
|
||||
"Is none of": "都不是",
|
||||
"Is not": "不是",
|
||||
"Is not empty": "不为空",
|
||||
"Is on or after": "在此日期或之后",
|
||||
"Is on or before": "在此日期或之前",
|
||||
"Is within": "在范围内",
|
||||
"Keep editing": "继续编辑",
|
||||
"Last edited at": "最后编辑于",
|
||||
"Last edited by": "最后编辑者",
|
||||
"Less than": "小于",
|
||||
"Link copied to clipboard": "链接已复制到剪贴板",
|
||||
"Local": "本地",
|
||||
"Long text": "长文本",
|
||||
"Moved card to {{column}}": "已将卡片移动到 {{column}}",
|
||||
"Moved column to {{column}}": "已将列移动到 {{column}}",
|
||||
"Multi-select": "多选",
|
||||
"New row": "新行",
|
||||
"Next month": "下个月",
|
||||
"Next week": "下周",
|
||||
"Next year": "明年",
|
||||
"No filters applied": "未应用筛选",
|
||||
"No match": "无匹配项",
|
||||
"No options for this property type": "此属性类型没有可用选项",
|
||||
"No sorts applied": "未应用排序",
|
||||
"No value": "无值",
|
||||
"None": "无",
|
||||
"Not started": "未开始",
|
||||
"Number": "数字",
|
||||
"One month ago": "一个月前",
|
||||
"One month from now": "从现在起一个月后",
|
||||
"One week ago": "一周前",
|
||||
"One week from now": "从现在起一周后",
|
||||
"Open as page": "作为页面打开",
|
||||
"Options": "选项",
|
||||
"Past month": "过去一个月",
|
||||
"Past week": "过去一周",
|
||||
"Past year": "过去一年",
|
||||
"Percent": "百分比",
|
||||
"Period, comma": "句点、逗号",
|
||||
"Person": "人员",
|
||||
"Please enter a valid email": "请输入有效的电子邮件地址",
|
||||
"Progress": "进度",
|
||||
"Property options": "属性选项",
|
||||
"Relative": "相对",
|
||||
"Rename property": "重命名属性",
|
||||
"Reset": "重置",
|
||||
"Save for everyone": "保存给所有人",
|
||||
"Select": "选择",
|
||||
"Select a property": "选择一个属性",
|
||||
"Select all loaded rows": "选择所有已加载的行",
|
||||
"Show all": "显示全部",
|
||||
"Sort": "排序",
|
||||
"Sort by": "排序依据",
|
||||
"Space, comma": "空格、逗号",
|
||||
"Space, period": "空格、句点",
|
||||
"This board has no grouping property yet.": "此看板尚无分组属性。",
|
||||
"This month": "本月",
|
||||
"This year": "今年",
|
||||
"Thousands and decimal separators": "千位分隔符和小数分隔符",
|
||||
"Time format": "时间格式",
|
||||
"To Do": "待办",
|
||||
"Tomorrow": "明天",
|
||||
"True": "真",
|
||||
"Unsaved changes": "未保存的更改",
|
||||
"Untitled base": "未命名资料库",
|
||||
"URL": "网址",
|
||||
"Value": "值",
|
||||
"View updated for everyone": "已为所有人更新视图",
|
||||
"You have unsaved changes. Do you want to discard them?": "您有未保存的更改。要放弃这些更改吗?",
|
||||
"{{count}} rows deleted_one": "已删除 1 行",
|
||||
"{{count}} rows deleted_other": "已删除 {{count}} 行",
|
||||
"{{count}} selected_one": "已选择 1 项",
|
||||
"{{count}} selected_other": "已选择 {{count}} 项"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import BasePage from "@/ee/base/pages/base-page.tsx";
|
||||
import BasePage from "@/pages/base/base-page.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
|
||||
@@ -6,21 +6,13 @@ import {
|
||||
Select,
|
||||
Switch,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
exportPage,
|
||||
exportPageToDocx,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { exportPage } 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;
|
||||
@@ -40,25 +32,17 @@ 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") {
|
||||
if (format === ExportFormat.Docx) {
|
||||
await exportPageToDocx({ pageId: id });
|
||||
} else {
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
await exportPage({
|
||||
pageId: id,
|
||||
format,
|
||||
includeChildren,
|
||||
includeAttachments,
|
||||
});
|
||||
}
|
||||
if (type === "space") {
|
||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||
@@ -104,15 +88,10 @@ export default function ExportModal({
|
||||
<div>
|
||||
<Text size="md">{t("Format")}</Text>
|
||||
</div>
|
||||
<ExportFormatSelection
|
||||
format={format}
|
||||
onChange={handleChange}
|
||||
includeDocx={type === "page"}
|
||||
docxEntitled={docxEntitled}
|
||||
/>
|
||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
{type === "page" && !isDocx && (
|
||||
{type === "page" && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
|
||||
@@ -164,16 +143,7 @@ export default function ExportModal({
|
||||
<Button onClick={onClose} variant="default">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
loading={isExporting}
|
||||
disabled={blockedByLicense}
|
||||
data-disabled={blockedByLicense || undefined}
|
||||
>
|
||||
{t("Export")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||
</Group>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
@@ -184,49 +154,23 @@ export default function ExportModal({
|
||||
interface ExportFormatSelection {
|
||||
format: ExportFormat;
|
||||
onChange: (value: string) => void;
|
||||
includeDocx?: boolean;
|
||||
docxEntitled?: boolean;
|
||||
}
|
||||
function ExportFormatSelection({
|
||||
format,
|
||||
onChange,
|
||||
includeDocx,
|
||||
docxEntitled,
|
||||
}: ExportFormatSelection) {
|
||||
function ExportFormatSelection({ format, onChange }: 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={data}
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={format}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||
comboboxProps={{ width: 200 }}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import { IconFileDescription, IconTable } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
icon?: string | null;
|
||||
isBase?: boolean;
|
||||
};
|
||||
|
||||
export function PageListIcon({ icon, isBase }: Props) {
|
||||
if (icon) {
|
||||
return <>{icon}</>;
|
||||
}
|
||||
return (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
Table,
|
||||
ThemeIcon,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { PageListIcon } from "@/components/common/page-list-icon";
|
||||
import { IconFiles } from "@tabler/icons-react";
|
||||
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -50,10 +50,14 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<PageListIcon icon={page.icon} isBase={page.isBase} />
|
||||
{page.icon || (
|
||||
<ThemeIcon variant="transparent" color="gray" size={18}>
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
|
||||
<Text fw={500} size="md" lineClamp={1}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
{page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
|
||||
|
||||
<Divider my="xs" />
|
||||
<div className={classes.section}>
|
||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||
<Text 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,16 +15,9 @@ 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";
|
||||
@@ -43,20 +36,11 @@ 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>
|
||||
@@ -131,26 +115,6 @@ 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} />}>
|
||||
@@ -196,8 +160,5 @@ export default function TopMenu() {
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,10 +16,13 @@ interface CustomAvatarProps {
|
||||
mt?: string | number;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// `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).
|
||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||
"blue.8",
|
||||
"cyan.9",
|
||||
@@ -51,24 +54,12 @@ function sanitizeInitialsSource(name: string) {
|
||||
export const CustomAvatar = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
CustomAvatarProps
|
||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
||||
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||
const isInitials = !color || color === "initials";
|
||||
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
||||
const hue = pickedColor.split(".")[0];
|
||||
const resolvedColor =
|
||||
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||
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}
|
||||
@@ -76,8 +67,6 @@ 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, Divider } from "@mantine/core";
|
||||
import { Modal, Button, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationPicker } from "./destination-picker";
|
||||
import {
|
||||
@@ -52,9 +52,7 @@ export function DestinationPickerModal({
|
||||
searchSpacesOnly={searchSpacesOnly}
|
||||
/>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.selectedIndicator {
|
||||
padding: 8px 12px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
|
||||
@@ -221,6 +221,14 @@ export function DestinationPicker({
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{selection && (
|
||||
<div className={classes.selectedIndicator}>
|
||||
{selection.type === "space"
|
||||
? selection.space.name
|
||||
: `${selection.space.name} / ${selection.page.title || t("Untitled")}`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ActionIcon } from "@mantine/core";
|
||||
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { getPageTitle } from "@/features/page/page.utils";
|
||||
import { PageChildren } from "./page-children";
|
||||
import classes from "./destination-picker.module.css";
|
||||
|
||||
@@ -96,7 +95,7 @@ export function PageRow({
|
||||
</div>
|
||||
|
||||
<div className={classes.pageTitle}>
|
||||
{getPageTitle(page.title, page.isBase, t)}
|
||||
{page.title || t("Untitled")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
|
||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
||||
export const RadioMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<"button">
|
||||
>((props, ref) => (
|
||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
||||
));
|
||||
|
||||
RadioMenuItem.displayName = "RadioMenuItem";
|
||||
@@ -1,39 +1 @@
|
||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||
|
||||
The Docmost Enterprise License (the “Enterprise License”)
|
||||
Copyright (c) 2023-present Docmost, Inc
|
||||
|
||||
|
||||
With regard to the Docmost Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Docmost Subscription Terms of Service, available
|
||||
at https://docmost.com/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
|
||||
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
|
||||
Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Docmost
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Docmost Enterprise Edition subscription for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Docmost and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Docmost Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||
@@ -66,8 +66,6 @@ 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.
|
||||
@@ -122,7 +120,9 @@ export default function AiChatSidebarItem({
|
||||
className={classes.chatItem}
|
||||
data-active={isActive || undefined}
|
||||
>
|
||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
||||
<span className={classes.chatItemTitle}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</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 for {{title}}", { title: chatTitle })}
|
||||
aria-label={t("Chat menu")}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
||||
import { useCallback, 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,7 +107,6 @@ 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;
|
||||
@@ -177,7 +176,7 @@ export default function ChatInput({
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!editor || editor.isDestroyed || isStreaming) return;
|
||||
if (!editor || isStreaming) return;
|
||||
const json = editor.getJSON();
|
||||
const text = editorJsonToText(json).trim();
|
||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||
@@ -264,7 +263,7 @@ export default function ChatInput({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && autofocus) {
|
||||
if (editor && autofocus) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
}, [editor]);
|
||||
@@ -343,7 +342,6 @@ export default function ChatInput({
|
||||
position="top-start"
|
||||
width={220}
|
||||
shadow="md"
|
||||
withRoles={false}
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
@@ -353,17 +351,13 @@ 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 id={plusMenuId} role="menu" p={4}>
|
||||
<Popover.Dropdown p={4}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
@@ -383,7 +377,6 @@ export default function ChatInput({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={classes.plusMenuItem}
|
||||
onClick={() => {
|
||||
editor?.commands.insertContent("@");
|
||||
@@ -392,7 +385,7 @@ export default function ChatInput({
|
||||
}}
|
||||
>
|
||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||
{t("Mention a page")}
|
||||
Mention a page
|
||||
</button>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
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,14 +1,7 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, TextInput } from "@mantine/core";
|
||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconArrowUp } from "@tabler/icons-react";
|
||||
@@ -21,7 +14,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, isEditorReady } from "@docmost/editor-ext";
|
||||
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface EditorAiMenuProps {
|
||||
@@ -56,7 +49,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
});
|
||||
}, [prompt, output, activeCommandSet]);
|
||||
const updateMenuPlacement = useCallback(() => {
|
||||
if (!isEditorReady(editor) || !showAiMenu) return;
|
||||
if (!editor || !showAiMenu) return;
|
||||
|
||||
const { view } = editor;
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -102,7 +95,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
const handleGenerate = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
if (!isEditorReady(editor) || isLoading) return;
|
||||
if (!editor || isLoading) return;
|
||||
|
||||
let command: CommandItem | null = item || null;
|
||||
|
||||
@@ -165,7 +158,6 @@ 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) {
|
||||
@@ -191,7 +183,6 @@ 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()
|
||||
@@ -255,7 +246,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (!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,11 +23,10 @@ export function ApiKeyTable({
|
||||
onRevoke,
|
||||
}: ApiKeyTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return t("Never");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CreateApiKeyModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateApiKeyModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const [expirationOption, setExpirationOption] = useState<string>("30");
|
||||
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function CreateApiKeyModal({
|
||||
const getExpirationLabel = (days: number) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
const formatted = date.toLocaleDateString(i18n.language, {
|
||||
const formatted = date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
|
||||
{expandable && (
|
||||
<Table.Tr className={classes.detailRow}>
|
||||
<Table.Td colSpan={4} p={0}>
|
||||
<Collapse expanded={isExpanded}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai/utils";
|
||||
import type { RowReferences } from "@/ee/base/types/base.types";
|
||||
|
||||
// Per-base normalized store of resolved reference entities, hydrated from each
|
||||
// rows-page `references`. Keyed by pageId, matching base-atoms.ts.
|
||||
export const referenceStoreAtomFamily = atomFamily((_pageId: string) =>
|
||||
atom<RowReferences>({ users: {}, pages: {} }),
|
||||
);
|
||||
|
||||
export function mergeReferences(
|
||||
prev: RowReferences,
|
||||
next: RowReferences | undefined,
|
||||
): RowReferences {
|
||||
if (!next) return prev;
|
||||
return {
|
||||
users: { ...prev.users, ...next.users },
|
||||
pages: { ...prev.pages, ...next.pages },
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import {
|
||||
usePageQuery,
|
||||
useUpdateTitlePageMutation,
|
||||
updatePageData,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { UpdateEvent } from "@/features/websocket/types";
|
||||
import localEmitter from "@/lib/local-emitter";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
// Editable base name for the inline embed. Follows the TitleEditor convention
|
||||
// (updatePageData + localEmitter + websocket emit) so the sidebar and other
|
||||
// clients stay in sync. Standalone pages use the page TitleEditor instead.
|
||||
export function BaseEmbedTitle({ pageId }: { pageId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageQuery({ pageId });
|
||||
const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const [value, setValue] = useState("");
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
// Keep in sync with the persisted title but never clobber active user input.
|
||||
useEffect(() => {
|
||||
if (!focusedRef.current) setValue(page?.title ?? "");
|
||||
}, [page?.title]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!page || trimmed === (page.title ?? "")) return;
|
||||
updateTitleAsync({ pageId, title: trimmed }).then((updated) => {
|
||||
if (updated.title !== trimmed) return;
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: updated.spaceId,
|
||||
entity: ["pages"],
|
||||
id: updated.id,
|
||||
payload: {
|
||||
title: updated.title,
|
||||
slugId: updated.slugId,
|
||||
parentPageId: updated.parentPageId,
|
||||
icon: updated.icon,
|
||||
},
|
||||
};
|
||||
updatePageData(updated);
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
});
|
||||
}, [value, page, pageId, updateTitleAsync, emit]);
|
||||
|
||||
const debouncedCommit = useDebouncedCallback(commit, 500);
|
||||
|
||||
// Force-save any pending edit on unmount (e.g. navigating away mid-type).
|
||||
const commitRef = useRef(commit);
|
||||
useEffect(() => {
|
||||
commitRef.current = commit;
|
||||
}, [commit]);
|
||||
useEffect(() => () => commitRef.current(), []);
|
||||
|
||||
return (
|
||||
<input
|
||||
className={classes.embedTitleInput}
|
||||
value={value}
|
||||
placeholder={t("Untitled base")}
|
||||
aria-label={t("Base name")}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
debouncedCommit();
|
||||
}}
|
||||
onFocus={() => {
|
||||
focusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
focusedRef.current = false;
|
||||
commit();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setValue(page?.title ?? "");
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IconSortAscending,
|
||||
IconFilter,
|
||||
IconEye,
|
||||
IconDownload,
|
||||
IconArrowsDiagonal,
|
||||
IconLayoutColumns,
|
||||
IconAdjustments,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewSortConfig,
|
||||
FilterCondition,
|
||||
FilterGroup,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { exportBaseToCsv } from "@/ee/base/services/base-service";
|
||||
import { getApiErrorMessage } from "@/lib/api-error";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Text, Stack } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
|
||||
import {
|
||||
FilterGroup,
|
||||
ViewSortConfig,
|
||||
EditingCell,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
export function computeVisibleBadgeCount(
|
||||
itemWidths: number[],
|
||||
gap: number,
|
||||
available: number,
|
||||
badgeWidth: number,
|
||||
): number {
|
||||
const count = itemWidths.length;
|
||||
if (count === 0) return 0;
|
||||
if (available <= 0) return count;
|
||||
|
||||
let lineWidth = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
|
||||
}
|
||||
if (lineWidth <= available) return count;
|
||||
|
||||
let used = 0;
|
||||
let fit = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const advance = itemWidths[i] + (i > 0 ? gap : 0);
|
||||
if (used + advance + gap + badgeWidth <= available) {
|
||||
used += advance;
|
||||
fit = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.max(fit, 1);
|
||||
}
|
||||
|
||||
const BADGE_GAP = 4;
|
||||
|
||||
type BadgeOverflowListProps = {
|
||||
chips: ReactElement[];
|
||||
measureKey: string;
|
||||
tooltipLabel?: string;
|
||||
};
|
||||
|
||||
export function BadgeOverflowList({
|
||||
chips,
|
||||
measureKey,
|
||||
tooltipLabel,
|
||||
}: BadgeOverflowListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(chips.length);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const recompute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
|
||||
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
|
||||
setVisibleCount(
|
||||
computeVisibleBadgeCount(
|
||||
chipWidths,
|
||||
BADGE_GAP,
|
||||
container.clientWidth,
|
||||
badgeWidth,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
recompute();
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [measureKey]);
|
||||
|
||||
const visible = chips.slice(0, visibleCount);
|
||||
const overflow = chips.length - visibleCount;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={tooltipLabel}
|
||||
multiline
|
||||
withinPortal
|
||||
openDelay={400}
|
||||
disabled={!tooltipLabel || overflow <= 0}
|
||||
>
|
||||
<div className={cellClasses.badgeGroup} ref={containerRef}>
|
||||
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
|
||||
{chips}
|
||||
<span className={cellClasses.overflowCount}>+{chips.length}</span>
|
||||
</div>
|
||||
{visible}
|
||||
{overflow > 0 && (
|
||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellCheckboxProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
readOnly?: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
|
||||
const checked = value === true;
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
onCommit(!checked);
|
||||
}, [readOnly, checked, onCommit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cellClasses.checkboxCell}
|
||||
onClick={handleChange}
|
||||
style={readOnly ? { cursor: "default" } : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => {}}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: readOnly ? "default" : "pointer",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellCreatedAtProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellCreatedAt({ value }: CellCreatedAtProps) {
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={cellClasses.dateValue}>{formatted}</span>;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellEmailProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const parse = (draft: string) => draft || null;
|
||||
|
||||
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
|
||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||
useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse,
|
||||
rowId,
|
||||
propertyId: property.id,
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="email"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
placeholder="email@example.com"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = toDraft(value);
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
return (
|
||||
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||
<a
|
||||
className={cellClasses.emailLink}
|
||||
href={`mailto:${displayValue}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellLastEditedAtProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={cellClasses.dateValue}>{formatted}</span>;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Group, Tooltip } from "@mantine/core";
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { useReferenceStore } from "@/ee/base/reference/reference-store";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellLastEditedByProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellLastEditedBy({ value, property }: CellLastEditedByProps) {
|
||||
const userId = typeof value === "string" ? value : null;
|
||||
|
||||
const store = useReferenceStore(property.pageId);
|
||||
const user = userId ? store.users[userId] ?? null : null;
|
||||
|
||||
if (!userId) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
const name = user?.name ?? userId.substring(0, 8);
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={20}
|
||||
radius="xl"
|
||||
/>
|
||||
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
|
||||
<span className={cellClasses.lastEditedByName}>{name}</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellLongTextProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onValueChange: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
onTabNavigate?: (shiftKey: boolean) => void;
|
||||
};
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const normalize = (s: string) => {
|
||||
const trimmed = s.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
export function CellLongText({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onValueChange,
|
||||
onCancel,
|
||||
onTabNavigate,
|
||||
}: CellLongTextProps) {
|
||||
const [draft, setDraft] = useState(() => toText(value));
|
||||
const cancelledRef = useRef(false);
|
||||
const committedRef = useRef(false);
|
||||
const wasEditingRef = useRef(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Seed draft and focus on the false->true editing transition only; ignore
|
||||
// value changes mid-edit so the user's typing is not clobbered.
|
||||
useEffect(() => {
|
||||
if (isEditing && !wasEditingRef.current) {
|
||||
cancelledRef.current = false;
|
||||
committedRef.current = false;
|
||||
setDraft(toText(value));
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
}
|
||||
wasEditingRef.current = isEditing;
|
||||
}, [isEditing, value]);
|
||||
|
||||
// Autosave after a typing pause; commit/cancel clear the pending fire so
|
||||
// a closed editor can never write a stale or discarded draft.
|
||||
const debouncedAutosave = useDebouncedCallback(() => {
|
||||
onValueChange(normalize(draft));
|
||||
}, 10_000);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
debouncedAutosave.cancel();
|
||||
onCommit(normalize(draft));
|
||||
};
|
||||
const cancel = () => {
|
||||
cancelledRef.current = true;
|
||||
debouncedAutosave.cancel();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const preview = formatLongTextPreview(toText(value));
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={isEditing}
|
||||
onChange={(opened) => {
|
||||
if (opened) return;
|
||||
// Programmatic close after cancel must not re-commit.
|
||||
if (cancelledRef.current) {
|
||||
cancelledRef.current = false;
|
||||
return;
|
||||
}
|
||||
commit();
|
||||
}}
|
||||
position="bottom-start"
|
||||
width={320}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div className={cellClasses.popoverTargetFlex}>
|
||||
{preview ? (
|
||||
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
|
||||
<span className={cellClasses.longTextPreview}>{preview}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cellClasses.emptyValue} />
|
||||
)}
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cellClasses.longTextDropdown}
|
||||
>
|
||||
{isEditing && (
|
||||
<>
|
||||
<Group justify="flex-end" mb={2}>
|
||||
<CloseButton size="sm" onClick={commit} aria-label="Close" />
|
||||
</Group>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
data-autofocus
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={12}
|
||||
maxLength={25000}
|
||||
variant="unstyled"
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.currentTarget.value);
|
||||
debouncedAutosave();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
onTabNavigate?.(e.shiftKey);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
styles={{ input: { padding: 4 } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
IBaseProperty,
|
||||
NumberTypeOptions,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { formatCurrency } from "@/ee/base/constants/currencies";
|
||||
import { snapNumber } from "@docmost/base-formula/client";
|
||||
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellNumberProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
|
||||
comma_period: { group: ",", decimal: "." },
|
||||
period_comma: { group: ".", decimal: "," },
|
||||
space_comma: { group: " ", decimal: "," },
|
||||
space_period: { group: " ", decimal: "." },
|
||||
};
|
||||
|
||||
function separatorChars(style: string): { group: string; decimal: string } {
|
||||
if (style === "local") {
|
||||
const parts = new Intl.NumberFormat().formatToParts(11111.1);
|
||||
return {
|
||||
group: parts.find((p) => p.type === "group")?.value ?? ",",
|
||||
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
|
||||
};
|
||||
}
|
||||
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
|
||||
}
|
||||
|
||||
function formatPlain(
|
||||
value: number,
|
||||
precision: number | undefined,
|
||||
style: string,
|
||||
): string {
|
||||
const fixed = precision == null ? String(value) : value.toFixed(precision);
|
||||
if (style === "none") return fixed;
|
||||
const { group, decimal } = separatorChars(style);
|
||||
const neg = fixed[0] === "-";
|
||||
const abs = neg ? fixed.slice(1) : fixed;
|
||||
const dot = abs.indexOf(".");
|
||||
const intPart = dot === -1 ? abs : abs.slice(0, dot);
|
||||
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
|
||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
||||
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
|
||||
return neg ? `-${out}` : out;
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
val: number | null | undefined,
|
||||
options: NumberTypeOptions | undefined,
|
||||
): string {
|
||||
if (val == null) return "";
|
||||
const precision = options?.precision;
|
||||
const format = options?.format ?? "plain";
|
||||
const style = options?.separators ?? "none";
|
||||
const v = precision == null ? snapNumber(val) : val;
|
||||
|
||||
switch (format) {
|
||||
case "currency":
|
||||
return formatCurrency(v, options?.currencyCode, precision);
|
||||
case "percent":
|
||||
return `${formatPlain(v, precision, style)}%`;
|
||||
case "progress":
|
||||
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
|
||||
default:
|
||||
return formatPlain(v, precision, style);
|
||||
}
|
||||
}
|
||||
|
||||
const toDraft = (value: unknown) =>
|
||||
typeof value === "number" ? String(value) : "";
|
||||
|
||||
export function sanitizeNumberInput(text: string): string {
|
||||
return text.replace(/[^0-9.-]/g, "");
|
||||
}
|
||||
|
||||
export function parseNumberDraft(draft: string): number | null {
|
||||
const cleaned = sanitizeNumberInput(draft);
|
||||
if (cleaned === "" || cleaned === "-") return null;
|
||||
const parsed = Number(cleaned);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function CellNumber({
|
||||
value,
|
||||
property,
|
||||
rowId,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellNumberProps) {
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||
useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse: parseNumberDraft,
|
||||
rowId,
|
||||
propertyId: property.id,
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
|
||||
setDraft(v);
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart ?? draft.length;
|
||||
const end = el.selectionEnd ?? draft.length;
|
||||
setDraft(
|
||||
draft.slice(0, start) +
|
||||
sanitizeNumberInput(e.clipboardData.getData("text")) +
|
||||
draft.slice(end),
|
||||
);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
if (numValue == null) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoTooltipText
|
||||
className={cellClasses.numberValue}
|
||||
fz="sm"
|
||||
tooltipProps={{ withinPortal: true }}
|
||||
>
|
||||
{formatNumber(numValue, typeOptions)}
|
||||
</AutoTooltipText>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import gridClasses from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type CellTextProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const parse = (draft: string) => draft;
|
||||
|
||||
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
|
||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||
useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse,
|
||||
rowId,
|
||||
propertyId: property.id,
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = toDraft(value);
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
return (
|
||||
<AutoTooltipText
|
||||
className={gridClasses.cellContent}
|
||||
fz="sm"
|
||||
tooltipProps={{ withinPortal: true }}
|
||||
>
|
||||
{displayValue}
|
||||
</AutoTooltipText>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CellUrlProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const parse = (draft: string) => draft || null;
|
||||
|
||||
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
|
||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||
useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse,
|
||||
rowId,
|
||||
propertyId: property.id,
|
||||
});
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
placeholder="https://..."
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = toDraft(value);
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
const safeHref = sanitizeUrl(displayValue);
|
||||
if (!safeHref) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||
<a
|
||||
className={cellClasses.urlLink}
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
function isEmpty(v: unknown): boolean {
|
||||
return v === null || v === undefined || v === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality for cell values. Treats null/undefined/"" as equivalent
|
||||
* "empty", compares arrays by ordered content, objects by serialized form.
|
||||
* Note: an empty array [] is a real value, distinct from "empty".
|
||||
*/
|
||||
export function cellValuesEqual(a: unknown, b: unknown): boolean {
|
||||
const aArr = Array.isArray(a);
|
||||
const bArr = Array.isArray(b);
|
||||
|
||||
if (!aArr && !bArr) {
|
||||
if (isEmpty(a) && isEmpty(b)) return true;
|
||||
if (isEmpty(a) !== isEmpty(b)) return false;
|
||||
}
|
||||
|
||||
if (aArr || bArr) {
|
||||
if (!aArr || !bArr) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((x, i) => cellValuesEqual(x, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === "object" && typeof b === "object" && a && b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
return a === b;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { CSSProperties, useRef, useState } from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type ChoiceBadgeProps = {
|
||||
name: string;
|
||||
style: CSSProperties;
|
||||
};
|
||||
|
||||
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
|
||||
<span
|
||||
ref={ref}
|
||||
className={cellClasses.badge}
|
||||
style={style}
|
||||
onMouseEnter={() => {
|
||||
const el = ref.current;
|
||||
if (el) setTruncated(el.scrollWidth > el.clientWidth);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
Choice,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||
import { 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { UserRef } from "@/ee/base/types/base.types";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type PersonReadListProps = {
|
||||
personIds: string[];
|
||||
users: Record<string, UserRef>;
|
||||
};
|
||||
|
||||
export function PersonReadList({ personIds, users }: PersonReadListProps) {
|
||||
const entries = personIds.map((id) => ({
|
||||
id,
|
||||
name: users[id]?.name ?? id.substring(0, 8),
|
||||
avatarUrl: users[id]?.avatarUrl ?? "",
|
||||
}));
|
||||
const chips = entries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
className={clsx(cellClasses.badge, cellClasses.personChip)}
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={entry.avatarUrl}
|
||||
name={entry.name}
|
||||
size={16}
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<span className={cellClasses.personChipName}>{entry.name}</span>
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<BadgeOverflowList
|
||||
chips={chips}
|
||||
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
|
||||
tooltipLabel={entries.map((e) => e.name).join(", ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { FormulaEditor } from "./formula-editor";
|
||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||
import {
|
||||
IBaseProperty,
|
||||
FormulaTypeOptions,
|
||||
TypeOptions,
|
||||
} from "@/ee/base/types/base.types";
|
||||
|
||||
type Props = {
|
||||
property: IBaseProperty;
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
|
||||
const { data: base } = useBaseQuery(pageId);
|
||||
const updatePropertyMutation = useUpdatePropertyMutation();
|
||||
const opts = property.typeOptions as FormulaTypeOptions | undefined;
|
||||
|
||||
return (
|
||||
<FormulaEditor
|
||||
properties={base?.properties ?? []}
|
||||
editingPropertyId={property.id}
|
||||
initialSource={opts?.source ?? ""}
|
||||
name={property.name}
|
||||
onCancel={onClose}
|
||||
onSave={(source, ast, resultType, dependencies) => {
|
||||
if (source === (opts?.source ?? "")) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
pageId,
|
||||
typeOptions: {
|
||||
source,
|
||||
ast,
|
||||
resultType,
|
||||
dependencies,
|
||||
astVersion: 1,
|
||||
} as TypeOptions,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
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())
|
||||
);
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
observeWindowOffset,
|
||||
observeWindowRect,
|
||||
useVirtualizer,
|
||||
windowScroll,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { useAtom, useSetAtom, type PrimitiveAtom } from "jotai";
|
||||
import {
|
||||
IBaseRow,
|
||||
IBaseProperty,
|
||||
EditingCell,
|
||||
FocusedCell,
|
||||
CellCoord,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import {
|
||||
editingCellAtomFamily,
|
||||
focusedCellAtomFamily,
|
||||
activeFormulaEditorAtomFamily,
|
||||
pendingTypeInsertAtom,
|
||||
type FormulaEditorTarget,
|
||||
type PendingTypeInsert,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
|
||||
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
|
||||
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
|
||||
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
|
||||
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
|
||||
import { GridHeader } from "./grid-header";
|
||||
import { GridRow } from "./grid-row";
|
||||
import { AddRowButton } from "./add-row-button";
|
||||
import { GridGhostRows } from "./grid-ghost-rows";
|
||||
import { SelectionActionBar } from "./selection-action-bar";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useRowExpand } from "@/ee/base/context/row-expand";
|
||||
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
|
||||
// var(--base-row-height) from this constant so the rendered height can
|
||||
// never drift from the virtualizer estimate.
|
||||
const ROW_HEIGHT = 37;
|
||||
const OVERSCAN = 25;
|
||||
|
||||
const GRID_ROOT_STYLE = {
|
||||
"--base-row-height": `${ROW_HEIGHT}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const ADD_COLUMN_TRACK_WIDTH = 40;
|
||||
|
||||
// Hoisted to module scope to avoid allocating a fresh options object on
|
||||
// every GridContainer render. The function refs from virtual-core are
|
||||
// stable; only the wrapper object identity matters for downstream
|
||||
// memoization inside useVirtualizer.
|
||||
const WINDOW_SCROLL_OPTIONS = {
|
||||
observeElementRect: observeWindowRect as never,
|
||||
observeElementOffset: observeWindowOffset as never,
|
||||
scrollToFn: windowScroll as never,
|
||||
} as const;
|
||||
|
||||
type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: (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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type GridGhostRowsProps = {
|
||||
/** how many placeholder rows to render */
|
||||
count: number;
|
||||
/** number of visible leaf columns (incl. the row-number column) */
|
||||
columnCount: number;
|
||||
/** create the first real row (clicking any ghost cell); omit when read-only */
|
||||
onCreate?: () => void;
|
||||
};
|
||||
|
||||
// Empty-state ghost rows shown when no data rows exist and no filter is active.
|
||||
// Clicking any ghost row creates the first real row; cells align via subgrid.
|
||||
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className={`${classes.row} ${classes.ghostRow}`}
|
||||
role={onCreate ? "button" : undefined}
|
||||
aria-label={onCreate ? "Create first row" : undefined}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{Array.from({ length: columnCount }).map((_, colIdx) => (
|
||||
<div key={colIdx} className={classes.cell} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Row, VisibilityState } from "@tanstack/react-table";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { GridCell } from "./grid-cell";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
export const ROW_DRAG_TYPE = "base-row";
|
||||
|
||||
type GridRowProps = {
|
||||
row: Row<IBaseRow>;
|
||||
rowIndex: number;
|
||||
measureRef: (node: Element | null) => void;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onRowReorder?: (
|
||||
rowId: string,
|
||||
targetRowId: string,
|
||||
position: "above" | "below",
|
||||
) => void;
|
||||
properties: IBaseProperty[];
|
||||
columnVisibility: VisibilityState;
|
||||
columnOrder: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
row,
|
||||
rowIndex,
|
||||
measureRef,
|
||||
onCellUpdate,
|
||||
onRowReorder,
|
||||
pageId,
|
||||
}: GridRowProps) {
|
||||
const rowId = row.id;
|
||||
const isSelected = useRowSelection(pageId).isSelected(rowId);
|
||||
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const setRowEl = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
rowRef.current = node;
|
||||
measureRef(node);
|
||||
},
|
||||
[measureRef],
|
||||
);
|
||||
|
||||
// onRowReorder ultimately depends on React Query result objects (activeView,
|
||||
// base) via persistViewConfig, and its identity changes on every WS-driven
|
||||
// cache invalidation. Holding it in a ref keeps it out of the DnD effect's
|
||||
// dep array so we don't tear down and re-register every row's pragmatic-dnd
|
||||
// adapter each time another user edits the base. Same pattern as the column
|
||||
// header's onColumnReorderRef.
|
||||
const onRowReorderRef = useRef(onRowReorder);
|
||||
useLayoutEffect(() => {
|
||||
onRowReorderRef.current = onRowReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const rowEl = rowRef.current;
|
||||
if (!rowEl || !onRowReorder) return;
|
||||
// The whole row is the draggable element (full-row native preview).
|
||||
// dragHandle limits initiation to the grip, leaving cell clicks and
|
||||
// inline editing untouched.
|
||||
const handle = rowEl.querySelector<HTMLElement>(
|
||||
`.${classes.rowNumberDragHandle}`,
|
||||
);
|
||||
if (!handle) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: rowEl,
|
||||
dragHandle: handle,
|
||||
getInitialData: () => ({ type: ROW_DRAG_TYPE, rowId, pageId }),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
// Native preview of the full-width sticky subgrid row rasterizes
|
||||
// garbled (it pulls in surrounding page paint, e.g. the sidebar).
|
||||
// Render a compact card that clones just the title cell instead.
|
||||
const titleCell =
|
||||
rowEl.querySelector<HTMLElement>(`.${classes.primaryCell}`) ??
|
||||
rowEl.querySelector<HTMLElement>(`.${classes.cell}`);
|
||||
if (!titleCell) return;
|
||||
const width = titleCell.getBoundingClientRect().width;
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
|
||||
render: ({ container }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = classes.rowDragPreview;
|
||||
card.style.width = `${width}px`;
|
||||
const clone = titleCell.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "static";
|
||||
clone.style.left = "auto";
|
||||
clone.style.width = "100%";
|
||||
clone.style.opacity = "1";
|
||||
clone.style.borderRight = "none";
|
||||
card.appendChild(clone);
|
||||
container.appendChild(card);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: rowEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === ROW_DRAG_TYPE &&
|
||||
source.data.pageId === pageId &&
|
||||
source.data.rowId !== rowId,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ rowId },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onRowReorderRef.current?.(
|
||||
source.data.rowId as string,
|
||||
rowId,
|
||||
edge === "top" ? "above" : "below",
|
||||
);
|
||||
triggerPostMoveFlash(rowEl);
|
||||
liveRegion.announce("Moved row");
|
||||
},
|
||||
}),
|
||||
);
|
||||
// onRowReorder is read through onRowReorderRef; only its presence gates
|
||||
// registration, and that does not change across a row's mounted life.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowId, pageId]);
|
||||
|
||||
const dropIndicatorClass = closestEdge
|
||||
? closestEdge === "top"
|
||||
? classes.rowDropAbove
|
||||
: classes.rowDropBelow
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRowEl}
|
||||
data-index={rowIndex}
|
||||
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
|
||||
role="row"
|
||||
aria-rowindex={rowIndex + 1}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, colIndex) => (
|
||||
<GridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
rowIndex={rowIndex}
|
||||
colIndex={colIndex}
|
||||
onCellUpdate={onCellUpdate}
|
||||
pageId={pageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
gridRowPropsEqual);
|
||||
|
||||
// row compares by row.original: React Query structural sharing keeps
|
||||
// unchanged rows reference-stable, while TanStack re-instantiates Row/Cell
|
||||
// wrappers on every data change. properties/columnVisibility/columnOrder are
|
||||
// layout busters — schema or column-state changes must re-render rows.
|
||||
function gridRowPropsEqual(prev: GridRowProps, next: GridRowProps) {
|
||||
return (
|
||||
prev.row.id === next.row.id &&
|
||||
prev.row.original === next.row.original &&
|
||||
prev.rowIndex === next.rowIndex &&
|
||||
prev.pageId === next.pageId &&
|
||||
prev.onCellUpdate === next.onCellUpdate &&
|
||||
prev.onRowReorder === next.onRowReorder &&
|
||||
prev.measureRef === next.measureRef &&
|
||||
prev.properties === next.properties &&
|
||||
prev.columnVisibility === next.columnVisibility &&
|
||||
prev.columnOrder === next.columnOrder
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { FocusedCell } from "@/ee/base/types/base.types";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type RowNumberCellProps = {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
isPinned: boolean;
|
||||
pinOffset?: number;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const RowNumberCell = memo(function RowNumberCell({
|
||||
rowId,
|
||||
rowIndex,
|
||||
isPinned,
|
||||
pinOffset,
|
||||
pageId,
|
||||
}: RowNumberCellProps) {
|
||||
const { isSelected, toggle } = useRowSelection(pageId);
|
||||
const selected = isSelected(rowId);
|
||||
const editable = useBaseEditable();
|
||||
const getOrderedRowIds = useGridRowOrder();
|
||||
|
||||
const setFocusedCell = useSetAtom(
|
||||
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
|
||||
);
|
||||
const isFocused = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
focusedCellAtomFamily(pageId),
|
||||
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
|
||||
),
|
||||
[pageId, rowId],
|
||||
),
|
||||
);
|
||||
|
||||
const 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,214 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||
import { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { useKanbanMoveCardMutation } from "@/ee/base/queries/base-row-query";
|
||||
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
|
||||
import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
|
||||
import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
|
||||
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
|
||||
import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
|
||||
import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
|
||||
import classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type BaseKanbanProps = {
|
||||
base: IBase;
|
||||
view: IBaseView;
|
||||
pageId: string;
|
||||
embedded?: boolean;
|
||||
editable: boolean;
|
||||
viewFilter: FilterGroup | undefined;
|
||||
};
|
||||
|
||||
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
|
||||
const { t } = useTranslation();
|
||||
const { groupByPropertyId, 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>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import { Text, Badge, Tooltip, Group } from "@mantine/core";
|
||||
import { IconCheck, IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
isFormulaErrorCell,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
|
||||
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
||||
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
|
||||
import {
|
||||
formatNumber,
|
||||
formatDateDisplay,
|
||||
formatTimestamp,
|
||||
formatLongTextPreview,
|
||||
} from "@/ee/base/formatters/cell-formatters";
|
||||
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
|
||||
import { FileValue } from "@/ee/base/components/cells/cell-file";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type CardFieldProps = {
|
||||
property: IBaseProperty;
|
||||
value: unknown;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export function CardField({ property, value, pageId }: CardFieldProps) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (Array.isArray(value) && value.length === 0) return null;
|
||||
|
||||
switch (property.type) {
|
||||
case "text":
|
||||
return <TextField value={value} />;
|
||||
case "longText":
|
||||
return <LongTextField value={value} />;
|
||||
case "number":
|
||||
return <NumberField value={value} property={property} />;
|
||||
case "select":
|
||||
case "status":
|
||||
return <SelectField value={value} property={property} />;
|
||||
case "multiSelect":
|
||||
return <MultiSelectField value={value} property={property} />;
|
||||
case "date":
|
||||
return <DateField value={value} property={property} />;
|
||||
case "createdAt":
|
||||
case "lastEditedAt":
|
||||
return <TimestampField value={value} />;
|
||||
case "person":
|
||||
return <PersonField value={value} pageId={pageId} />;
|
||||
case "lastEditedBy":
|
||||
return <LastEditedByField value={value} pageId={pageId} />;
|
||||
case "file":
|
||||
return <FileField value={value} />;
|
||||
case "page":
|
||||
return <PageField value={value} basePageId={pageId} propertyPageId={property.pageId} />;
|
||||
case "checkbox":
|
||||
return <CheckboxField value={value} />;
|
||||
case "url":
|
||||
return <UrlField value={value} />;
|
||||
case "email":
|
||||
return <EmailField value={value} />;
|
||||
case "formula":
|
||||
return <FormulaField value={value} property={property} />;
|
||||
default:
|
||||
return (
|
||||
<Text size="xs" lineClamp={1}>
|
||||
{String(value)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function TextField({ value }: { value: unknown }) {
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<Text size="sm" lineClamp={2}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function LongTextField({ value }: { value: unknown }) {
|
||||
const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{preview}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||
const num = typeof value === "number" ? value : null;
|
||||
if (num === null) return null;
|
||||
const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
|
||||
if (!formatted) return null;
|
||||
return <Text size="sm">{formatted}</Text>;
|
||||
}
|
||||
|
||||
function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||
const selectedId = typeof value === "string" ? value : null;
|
||||
const choice = choices.find((c) => c.id === selectedId);
|
||||
if (!choice) return null;
|
||||
return (
|
||||
<ChoiceBadge
|
||||
name={choice.name}
|
||||
style={{ ...choiceColor(choice.color), alignSelf: "flex-start" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
|
||||
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
|
||||
if (selectedChoices.length === 0) return null;
|
||||
const chips = selectedChoices.map((choice) => (
|
||||
<span key={choice.id} className={cellClasses.badge} style={choiceColor(choice.color)}>
|
||||
{choice.name}
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<BadgeOverflowList
|
||||
chips={chips}
|
||||
measureKey={selectedChoices.map((c) => `${c.id}:${c.name}`).join("|")}
|
||||
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||
const dateStr = typeof value === "string" ? value : null;
|
||||
const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
|
||||
if (!formatted) return null;
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatted}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function TimestampField({ value }: { value: unknown }) {
|
||||
const formatted = formatTimestamp(typeof value === "string" ? value : null);
|
||||
if (!formatted) return null;
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatted}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
|
||||
const store = useReferenceStore(pageId);
|
||||
const personIds = Array.isArray(value)
|
||||
? (value as string[])
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
if (personIds.length === 0) return null;
|
||||
return <PersonReadList personIds={personIds} users={store.users} />;
|
||||
}
|
||||
|
||||
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
|
||||
const userId = typeof value === "string" ? value : null;
|
||||
const store = useReferenceStore(pageId);
|
||||
if (!userId) return null;
|
||||
const user = store.users[userId] ?? null;
|
||||
const name = user?.name ?? userId.substring(0, 8);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<CustomAvatar avatarUrl={user?.avatarUrl ?? ""} name={name} size={20} radius="xl" />
|
||||
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
|
||||
<Text size="xs" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function FileField({ value }: { value: unknown }) {
|
||||
const files = Array.isArray(value)
|
||||
? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
|
||||
: [];
|
||||
if (files.length === 0) return null;
|
||||
const maxVisible = 2;
|
||||
const visible = files.slice(0, maxVisible);
|
||||
const overflow = files.length - maxVisible;
|
||||
return (
|
||||
<div className={cellClasses.fileGroup}>
|
||||
{visible.map((file) => (
|
||||
<span key={file.id} className={cellClasses.fileBadge}>
|
||||
{file.fileName}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && <span className={cellClasses.overflowCount}>+{overflow}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageField({
|
||||
value,
|
||||
basePageId,
|
||||
propertyPageId,
|
||||
}: {
|
||||
value: unknown;
|
||||
basePageId: string;
|
||||
propertyPageId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pageId = typeof value === "string" && value.length > 0 ? value : null;
|
||||
const resolvedPage = useResolvePage(propertyPageId, pageId);
|
||||
|
||||
if (!pageId) return null;
|
||||
if (resolvedPage === undefined) return null;
|
||||
|
||||
if (resolvedPage === null) {
|
||||
return (
|
||||
<span className={cellClasses.pageMissing}>
|
||||
<IconFileDescription size={14} />
|
||||
<span>Page not found</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const title = getPageTitle(resolvedPage.title, undefined, t);
|
||||
const spaceSlug = resolvedPage.space?.slug ?? "";
|
||||
const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
|
||||
|
||||
return (
|
||||
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
|
||||
<Link
|
||||
to={url}
|
||||
className={cellClasses.pagePill}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{resolvedPage.icon ? (
|
||||
<span className={cellClasses.pagePillIcon}>{resolvedPage.icon}</span>
|
||||
) : (
|
||||
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
|
||||
)}
|
||||
<span className={cellClasses.pagePillText}>{title}</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxField({ value }: { value: unknown }) {
|
||||
if (value !== true) return null;
|
||||
return <IconCheck size={14} />;
|
||||
}
|
||||
|
||||
function UrlField({ value }: { value: unknown }) {
|
||||
const displayValue = typeof value === "string" ? value : "";
|
||||
if (!displayValue) return null;
|
||||
const safeHref = sanitizeUrl(displayValue);
|
||||
if (!safeHref) {
|
||||
return (
|
||||
<Text size="xs" lineClamp={1}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||
<a
|
||||
className={cellClasses.urlLink}
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: "var(--mantine-font-size-xs)" }}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailField({ value }: { value: unknown }) {
|
||||
const displayValue = typeof value === "string" ? value : "";
|
||||
if (!displayValue) return null;
|
||||
return (
|
||||
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
|
||||
<a
|
||||
className={cellClasses.emailLink}
|
||||
href={`mailto:${displayValue}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: "var(--mantine-font-size-xs)" }}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
|
||||
if (isFormulaErrorCell(value)) {
|
||||
return (
|
||||
<Tooltip label={`${value.__err}: ${value.msg}`} withinPortal>
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
#ERROR
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const opts = (property.typeOptions ?? {}) as { resultType?: string };
|
||||
const resultType = opts.resultType ?? "null";
|
||||
|
||||
if (resultType === "number") {
|
||||
return <NumberField value={value} property={property} />;
|
||||
}
|
||||
if (resultType === "boolean") {
|
||||
return <CheckboxField value={value} />;
|
||||
}
|
||||
if (resultType === "date") {
|
||||
return <DateField value={value} property={property} />;
|
||||
}
|
||||
|
||||
const text = typeof value === "string" ? value : value != null ? String(value) : null;
|
||||
if (!text) return null;
|
||||
return (
|
||||
<Text size="sm" lineClamp={2}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type KanbanAddCardButtonProps = {
|
||||
onAddCard: () => void;
|
||||
};
|
||||
|
||||
export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={classes.addCard}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onAddCard}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onAddCard();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
{t("New row")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
|
||||
import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
|
||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import propClasses from "@/ee/base/styles/property.module.css";
|
||||
|
||||
const DRAG_TYPE = "base-card-property";
|
||||
|
||||
type KanbanCardPropertiesProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
base: IBase;
|
||||
view: IBaseView;
|
||||
pageId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function KanbanCardProperties({
|
||||
opened,
|
||||
onClose,
|
||||
base,
|
||||
view,
|
||||
pageId,
|
||||
children,
|
||||
}: KanbanCardPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateView = useUpdateViewMutation();
|
||||
|
||||
const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
|
||||
const visibleIds = view.config?.visiblePropertyIds ?? [];
|
||||
|
||||
const savedOrder = view.config?.propertyOrder ?? [];
|
||||
const orderedProperties = [
|
||||
...savedOrder
|
||||
.map((id) => nonPrimaryProperties.find((p) => p.id === id))
|
||||
.filter((p): p is IBaseProperty => p !== undefined),
|
||||
...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
|
||||
];
|
||||
|
||||
const primaryProperty = base.properties.find((p) => p.isPrimary);
|
||||
const PrimaryIcon = primaryProperty
|
||||
? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
|
||||
: undefined;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(propertyId: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? [...visibleIds, propertyId]
|
||||
: visibleIds.filter((id) => id !== propertyId);
|
||||
updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
|
||||
},
|
||||
[updateView, view.id, visibleIds, pageId],
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(activeId: string, targetId: string, edge: Edge) => {
|
||||
const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
|
||||
const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
|
||||
if (startIndex === -1 || indexOfTarget === -1) return;
|
||||
const finishIndex = getReorderDestinationIndex({
|
||||
startIndex,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget: edge,
|
||||
axis: "vertical",
|
||||
});
|
||||
if (finishIndex === startIndex) return;
|
||||
const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
|
||||
updateView.mutate({
|
||||
viewId: view.id,
|
||||
pageId,
|
||||
config: { propertyOrder: reordered.map((p) => p.id) },
|
||||
});
|
||||
},
|
||||
[orderedProperties, updateView, view.id, pageId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" px={4} py={2}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Card properties")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah="min(60vh, 420px)" scrollbarSize={6} offsetScrollbars>
|
||||
<Stack gap={0}>
|
||||
{primaryProperty && (
|
||||
<div className={cellClasses.menuItem} style={{ paddingLeft: 4, cursor: "default" }}>
|
||||
<div className={propClasses.dragHandle} style={{ visibility: "hidden" }}>
|
||||
<IconGripVertical size={14} />
|
||||
</div>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{PrimaryIcon && <PrimaryIcon size={14} style={{ flexShrink: 0 }} />}
|
||||
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{primaryProperty.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked
|
||||
disabled
|
||||
onChange={() => {}}
|
||||
styles={{ track: { cursor: "default" } }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{orderedProperties.map((p) => {
|
||||
const isVisible = visibleIds.includes(p.id);
|
||||
const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
|
||||
const TypeIcon = typeConfig?.icon;
|
||||
return (
|
||||
<SortablePropertyRow
|
||||
key={p.id}
|
||||
property={p}
|
||||
isVisible={isVisible}
|
||||
TypeIcon={TypeIcon}
|
||||
onToggle={handleToggle}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type SortablePropertyRowProps = {
|
||||
property: IBaseProperty;
|
||||
isVisible: boolean;
|
||||
TypeIcon: typeof IconLetterT | undefined;
|
||||
onToggle: (propertyId: string, checked: boolean) => void;
|
||||
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
|
||||
};
|
||||
|
||||
function SortablePropertyRow({
|
||||
property,
|
||||
isVisible,
|
||||
TypeIcon,
|
||||
onToggle,
|
||||
onReorder,
|
||||
}: SortablePropertyRowProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const onReorderRef = useRef(onReorder);
|
||||
useLayoutEffect(() => {
|
||||
onReorderRef.current = onReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const handle = handleRef.current;
|
||||
if (!row || !handle) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: row,
|
||||
dragHandle: handle,
|
||||
getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: row,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ propertyId: property.id },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onReorderRef.current(source.data.propertyId as string, property.id, edge);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [property.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
style={{ position: "relative", opacity: isDragging ? 0.4 : 1 }}
|
||||
>
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => onToggle(property.id, !isVisible)}
|
||||
style={{ paddingLeft: 4 }}
|
||||
>
|
||||
<div ref={handleRef} className={propClasses.dragHandle} onClick={(e) => e.stopPropagation()}>
|
||||
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
|
||||
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{property.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={isVisible}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
styles={{ track: { cursor: "pointer" } }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { forwardRef, useCallback, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
|
||||
import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
|
||||
import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
|
||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||
import classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type KanbanCardProps = {
|
||||
base: IBase;
|
||||
view: IBaseView;
|
||||
row: IBaseRow;
|
||||
columnKey: string;
|
||||
onOpen: (rowId: string) => void;
|
||||
};
|
||||
|
||||
export const KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(
|
||||
function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const primary = base.properties.find((p) => p.isPrimary);
|
||||
const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
|
||||
|
||||
const visibleIds = view.config?.visiblePropertyIds ?? [];
|
||||
const propertyOrder = view.config?.propertyOrder;
|
||||
|
||||
const cardProps = base.properties.filter(
|
||||
(p) => visibleIds.includes(p.id) && !p.isPrimary,
|
||||
);
|
||||
|
||||
if (propertyOrder) {
|
||||
cardProps.sort(
|
||||
(a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
|
||||
);
|
||||
}
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setCardEl = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
cardRef.current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const { closestEdge, isDragging } = useKanbanCardDnd({
|
||||
cardRef,
|
||||
rowId: row.id,
|
||||
columnKey,
|
||||
pageId: base.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setCardEl}
|
||||
className={clsx(classes.card, isDragging && classes.cardDragging)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen(row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{closestEdge === "top" && <BaseDropEdgeIndicator edge="top" />}
|
||||
<div className={clsx(classes.cardTitle, !title && classes.cardUntitled)}>
|
||||
{title || t("Untitled")}
|
||||
</div>
|
||||
{cardProps.map((property) => (
|
||||
<CardField
|
||||
key={property.id}
|
||||
property={property}
|
||||
value={row.cells[property.id]}
|
||||
pageId={base.id}
|
||||
/>
|
||||
))}
|
||||
{closestEdge === "bottom" && <BaseDropEdgeIndicator edge="bottom" />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { type IBase, type 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>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type KanbanGroupByPickerProps = {
|
||||
base: IBase;
|
||||
view: IBaseView;
|
||||
pageId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateView = useUpdateViewMutation();
|
||||
const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
|
||||
|
||||
const data = base.properties
|
||||
.filter((p) => p.type === "select" || p.type === "status")
|
||||
.map((p) => ({ value: p.id, label: p.name }));
|
||||
|
||||
const handleChange = (value: string | null) => {
|
||||
updateView.mutate({
|
||||
viewId: view.id,
|
||||
pageId,
|
||||
config: { groupByPropertyId: value ?? null },
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroup = (key: string, currentlyHidden: boolean) => {
|
||||
const current = view.config?.hiddenChoiceIds ?? [];
|
||||
const next = currentlyHidden
|
||||
? current.filter((k) => k !== key)
|
||||
: [...current, key];
|
||||
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={300}
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Group by")}
|
||||
</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
placeholder={t("Select a property")}
|
||||
data={data}
|
||||
value={view.config?.groupByPropertyId ?? null}
|
||||
onChange={handleChange}
|
||||
clearable
|
||||
/>
|
||||
{hasValidGroupBy && allGroups.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Groups")}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
{allGroups.map((g) => {
|
||||
const dotColor = g.color
|
||||
? (choiceColor(g.color).color as string)
|
||||
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={g.key}
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => toggleGroup(g.key, g.hidden)}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: dotColor,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{g.isNoValue ? t("No value") : g.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={!g.hidden}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
styles={{ track: { cursor: "pointer" } }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { BasePropertyType } from "@/ee/base/types/base.types";
|
||||
|
||||
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
|
||||
"createdAt",
|
||||
"lastEditedAt",
|
||||
"lastEditedBy",
|
||||
"formula",
|
||||
]);
|
||||
|
||||
type ConversionInfo = {
|
||||
// i18n source key (translation files key them by their exact text).
|
||||
message: string;
|
||||
// True when cells can be cleared, discarded, truncated, or have their
|
||||
// structured value flattened, i.e. the change is not safely reversible.
|
||||
// Drives the destructive (red) "Apply" button in the confirm panel.
|
||||
lossy: boolean;
|
||||
};
|
||||
|
||||
// Buckets ordered most-specific first; default covers safe reinterpretations.
|
||||
function describeConversion(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): ConversionInfo {
|
||||
if (to === "text" || to === "longText") {
|
||||
if (from === "longText" && to === "text") {
|
||||
return {
|
||||
message:
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "select" || from === "status") {
|
||||
return { message: "Cells will be replaced with the option name.", lossy: true };
|
||||
}
|
||||
if (from === "multiSelect") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be replaced with a comma-separated list of option names.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "person") {
|
||||
return { message: "Cells will be replaced with the person's name.", lossy: true };
|
||||
}
|
||||
if (from === "file") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be replaced with a comma-separated list of file names.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "page") {
|
||||
return { message: "Cells will be replaced with the page title.", lossy: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (to === "select" && from === "multiSelect") {
|
||||
return {
|
||||
message:
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "multiSelect" && from === "select") {
|
||||
return {
|
||||
message: "Existing values become single-item lists. No data is lost.",
|
||||
lossy: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "page") {
|
||||
return {
|
||||
message: "Cells that aren't already a page reference will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "number" && from !== "number") {
|
||||
return {
|
||||
message: "Cells that can't be parsed as a number will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "date" && from !== "date") {
|
||||
return {
|
||||
message: "Cells that can't be parsed as a date will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "checkbox" && from !== "checkbox") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if ((to === "url" || to === "email") && from !== to) {
|
||||
return {
|
||||
message:
|
||||
to === "url"
|
||||
? "Cells that aren't a valid URL will be cleared."
|
||||
: "Cells that aren't a valid email address will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Cells will be reinterpreted under the new type.", lossy: false };
|
||||
}
|
||||
|
||||
export function conversionWarning(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): string {
|
||||
return describeConversion(from, to).message;
|
||||
}
|
||||
|
||||
// Whether the type change can lose data, used to make "Apply" destructive.
|
||||
export function isLossyConversion(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): boolean {
|
||||
return describeConversion(from, to).lossy;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Group, MultiSelect, Select, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Choice } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComboboxItem } from "@mantine/core";
|
||||
|
||||
type DefaultValuePickerProps = {
|
||||
choices: Choice[];
|
||||
value: string | string[] | null;
|
||||
multiple?: boolean;
|
||||
onChange: (value: string | string[] | null) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function DefaultValuePicker({
|
||||
choices,
|
||||
value,
|
||||
multiple,
|
||||
onChange,
|
||||
dropdownPortalTarget,
|
||||
}: DefaultValuePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = choices.map((c) => ({ value: c.id, label: c.name }));
|
||||
const comboboxProps = {
|
||||
portalProps: { target: dropdownPortalTarget ?? undefined },
|
||||
};
|
||||
|
||||
const renderOption = ({
|
||||
option,
|
||||
checked,
|
||||
}: {
|
||||
option: ComboboxItem;
|
||||
checked?: boolean;
|
||||
}) => {
|
||||
const choice = choices.find((c) => c.id === option.value);
|
||||
const colors = choice ? choiceColor(choice.color) : undefined;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="space-between" style={{ flex: 1 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{colors && (
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.backgroundColor as string,
|
||||
border: `2px solid ${colors.color as string}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs">{option.label}</Text>
|
||||
</Group>
|
||||
{checked && (
|
||||
<IconCheck size={14} color="var(--mantine-color-dimmed)" />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
if (multiple) {
|
||||
const selected = (
|
||||
Array.isArray(value) ? value : value ? [value] : []
|
||||
).filter((id) => choices.some((c) => c.id === id));
|
||||
return (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={selected.length ? undefined : t("None")}
|
||||
data={data}
|
||||
value={selected}
|
||||
onChange={(vals) => onChange(vals.length ? vals : null)}
|
||||
clearable
|
||||
comboboxProps={comboboxProps}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const single =
|
||||
typeof value === "string" && choices.some((c) => c.id === value)
|
||||
? value
|
||||
: null;
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
data={data}
|
||||
value={single}
|
||||
onChange={(val) => onChange(val)}
|
||||
clearable
|
||||
comboboxProps={comboboxProps}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Divider,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
PersonTypeOptions,
|
||||
Choice,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { ChoiceEditor } from "./choice-editor";
|
||||
import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
|
||||
import {
|
||||
CURRENCIES,
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
} from "@/ee/base/constants/currencies";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PropertyOptionsProps = {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
// Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function PropertyOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: PropertyOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (property.type) {
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<SelectOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<StatusOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "person":
|
||||
return (
|
||||
<PersonOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
case "longText":
|
||||
case "url":
|
||||
case "email":
|
||||
return (
|
||||
<TextDefaultOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckboxOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No options for this property type")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type OptionEditorProps = {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
const EMPTY_OPTIONS: Record<string, unknown> = {};
|
||||
|
||||
function optionsEqual(
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>,
|
||||
): boolean {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const key of keys) {
|
||||
const av = a[key];
|
||||
const bv = b[key];
|
||||
if (Array.isArray(av) && Array.isArray(bv)) {
|
||||
if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
|
||||
return false;
|
||||
}
|
||||
} else if (av !== bv) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
|
||||
function useEditableTypeOptions(
|
||||
initialRaw: Record<string, unknown> | undefined,
|
||||
{
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
onUpdate: (opts: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
},
|
||||
) {
|
||||
const initial = initialRaw ?? EMPTY_OPTIONS;
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideButtons) setDraft(initial);
|
||||
}, [initial, hideButtons]);
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
useEffect(() => {
|
||||
if (hideButtons) onUpdateRef.current(draft);
|
||||
}, [hideButtons, draft]);
|
||||
|
||||
const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Record<string, unknown>) =>
|
||||
setDraft((prev) => ({ ...prev, ...patch })),
|
||||
[],
|
||||
);
|
||||
const save = useCallback(() => {
|
||||
onUpdate(draft);
|
||||
onClose();
|
||||
}, [draft, onUpdate, onClose]);
|
||||
const cancel = useCallback(() => {
|
||||
setDraft(initial);
|
||||
onDirtyChange?.(false);
|
||||
onClose();
|
||||
}, [initial, onClose, onDirtyChange]);
|
||||
|
||||
return { draft, update, isDirty, save, cancel };
|
||||
}
|
||||
|
||||
function OptionsFooter({
|
||||
isDirty,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={onCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={onSave} disabled={!isDirty}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[], defaultValue: string | string[] | null) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
defaultValue,
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories={false}
|
||||
hideButtons={hideButtons}
|
||||
initialDefaultValue={options?.defaultValue ?? null}
|
||||
multiDefault={property.type === "multiSelect"}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[], defaultValue: string | string[] | null) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
defaultValue,
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories
|
||||
hideButtons={hideButtons}
|
||||
initialDefaultValue={options?.defaultValue ?? null}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as NumberTypeOptions;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Format")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "plain", label: t("Number") },
|
||||
{ value: "currency", label: t("Currency") },
|
||||
{ value: "percent", label: t("Percent") },
|
||||
{ value: "progress", label: t("Progress") },
|
||||
]}
|
||||
value={options.format ?? "plain"}
|
||||
onChange={(val) => update({ format: val ?? "plain" })}
|
||||
/>
|
||||
{options.format === "currency" && (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Currency")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={CURRENCIES.map((c) => ({
|
||||
value: c.code,
|
||||
label: `${c.name} (${c.code})`,
|
||||
}))}
|
||||
value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
|
||||
onChange={(val) =>
|
||||
update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Thousands and decimal separators")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "none", label: t("None") },
|
||||
{ value: "local", label: t("Local") },
|
||||
{ value: "comma_period", label: t("Comma, period") },
|
||||
{ value: "period_comma", label: t("Period, comma") },
|
||||
{ value: "space_comma", label: t("Space, comma") },
|
||||
{ value: "space_period", label: t("Space, period") },
|
||||
]}
|
||||
value={options.separators ?? "none"}
|
||||
onChange={(val) => update({ separators: val ?? "none" })}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Decimal places")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "default", label: t("Default") },
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: String(i),
|
||||
})),
|
||||
]}
|
||||
value={options.precision == null ? "default" : String(options.precision)}
|
||||
onChange={(val) =>
|
||||
update({ precision: val == null || val === "default" ? undefined : Number(val) })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
value={typeof options.defaultValue === "number" ? options.defaultValue : ""}
|
||||
onChange={(val) =>
|
||||
update({ defaultValue: typeof val === "number" ? val : undefined })
|
||||
}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DateOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as DateTypeOptions;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Include time")}
|
||||
checked={options.includeTime ?? false}
|
||||
onChange={(e) => update({ includeTime: e.currentTarget.checked })}
|
||||
/>
|
||||
{options.includeTime && (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Time format")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
]}
|
||||
value={options.timeFormat ?? "12h"}
|
||||
onChange={(val) => update({ timeFormat: val ?? "12h" })}
|
||||
/>
|
||||
)}
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as PersonTypeOptions;
|
||||
const allowMultiple = options.allowMultiple === true;
|
||||
|
||||
const handleAllowMultipleChange = (toMulti: boolean) => {
|
||||
const dv = options.defaultValue;
|
||||
const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
|
||||
update({
|
||||
allowMultiple: toMulti,
|
||||
defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Allow multiple people")}
|
||||
checked={allowMultiple}
|
||||
onChange={(e) => handleAllowMultipleChange(e.currentTarget.checked)}
|
||||
/>
|
||||
<FilterPersonInput
|
||||
pageId={property.pageId}
|
||||
multiple={allowMultiple}
|
||||
value={options.defaultValue ?? null}
|
||||
onChange={(value) =>
|
||||
update({ defaultValue: value as string | string[] | undefined })
|
||||
}
|
||||
placeholder={t("None")}
|
||||
label={t("Default value")}
|
||||
w="100%"
|
||||
portalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function TextDefaultOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const defaultValue =
|
||||
typeof draft.defaultValue === "string" ? draft.defaultValue : "";
|
||||
const defaultValueError =
|
||||
defaultValue && property.type === "url" && !URL.canParse(defaultValue)
|
||||
? t("Please enter a valid url")
|
||||
: defaultValue &&
|
||||
property.type === "email" &&
|
||||
!EMAIL_FORMAT.test(defaultValue)
|
||||
? t("Please enter a valid email")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{property.type === "longText" ? (
|
||||
<Textarea
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
value={defaultValue}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
defaultValue: e.currentTarget.value.trim()
|
||||
? e.currentTarget.value
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={
|
||||
property.type === "url"
|
||||
? "https://example.com"
|
||||
: property.type === "email"
|
||||
? "name@example.com"
|
||||
: t("None")
|
||||
}
|
||||
value={defaultValue}
|
||||
error={defaultValueError}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
defaultValue: e.currentTarget.value.trim()
|
||||
? e.currentTarget.value
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!hideButtons && (
|
||||
<OptionsFooter
|
||||
isDirty={isDirty && !defaultValueError}
|
||||
onCancel={cancel}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Checked by default")}
|
||||
checked={draft.defaultValue === true}
|
||||
onChange={(e) =>
|
||||
update({ defaultValue: e.currentTarget.checked ? true : undefined })
|
||||
}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldText } from "./field-text";
|
||||
import { FieldLongText } from "./field-long-text";
|
||||
import { FieldNumber } from "./field-number";
|
||||
import { FieldDate } from "./field-date";
|
||||
import { FieldChoice } from "./field-choice";
|
||||
import { FieldCellAdapter } from "./field-cell-adapter";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
export type FieldProps = {
|
||||
property: IBaseProperty;
|
||||
value: unknown;
|
||||
rowId: string;
|
||||
readOnly: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
type FieldShellProps = {
|
||||
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
|
||||
cursor?: "text" | "pointer" | "default";
|
||||
/** Popover open — keeps the focus ring while focus is in the portal. */
|
||||
active?: boolean;
|
||||
locked?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
|
||||
// ref injected into this element; without it the picker renders at (0,0).
|
||||
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
|
||||
function FieldShell(
|
||||
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
classes.fieldShell,
|
||||
cursor === "text" && classes.fieldShellText,
|
||||
cursor === "pointer" && classes.fieldShellPointer,
|
||||
active && classes.fieldShellActive,
|
||||
locked && classes.fieldShellLocked,
|
||||
alignTop && classes.fieldShellTop,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
|
||||
const checked = value === true;
|
||||
return (
|
||||
<FieldShell>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={readOnly}
|
||||
onChange={() => onChange(!checked)}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
return (
|
||||
<FieldShell locked>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
{CellComponent && (
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={false}
|
||||
readOnly
|
||||
onCommit={() => {}}
|
||||
onValueChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailFieldProps = {
|
||||
property: IBaseProperty;
|
||||
row: IBaseRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
|
||||
const descriptor = getDescriptor(property.type);
|
||||
const value = descriptor?.systemAccessor
|
||||
? descriptor.systemAccessor(row)
|
||||
: (row.cells ?? {})[property.id];
|
||||
const fieldProps: FieldProps = {
|
||||
property,
|
||||
value,
|
||||
rowId: row.id,
|
||||
readOnly,
|
||||
onChange: (next: unknown) => onUpdate(property.id, next),
|
||||
};
|
||||
|
||||
switch (property.type) {
|
||||
case "text":
|
||||
case "url":
|
||||
case "email":
|
||||
return <FieldText {...fieldProps} />;
|
||||
case "longText":
|
||||
return <FieldLongText {...fieldProps} />;
|
||||
case "number":
|
||||
return <FieldNumber {...fieldProps} />;
|
||||
case "checkbox":
|
||||
return <FieldCheckbox {...fieldProps} />;
|
||||
case "date":
|
||||
return <FieldDate {...fieldProps} />;
|
||||
case "select":
|
||||
case "status":
|
||||
case "multiSelect":
|
||||
return <FieldChoice {...fieldProps} />;
|
||||
case "person":
|
||||
case "file":
|
||||
case "page":
|
||||
return <FieldCellAdapter {...fieldProps} />;
|
||||
default:
|
||||
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
|
||||
return <FieldReadonlyCell {...fieldProps} />;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
/** Person, file and page editors are popover pickers owned by their cell
|
||||
* components; the shell supplies modal styling and click-anywhere
|
||||
* activation while the cell keeps its picker behavior. */
|
||||
export function FieldCellAdapter({
|
||||
property,
|
||||
value,
|
||||
rowId,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: FieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Whether the picker was open when the current gesture's mousedown fired.
|
||||
const editingAtMouseDownRef = useRef(false);
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
|
||||
// Files stay openable read-only (download-only popover), matching the grid.
|
||||
const canActivate = !readOnly || property.type === "file";
|
||||
|
||||
// Activate on click, not mousedown: opening on mousedown mounts the cell's
|
||||
// picker mid-dispatch, and its document-level outside-mousedown listener
|
||||
// then catches the same still-bubbling event and instantly closes it. By
|
||||
// click time the mousedown has fully finished. The ref keeps toggle-close
|
||||
// working: when the gesture started with the picker open, the picker's own
|
||||
// outside-close already handled it and we must not reopen.
|
||||
const handleMouseDown = useCallback(() => {
|
||||
editingAtMouseDownRef.current = editing;
|
||||
}, [editing]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!canActivate || editingAtMouseDownRef.current || editing) return;
|
||||
setEditing(true);
|
||||
}, [canActivate, editing]);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(next: unknown) => {
|
||||
setEditing(false);
|
||||
onChange(next);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
const handleCancel = useCallback(() => setEditing(false), []);
|
||||
|
||||
if (!CellComponent) return <FieldShell />;
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
cursor={canActivate ? "pointer" : "default"}
|
||||
active={editing}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
role={canActivate ? "button" : undefined}
|
||||
tabIndex={canActivate ? 0 : undefined}
|
||||
aria-label={property.name}
|
||||
onKeyDown={(e) => {
|
||||
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={editing}
|
||||
readOnly={readOnly}
|
||||
onCommit={handleCommit}
|
||||
onValueChange={onChange}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const multiple = property.type === "multiSelect";
|
||||
const choices =
|
||||
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||
|
||||
const selectedIds = multiple
|
||||
? Array.isArray(value)
|
||||
? (value as string[])
|
||||
: []
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(choice: Choice) => {
|
||||
if (multiple) {
|
||||
const next = selectedIds.includes(choice.id)
|
||||
? selectedIds.filter((id) => id !== choice.id)
|
||||
: [...selectedIds, choice.id];
|
||||
onChange(next.length > 0 ? next : null);
|
||||
} else {
|
||||
onChange(choice.id === selectedIds[0] ? null : choice.id);
|
||||
setOpened(false);
|
||||
}
|
||||
},
|
||||
[multiple, selectedIds, onChange],
|
||||
);
|
||||
|
||||
const chips = selectedChoices.map((choice) => (
|
||||
<span
|
||||
key={choice.id}
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
));
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="target"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
hideDetached={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
{opened && (
|
||||
<ChoicePicker
|
||||
property={property}
|
||||
selectedIds={selectedIds}
|
||||
multiple={multiple}
|
||||
grouped={property.type === "status"}
|
||||
allowCreate={property.type !== "status"}
|
||||
onToggle={handleToggle}
|
||||
onEscape={() => setOpened(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { DateTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
function toISODateString(dateStr: string | null): string | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
|
||||
const dateStr = typeof value === "string" ? value : null;
|
||||
const display = formatDateDisplay(dateStr, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="auto"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
hideDetached={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<DatePicker
|
||||
value={toISODateString(dateStr)}
|
||||
onChange={(selected) => {
|
||||
onChange(selected ? new Date(selected).toISOString() : null);
|
||||
setOpened(false);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Textarea } from "@mantine/core";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const normalize = (s: string) => {
|
||||
const trimmed = s.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell alignTop>
|
||||
<span className={classes.fieldValueTextMultiline}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text" alignTop>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={16}
|
||||
maxLength={25000}
|
||||
variant="unstyled"
|
||||
className={classes.fieldTextarea}
|
||||
classNames={{ input: classes.fieldTextareaInput }}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
formatNumber,
|
||||
parseNumberDraft,
|
||||
sanitizeNumberInput,
|
||||
} from "@/ee/base/components/cells/cell-number";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toDraft = (value: unknown) =>
|
||||
typeof value === "number" ? String(value) : "";
|
||||
|
||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
const [draft, setDraft] = useState(toDraft(value));
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(toDraft(value));
|
||||
}, [value, focused]);
|
||||
|
||||
const formatted = formatNumber(numValue, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{formatted}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(toDraft(value));
|
||||
return;
|
||||
}
|
||||
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={classes.fieldInput}
|
||||
value={focused ? draft : formatted}
|
||||
onFocus={() => {
|
||||
setDraft(toDraft(value));
|
||||
setFocused(true);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
|
||||
setDraft(v);
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart ?? draft.length;
|
||||
const end = el.selectionEnd ?? draft.length;
|
||||
setDraft(
|
||||
draft.slice(0, start) +
|
||||
sanitizeNumberInput(e.clipboardData.getData("text")) +
|
||||
draft.slice(end),
|
||||
);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconExternalLink, IconMail } from "@tabler/icons-react";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
// Track remote/navigation updates while not typing.
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (draft !== text) onChange(draft);
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const linkHref =
|
||||
!focused && text
|
||||
? property.type === "email"
|
||||
? text.includes("@")
|
||||
? `mailto:${text}`
|
||||
: null
|
||||
: property.type === "url" && /^https?:\/\//i.test(text)
|
||||
? text
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
className={classes.fieldInput}
|
||||
value={draft}
|
||||
maxLength={1000}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
{linkHref && (
|
||||
<a
|
||||
href={linkHref}
|
||||
target={property.type === "url" ? "_blank" : undefined}
|
||||
rel="noopener noreferrer"
|
||||
className={classes.fieldTrailing}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
|
||||
>
|
||||
{property.type === "email" ? (
|
||||
<IconMail size={14} />
|
||||
) : (
|
||||
<IconExternalLink size={14} />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { DetailField } from "./fields/detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
type PropertyRowProps = {
|
||||
property: IBaseProperty;
|
||||
row: IBaseRow;
|
||||
pageId: string;
|
||||
menuOpened: boolean;
|
||||
onMenuOpenChange: (opened: boolean) => void;
|
||||
onMenuDirtyChange: (dirty: boolean) => void;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
autoFocusValue?: boolean;
|
||||
onAutoFocused?: () => void;
|
||||
};
|
||||
|
||||
export function PropertyRow({
|
||||
property,
|
||||
row,
|
||||
pageId,
|
||||
menuOpened,
|
||||
onMenuOpenChange,
|
||||
onMenuDirtyChange,
|
||||
onUpdate,
|
||||
autoFocusValue,
|
||||
onAutoFocused,
|
||||
}: PropertyRowProps) {
|
||||
const canEdit = useBaseEditable();
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocusValue || focusedRef.current) return;
|
||||
focusedRef.current = true;
|
||||
const el = rowRef.current;
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
el.querySelector<HTMLElement>("input, textarea")?.focus();
|
||||
}
|
||||
onAutoFocused?.();
|
||||
}, [autoFocusValue, onAutoFocused]);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
onMenuOpenChange(!menuOpened);
|
||||
}, [menuOpened, onMenuOpenChange]);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
onMenuOpenChange(false);
|
||||
}, [onMenuOpenChange]);
|
||||
|
||||
const Icon = getDescriptor(property.type)?.icon;
|
||||
|
||||
const label = (
|
||||
<>
|
||||
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
|
||||
<span className={classes.propertyLabelText}>{property.name}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.propertyRow} ref={rowRef}>
|
||||
{canEdit ? (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
hideDetached={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(classes.propertyLabel, classes.propertyLabelButton, {
|
||||
[classes.propertyLabelActive]: menuOpened,
|
||||
})}
|
||||
onClick={handleLabelClick}
|
||||
data-property-menu-target
|
||||
>
|
||||
{label}
|
||||
<IconChevronDown size={13} className={classes.propertyLabelChevron} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PropertyMenuContent
|
||||
property={property}
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={onMenuDirtyChange}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className={classes.propertyLabel}>{label}</div>
|
||||
)}
|
||||
<DetailField
|
||||
property={property}
|
||||
row={row}
|
||||
readOnly={!canEdit}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconDotsVertical,
|
||||
IconLink,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IBase, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
useBaseRowQuery,
|
||||
useDeleteRowMutation,
|
||||
useUpdateRowMutation,
|
||||
} from "@/ee/base/queries/base-row-query";
|
||||
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
|
||||
import { RowDetailTitle } from "./row-detail-title";
|
||||
import { PropertyRow } from "./property-row";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
type RowDetailModalProps = {
|
||||
base: IBase;
|
||||
rows: IBaseRow[];
|
||||
openRowId: string | null;
|
||||
onClose: () => void;
|
||||
onNavigate: (rowId: string) => void;
|
||||
};
|
||||
|
||||
export function RowDetailModal({
|
||||
base,
|
||||
rows,
|
||||
openRowId,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: RowDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const canEdit = useBaseEditable();
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const deleteRowMutation = useDeleteRowMutation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
|
||||
const rowIndex = useMemo(
|
||||
() => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
|
||||
[openRowId, rows],
|
||||
);
|
||||
const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
|
||||
// Deep links (?row=) can target rows outside the loaded pages or filtered
|
||||
// out of the active view — fetch by id instead of closing. Close only
|
||||
// when the server confirms the row is gone.
|
||||
const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
|
||||
enabled: !!openRowId && !rowFromList,
|
||||
});
|
||||
const row = rowFromList ?? rowQuery.data;
|
||||
const primaryProperty = useMemo(
|
||||
() => base.properties.find((p) => p.isPrimary),
|
||||
[base.properties],
|
||||
);
|
||||
|
||||
const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
|
||||
useEffect(() => {
|
||||
if (rowMissing) onClose();
|
||||
}, [rowMissing, onClose]);
|
||||
|
||||
const isSaving = updateRowMutation.isPending;
|
||||
const opened = !!openRowId;
|
||||
|
||||
// One field menu open at a time, mirroring the grid header's semantics.
|
||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||
// run its discard-confirm flow instead of being torn down mid-edit.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
|
||||
const menuDirtyRef = useRef(false);
|
||||
const [closeRequest, setCloseRequest] = useAtom(
|
||||
propertyMenuCloseRequestAtomFamily(base.id),
|
||||
) as unknown as [number, (val: number) => void];
|
||||
|
||||
useEffect(() => {
|
||||
setOpenMenuId(null);
|
||||
menuDirtyRef.current = false;
|
||||
}, [openRowId]);
|
||||
|
||||
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
|
||||
menuDirtyRef.current = dirty;
|
||||
}, []);
|
||||
|
||||
const requestMenuClose = useCallback(() => {
|
||||
if (menuDirtyRef.current) {
|
||||
setCloseRequest(closeRequest + 1);
|
||||
} else {
|
||||
setOpenMenuId(null);
|
||||
}
|
||||
}, [closeRequest, setCloseRequest]);
|
||||
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(propertyId: string, nextOpened: boolean) => {
|
||||
if (!nextOpened) {
|
||||
setOpenMenuId(null);
|
||||
menuDirtyRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
|
||||
setCloseRequest(closeRequest + 1);
|
||||
return;
|
||||
}
|
||||
setOpenMenuId(propertyId);
|
||||
},
|
||||
[openMenuId, closeRequest, setCloseRequest],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-position]")) return;
|
||||
if (target.closest("[data-property-menu-target]")) return;
|
||||
requestMenuClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [openMenuId, requestMenuClose]);
|
||||
|
||||
const hasPrev = rowIndex > 0;
|
||||
const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
|
||||
const navigate = useCallback(
|
||||
(delta: number) => {
|
||||
if (rowIndex === -1) return;
|
||||
const next = rows[rowIndex + delta];
|
||||
if (next) onNavigate(next.id);
|
||||
},
|
||||
[rows, rowIndex, onNavigate],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
clipboard.copy(window.location.href);
|
||||
notifications.show({ message: t("Link copied") });
|
||||
}, [clipboard, t]);
|
||||
|
||||
const handleDeleteRecord = useCallback(() => {
|
||||
if (!row) return;
|
||||
const rowId = row.id;
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete record?"),
|
||||
centered: true,
|
||||
children: <Text size="sm">{t("This action cannot be undone.")}</Text>,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteRowMutation.mutate({ rowId, pageId: base.id });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}, [row, base.id, deleteRowMutation, onClose, t]);
|
||||
|
||||
// Mantine's closeOnEscape runs a capture-phase window listener that fires
|
||||
// before inner popovers and inputs see the key, so we manage Esc ourselves
|
||||
// and yield to: nested dialogs (delete confirm), open popovers
|
||||
// ([data-position]) and editable elements. Arrows step records under the
|
||||
// same yield rules. Mantine puts role="dialog" and our content class on
|
||||
// the same element, which distinguishes this modal from nested ones.
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isEscape = event.key === "Escape";
|
||||
const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
|
||||
if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const dialog = target.closest('[role="dialog"]');
|
||||
if (dialog && !dialog.classList.contains(classes.modalContent)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest("[data-position]") ||
|
||||
target.matches("input, textarea, select, [contenteditable='true']")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isEscape) {
|
||||
if (openMenuId) {
|
||||
requestMenuClose();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (openMenuId) return;
|
||||
event.preventDefault();
|
||||
navigate(event.key === "ArrowUp" ? -1 : 1);
|
||||
},
|
||||
[opened, openMenuId, requestMenuClose, onClose, navigate],
|
||||
);
|
||||
useWindowEvent("keydown", handleKeyDown, { capture: true });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={!openMenuId}
|
||||
padding={0}
|
||||
radius="md"
|
||||
title={null}
|
||||
classNames={{ content: classes.modalContent }}
|
||||
removeScrollProps={{ noIsolation: true }}
|
||||
>
|
||||
{row ? (
|
||||
<>
|
||||
<div className={classes.topBar}>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Tooltip label={t("Previous record")} openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!hasPrev}
|
||||
aria-label={t("Previous record")}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Next record")} openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!hasNext}
|
||||
aria-label={t("Next record")}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Menu position="bottom-end" shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
aria-label={t("Record actions")}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={14} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={handleDeleteRecord}
|
||||
>
|
||||
{t("Delete record")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={onClose}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RowDetailTitle
|
||||
row={row}
|
||||
primaryProperty={primaryProperty}
|
||||
canEdit={canEdit}
|
||||
onClose={onClose}
|
||||
onCommit={(value) => {
|
||||
if (!primaryProperty) return;
|
||||
updateRowMutation.mutate({
|
||||
rowId: row.id,
|
||||
pageId: base.id,
|
||||
cells: { [primaryProperty.id]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={classes.body}>
|
||||
<div className={classes.propertyList}>
|
||||
{base.properties
|
||||
.filter((p) => !p.isPrimary)
|
||||
.map((property) => (
|
||||
<PropertyRow
|
||||
key={property.id}
|
||||
property={property}
|
||||
row={row}
|
||||
pageId={base.id}
|
||||
autoFocusValue={property.id === newPropertyId}
|
||||
onAutoFocused={clearNewProperty}
|
||||
menuOpened={openMenuId === property.id}
|
||||
onMenuOpenChange={(nextOpened) =>
|
||||
handleMenuOpenChange(property.id, nextOpened)
|
||||
}
|
||||
onMenuDirtyChange={handleMenuDirtyChange}
|
||||
onUpdate={(propertyId, value) => {
|
||||
updateRowMutation.mutate({
|
||||
rowId: row.id,
|
||||
pageId: base.id,
|
||||
cells: { [propertyId]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<CreatePropertyPopover
|
||||
pageId={base.id}
|
||||
properties={base.properties}
|
||||
onPropertyCreated={(p) => setNewPropertyId(p.id)}
|
||||
renderTarget={(open) => (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.addPropertyRow}
|
||||
onClick={open}
|
||||
>
|
||||
<span className={classes.addPropertyLabel}>
|
||||
<IconPlus size={15} />
|
||||
{t("Add property")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className={classes.footer}>
|
||||
<div className={classes.footerStatus}>
|
||||
{!canEdit ? (
|
||||
<span className={classes.lockedHint}>
|
||||
<IconLock size={12} />
|
||||
{t("Read-only")}
|
||||
</span>
|
||||
) : isSaving ? (
|
||||
<>
|
||||
<span className={classes.savingDot} />
|
||||
<span>{t("Saving…")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classes.kbdHint}>
|
||||
{rowIndex >= 0 && rows.length > 1 && (
|
||||
<>
|
||||
<kbd className={classes.kbd}>↑</kbd>
|
||||
<kbd className={classes.kbd}>↓</kbd>
|
||||
<span>{t("to navigate")}</span>
|
||||
<span className={classes.kbdSeparator} />
|
||||
</>
|
||||
)}
|
||||
<kbd className={classes.kbd}>Esc</kbd>
|
||||
<span>{t("to close")}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<RowDetailSkeleton base={base} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hydration state for deep-linked rows: the schema is already loaded, so
|
||||
* render the real labels and shimmer only the unknown values. Matching the
|
||||
* final layout avoids a size jump when the row arrives. */
|
||||
function RowDetailSkeleton({ base }: { base: IBase }) {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.topBar}>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
</div>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
</div>
|
||||
</div>
|
||||
<header className={classes.header}>
|
||||
<Skeleton height={30} width="45%" radius={8} />
|
||||
<div className={classes.metaRow}>
|
||||
<Skeleton height={12} width={150} radius={4} />
|
||||
</div>
|
||||
</header>
|
||||
<div className={classes.body}>
|
||||
<div className={classes.propertyList}>
|
||||
{base.properties
|
||||
.filter((p) => !p.isPrimary)
|
||||
.map((property) => {
|
||||
const Icon = getDescriptor(property.type)?.icon;
|
||||
return (
|
||||
<div key={property.id} className={classes.propertyRow}>
|
||||
<div className={classes.propertyLabel}>
|
||||
{Icon && (
|
||||
<Icon size={15} className={classes.propertyLabelIcon} />
|
||||
)}
|
||||
<span className={classes.propertyLabelText}>
|
||||
{property.name}
|
||||
</span>
|
||||
</div>
|
||||
<Skeleton
|
||||
height={property.type === "longText" ? 82 : 34}
|
||||
radius={7}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
InputBase,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DateFilterValue,
|
||||
FilterOperator,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import {
|
||||
DATE_ANCHOR_PRESETS,
|
||||
DATE_RANGE_PRESETS,
|
||||
ANCHOR_VALUES,
|
||||
RANGE_VALUES,
|
||||
} from "./relative-date-presets";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type FilterDateInputProps = {
|
||||
op: FilterOperator;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
type Mode = "exact" | "relative";
|
||||
|
||||
const ANCHOR_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DATE_ANCHOR_PRESETS.map((p) => [p.value, p.labelKey]),
|
||||
);
|
||||
const RANGE_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DATE_RANGE_PRESETS.map((p) => [p.value, p.labelKey]),
|
||||
);
|
||||
|
||||
function asDateValue(value: unknown): DateFilterValue | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return value as DateFilterValue;
|
||||
}
|
||||
|
||||
function toISODate(d: string | null): string | null {
|
||||
if (!d) return null;
|
||||
// Already a date-only ISO string (Mantine v8 emits these) — pass through to
|
||||
// avoid a UTC-parse + local-getter round-trip that shifts the day west of UTC.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function FilterDateInput({ op, value, onChange }: FilterDateInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const current = asDateValue(value);
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [localMode, setLocalMode] = useState<Mode>("exact");
|
||||
|
||||
const exactDate = current?.mode === "exact" ? toISODate(current.date) : null;
|
||||
const anchor =
|
||||
current?.mode === "relative" && ANCHOR_VALUES.has(current.preset)
|
||||
? current.preset
|
||||
: null;
|
||||
const range =
|
||||
current?.mode === "range" && RANGE_VALUES.has(current.preset)
|
||||
? current.preset
|
||||
: null;
|
||||
|
||||
const valueMode: Mode | null =
|
||||
current?.mode === "relative"
|
||||
? "relative"
|
||||
: current?.mode === "exact"
|
||||
? "exact"
|
||||
: null;
|
||||
const mode: Mode = valueMode ?? localMode;
|
||||
|
||||
let triggerLabel: string | null = null;
|
||||
if (op === "isWithin") triggerLabel = range ? t(RANGE_LABEL[range]) : null;
|
||||
else if (exactDate) triggerLabel = exactDate;
|
||||
else if (anchor) triggerLabel = t(ANCHOR_LABEL[anchor]);
|
||||
|
||||
// Consume Escape locally so the outer filter popover (bubble handler) keeps
|
||||
// the panel open and only this picker closes.
|
||||
const handleEscape = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpened(false);
|
||||
}
|
||||
};
|
||||
|
||||
const presetRow = (
|
||||
selected: boolean,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
key: string,
|
||||
) => (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
selected && cellClasses.selectOptionActive,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={cellClasses.personOptionName}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width={op === "isWithin" ? 200 : "auto"}
|
||||
withinPortal={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<Popover.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
pointer
|
||||
w={170}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={handleEscape}
|
||||
>
|
||||
{triggerLabel ?? <Input.Placeholder>{t("Select")}</Input.Placeholder>}
|
||||
</InputBase>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={op === "isWithin" ? 0 : "xs"} onKeyDown={handleEscape}>
|
||||
{op === "isWithin" ? (
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{DATE_RANGE_PRESETS.map((p) =>
|
||||
presetRow(
|
||||
range === p.value,
|
||||
t(p.labelKey),
|
||||
() => {
|
||||
onChange({ mode: "range", preset: p.value });
|
||||
setOpened(false);
|
||||
},
|
||||
p.value,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
mb="xs"
|
||||
value={mode}
|
||||
onChange={(m) => {
|
||||
setLocalMode(m as Mode);
|
||||
onChange(undefined);
|
||||
}}
|
||||
data={[
|
||||
{ value: "exact", label: t("Date") },
|
||||
{ value: "relative", label: t("Relative") },
|
||||
]}
|
||||
/>
|
||||
{mode === "exact" ? (
|
||||
<DatePicker
|
||||
value={exactDate}
|
||||
onChange={(d) => {
|
||||
const iso = toISODate(d);
|
||||
onChange(iso ? { mode: "exact", date: iso } : undefined);
|
||||
setOpened(false);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{DATE_ANCHOR_PRESETS.map((p) =>
|
||||
presetRow(
|
||||
anchor === p.value,
|
||||
t(p.labelKey),
|
||||
() => {
|
||||
onChange({ mode: "relative", preset: p.value });
|
||||
setOpened(false);
|
||||
},
|
||||
p.value,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Popover, InputBase, Input } from "@mantine/core";
|
||||
import { IconX, IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
usePersonSearch,
|
||||
type PersonSuggestion,
|
||||
} from "@/ee/base/hooks/use-person-search";
|
||||
import {
|
||||
useReferenceStore,
|
||||
useHydrateUsers,
|
||||
} from "@/ee/base/reference/reference-store";
|
||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type FilterPersonInputProps = {
|
||||
pageId: string;
|
||||
multiple: boolean;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
placeholder: string;
|
||||
label?: string;
|
||||
w?: number | string;
|
||||
portalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
function toIds(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => !!v);
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function FilterPersonInput({
|
||||
pageId,
|
||||
multiple,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
w,
|
||||
portalTarget,
|
||||
}: FilterPersonInputProps) {
|
||||
const ids = toIds(value);
|
||||
const selectedSet = new Set(ids);
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const store = useReferenceStore(pageId);
|
||||
const hydrateUsers = useHydrateUsers(pageId);
|
||||
const suggestions = usePersonSearch(search, opened);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) requestAnimationFrame(() => searchRef.current?.focus());
|
||||
else setSearch("");
|
||||
}, [opened]);
|
||||
|
||||
const filtered: PersonSuggestion[] = multiple
|
||||
? suggestions.filter((s) => !selectedSet.has(s.id))
|
||||
: suggestions;
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(filtered.length, [search, opened]);
|
||||
|
||||
const emit = useCallback(
|
||||
(nextIds: string[]) => {
|
||||
if (multiple) onChange(nextIds.length > 0 ? nextIds : undefined);
|
||||
else onChange(nextIds[0] ?? undefined);
|
||||
},
|
||||
[multiple, onChange],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
const picked = suggestions.find((s) => s.id === id);
|
||||
if (picked)
|
||||
hydrateUsers([
|
||||
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
|
||||
]);
|
||||
if (multiple) {
|
||||
emit(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
|
||||
} else {
|
||||
emit([id]);
|
||||
setOpened(false);
|
||||
}
|
||||
setSearch("");
|
||||
},
|
||||
[suggestions, hydrateUsers, multiple, ids, emit],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => emit(ids.filter((x) => x !== id)),
|
||||
[emit, ids],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpened(false);
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex < 0 || activeIndex >= filtered.length) return;
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[activeIndex].id);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && search === "" && ids.length > 0) {
|
||||
e.preventDefault();
|
||||
handleRemove(ids[ids.length - 1]);
|
||||
}
|
||||
},
|
||||
[handleNavKey, activeIndex, filtered, handleSelect, search, ids, handleRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width={260}
|
||||
withinPortal={!!portalTarget}
|
||||
portalProps={{ target: portalTarget ?? undefined }}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<Popover.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
pointer
|
||||
multiline
|
||||
w={w ?? 170}
|
||||
label={label}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
{ids.length === 0 ? (
|
||||
<Input.Placeholder>{placeholder}</Input.Placeholder>
|
||||
) : (
|
||||
<span className={cellClasses.filterTriggerChips}>
|
||||
{ids.map((id) => {
|
||||
const user = store.users[id];
|
||||
const name = user?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<span key={id} className={cellClasses.filterTriggerChip}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={16}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.filterTriggerChipName}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</InputBase>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<div className={cellClasses.personTagArea}>
|
||||
{multiple &&
|
||||
ids.map((id) => {
|
||||
const user = store.users[id];
|
||||
const name = user?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<span key={id} className={cellClasses.personTag}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={18}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.personTagName}>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cellClasses.personTagRemove}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(id);
|
||||
}}
|
||||
>
|
||||
<IconX size={10} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={cellClasses.personTagInput}
|
||||
placeholder="Find a user..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className={cellClasses.personDropdownDivider} />
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filtered.map((member, idx) => (
|
||||
<div
|
||||
key={member.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
selectedSet.has(member.id) && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onClick={() => handleSelect(member.id)}
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={member.avatarUrl ?? ""}
|
||||
name={member.name ?? ""}
|
||||
size={24}
|
||||
radius="xl"
|
||||
/>
|
||||
<div className={cellClasses.personOptionText}>
|
||||
<span className={cellClasses.personOptionName}>
|
||||
{member.name ?? ""}
|
||||
</span>
|
||||
{member.email && (
|
||||
<span className={cellClasses.personOptionEmail}>
|
||||
{member.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className={cellClasses.personDropdownHint}>No users found</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type {
|
||||
DateFilterAnchor,
|
||||
DateFilterRange,
|
||||
} from "@/ee/base/types/base.types";
|
||||
|
||||
export const DATE_ANCHOR_PRESETS: { value: DateFilterAnchor; labelKey: string }[] =
|
||||
[
|
||||
{ value: "today", labelKey: "Today" },
|
||||
{ value: "tomorrow", labelKey: "Tomorrow" },
|
||||
{ value: "yesterday", labelKey: "Yesterday" },
|
||||
{ value: "oneWeekAgo", labelKey: "One week ago" },
|
||||
{ value: "oneWeekFromNow", labelKey: "One week from now" },
|
||||
{ value: "oneMonthAgo", labelKey: "One month ago" },
|
||||
{ value: "oneMonthFromNow", labelKey: "One month from now" },
|
||||
];
|
||||
|
||||
export const DATE_RANGE_PRESETS: { value: DateFilterRange; labelKey: string }[] =
|
||||
[
|
||||
{ value: "pastWeek", labelKey: "Past week" },
|
||||
{ value: "pastMonth", labelKey: "Past month" },
|
||||
{ value: "pastYear", labelKey: "Past year" },
|
||||
{ value: "thisWeek", labelKey: "This week" },
|
||||
{ value: "thisMonth", labelKey: "This month" },
|
||||
{ value: "thisYear", labelKey: "This year" },
|
||||
{ value: "nextWeek", labelKey: "Next week" },
|
||||
{ value: "nextMonth", labelKey: "Next month" },
|
||||
{ value: "nextYear", labelKey: "Next year" },
|
||||
];
|
||||
|
||||
export const ANCHOR_VALUES = new Set<string>(
|
||||
DATE_ANCHOR_PRESETS.map((p) => p.value),
|
||||
);
|
||||
export const RANGE_VALUES = new Set<string>(
|
||||
DATE_RANGE_PRESETS.map((p) => p.value),
|
||||
);
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBase } from "@/ee/base/types/base.types";
|
||||
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
|
||||
type Panel = "types" | "groupBy";
|
||||
|
||||
type ViewCreateMenuProps = {
|
||||
base: IBase;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [panel, setPanel] = useState<Panel>("types");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
const [, setActiveViewId] = useAtom(
|
||||
activeViewIdAtomFamily(pageId),
|
||||
) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
const groupable = base.properties.filter(
|
||||
(p) => p.type === "select" || p.type === "status",
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpened(false);
|
||||
setPanel("types");
|
||||
}, []);
|
||||
|
||||
const submitView = useCallback(
|
||||
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
|
||||
createViewMutation.mutate(
|
||||
{ pageId, ...input },
|
||||
{ onSuccess: (created) => setActiveViewId(created.id) },
|
||||
);
|
||||
close();
|
||||
},
|
||||
[pageId, createViewMutation, setActiveViewId, close],
|
||||
);
|
||||
|
||||
const handleCreateTable = useCallback(() => {
|
||||
submitView({ name: t("Table"), type: "table" });
|
||||
}, [submitView, t]);
|
||||
|
||||
const handleBoardClick = useCallback(() => {
|
||||
if (groupable.length <= 1) {
|
||||
const config =
|
||||
groupable.length === 1
|
||||
? { groupByPropertyId: groupable[0].id }
|
||||
: undefined;
|
||||
submitView({ name: t("Kanban"), type: "kanban", config });
|
||||
} else {
|
||||
setPanel("groupBy");
|
||||
}
|
||||
}, [groupable, submitView, t]);
|
||||
|
||||
const handleGroupByPick = useCallback(
|
||||
(propertyId: string) => {
|
||||
submitView({
|
||||
name: t("Kanban"),
|
||||
type: "kanban",
|
||||
config: { groupByPropertyId: propertyId },
|
||||
});
|
||||
},
|
||||
[submitView, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dropdownRef.current
|
||||
?.querySelector<HTMLElement>("[data-menu-item]:not([data-disabled])")
|
||||
?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [panel]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
setOpened(o);
|
||||
if (!o) setPanel("types");
|
||||
}}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={200}
|
||||
withinPortal
|
||||
closeOnItemClick={false}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Add view")}>
|
||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add view")}>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown ref={dropdownRef}>
|
||||
{panel === "types" && (
|
||||
<>
|
||||
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
|
||||
{t("Table")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
|
||||
{t("Kanban")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{panel === "groupBy" && (
|
||||
<>
|
||||
<Menu.Item leftSection={<IconArrowLeft size={14} />} onClick={() => setPanel("types")}>
|
||||
{t("Group by")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{groupable.map((p) => {
|
||||
const Icon = getDescriptor(p.type)?.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={p.id}
|
||||
leftSection={Icon ? <Icon size={14} /> : undefined}
|
||||
onClick={() => handleGroupByPick(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
TextInput,
|
||||
Popover,
|
||||
Stack,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconTable,
|
||||
IconLink,
|
||||
IconLayoutKanban,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
|
||||
import {
|
||||
useUpdateViewMutation,
|
||||
useDeleteViewMutation,
|
||||
} from "@/ee/base/queries/base-view-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||
|
||||
const VIEW_DRAG_TYPE = "base-view";
|
||||
|
||||
type ViewTabsProps = {
|
||||
views: IBaseView[];
|
||||
activeViewId: string | undefined;
|
||||
pageId: string;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
base?: IBase;
|
||||
canAddView?: boolean;
|
||||
/** Standalone base-page link for a view, used by "Copy link to view". */
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
};
|
||||
|
||||
export function ViewTabs({
|
||||
views,
|
||||
activeViewId,
|
||||
pageId,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
base,
|
||||
canAddView,
|
||||
getViewShareUrl,
|
||||
}: ViewTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const editable = useBaseEditable();
|
||||
const [editingViewId, setEditingViewId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const deleteViewMutation = useDeleteViewMutation();
|
||||
|
||||
const orderedViews = useMemo(
|
||||
() =>
|
||||
[...views].sort((a, b) =>
|
||||
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||
),
|
||||
[views],
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(sourceId: string, targetId: string, edge: Edge) => {
|
||||
if (sourceId === targetId) return;
|
||||
const remaining = orderedViews.filter((v) => v.id !== sourceId);
|
||||
const targetIndex = remaining.findIndex((v) => v.id === targetId);
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
let lowerPos: string | null = null;
|
||||
let upperPos: string | null = null;
|
||||
if (edge === "left") {
|
||||
lowerPos =
|
||||
targetIndex > 0 ? remaining[targetIndex - 1]?.position : null;
|
||||
upperPos = remaining[targetIndex]?.position ?? null;
|
||||
} else {
|
||||
lowerPos = remaining[targetIndex]?.position ?? null;
|
||||
upperPos =
|
||||
targetIndex < remaining.length - 1
|
||||
? remaining[targetIndex + 1]?.position
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
const position =
|
||||
lowerPos && upperPos && lowerPos === upperPos
|
||||
? generateJitteredKeyBetween(lowerPos, null)
|
||||
: generateJitteredKeyBetween(lowerPos, upperPos);
|
||||
updateViewMutation.mutate({ viewId: sourceId, pageId, position });
|
||||
} catch {
|
||||
// Position computation failed; skip the reorder.
|
||||
}
|
||||
},
|
||||
[orderedViews, pageId, updateViewMutation],
|
||||
);
|
||||
|
||||
const handleRenameStart = useCallback(
|
||||
(view: IBaseView) => {
|
||||
setEditingViewId(view.id);
|
||||
setEditingName(view.name);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenameCommit = useCallback(() => {
|
||||
if (!editingViewId) return;
|
||||
const trimmed = editingName.trim();
|
||||
const view = views.find((v) => v.id === editingViewId);
|
||||
if (trimmed && view && trimmed !== view.name) {
|
||||
updateViewMutation.mutate({
|
||||
viewId: editingViewId,
|
||||
pageId,
|
||||
name: trimmed,
|
||||
});
|
||||
}
|
||||
setEditingViewId(null);
|
||||
}, [editingViewId, editingName, views, pageId, updateViewMutation]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameCommit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditingViewId(null);
|
||||
}
|
||||
},
|
||||
[handleRenameCommit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(viewId: string) => {
|
||||
if (orderedViews.length <= 1) return;
|
||||
deleteViewMutation.mutate({ viewId, pageId });
|
||||
if (viewId === activeViewId) {
|
||||
const remaining = orderedViews.filter((v) => v.id !== viewId);
|
||||
onViewChange(remaining[0].id);
|
||||
}
|
||||
},
|
||||
[orderedViews, pageId, activeViewId, deleteViewMutation, onViewChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{orderedViews.map((view) => (
|
||||
<ViewTab
|
||||
key={view.id}
|
||||
view={view}
|
||||
isActive={view.id === activeViewId}
|
||||
isEditing={view.id === editingViewId}
|
||||
editingName={editingName}
|
||||
canDelete={orderedViews.length > 1}
|
||||
reorderEnabled={editable && orderedViews.length > 1}
|
||||
onReorder={handleReorder}
|
||||
onClick={() => onViewChange(view.id)}
|
||||
onRenameStart={() => handleRenameStart(view)}
|
||||
onRenameChange={setEditingName}
|
||||
onRenameCommit={handleRenameCommit}
|
||||
onRenameKeyDown={handleRenameKeyDown}
|
||||
onDelete={() => handleDelete(view.id)}
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
))}
|
||||
{canAddView && base && (
|
||||
<ViewCreateMenu base={base} pageId={pageId} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewTab({
|
||||
view,
|
||||
isActive,
|
||||
isEditing,
|
||||
editingName,
|
||||
canDelete,
|
||||
reorderEnabled,
|
||||
onReorder,
|
||||
onClick,
|
||||
onRenameStart,
|
||||
onRenameChange,
|
||||
onRenameCommit,
|
||||
onRenameKeyDown,
|
||||
onDelete,
|
||||
getViewShareUrl,
|
||||
}: {
|
||||
view: IBaseView;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
editingName: string;
|
||||
canDelete: boolean;
|
||||
reorderEnabled: boolean;
|
||||
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
|
||||
onClick: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameChange: (name: string) => void;
|
||||
onRenameCommit: () => void;
|
||||
onRenameKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onDelete: () => void;
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const editable = useBaseEditable();
|
||||
const tabRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const onReorderRef = useRef(onReorder);
|
||||
useLayoutEffect(() => {
|
||||
onReorderRef.current = onReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabRef.current;
|
||||
if (!el || !reorderEnabled || isEditing) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({ type: VIEW_DRAG_TYPE, viewId: view.id }),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === VIEW_DRAG_TYPE &&
|
||||
source.data.viewId !== view.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ viewId: view.id },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onReorderRef.current(source.data.viewId as string, view.id, edge);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [view.id, reorderEnabled, isEditing]);
|
||||
|
||||
const handleTabClick = useCallback(() => {
|
||||
if (isActive) {
|
||||
setMenuOpened((o) => !o);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}, [isActive, onClick]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
setMenuOpened(false);
|
||||
const url = getViewShareUrl?.(view.id);
|
||||
if (!url) return;
|
||||
void navigator.clipboard.writeText(url);
|
||||
notifications.show({ message: t("Link copied to clipboard") });
|
||||
}, [getViewShareUrl, view.id, t]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "1px 10px",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-xl)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
variant="unstyled"
|
||||
size="xs"
|
||||
value={editingName}
|
||||
onChange={(e) => onRenameChange(e.currentTarget.value)}
|
||||
onBlur={onRenameCommit}
|
||||
onKeyDown={onRenameKeyDown}
|
||||
autoFocus
|
||||
styles={{
|
||||
input: {
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
width: 100,
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tabRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={180}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={handleTabClick}
|
||||
style={{
|
||||
padding: "2px 10px",
|
||||
borderRadius: "var(--mantine-radius-xl)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
backgroundColor: isActive
|
||||
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{view.type === "kanban" ? (
|
||||
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
|
||||
) : (
|
||||
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
|
||||
)}
|
||||
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
|
||||
{view.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Stack gap={0}>
|
||||
{editable && (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onRenameStart();
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconPencil size={14} />
|
||||
<Text size="sm">{t("Rename")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{getViewShareUrl && (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconLink size={14} />
|
||||
<Text size="sm">{t("Copy link to view")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{editable && canDelete && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onDelete();
|
||||
}}
|
||||
style={{ color: "var(--mantine-color-red-6)" }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconTrash size={14} />
|
||||
<Text size="sm">{t("Delete view")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
export type Currency = { code: string; name: string };
|
||||
|
||||
// Most-used first; order drives the dropdown.
|
||||
export const CURRENCIES: Currency[] = [
|
||||
{ code: "USD", name: "US Dollar" },
|
||||
{ code: "EUR", name: "Euro" },
|
||||
{ code: "GBP", name: "Pound" },
|
||||
{ code: "CAD", name: "Canadian dollar" },
|
||||
{ code: "AUD", name: "Australian dollar" },
|
||||
{ code: "SGD", name: "Singapore dollar" },
|
||||
{ code: "JPY", name: "Yen" },
|
||||
{ code: "CNY", name: "Chinese Yuan" },
|
||||
];
|
||||
|
||||
export const DEFAULT_CURRENCY_CODE = "USD";
|
||||
|
||||
const CURRENCY_CODES = new Set(CURRENCIES.map((c) => c.code));
|
||||
|
||||
// Renders value with locale symbol and grouping. Falls back to USD for unknown codes,
|
||||
// plain string if Intl throws. precision overrides the currency's natural decimal places.
|
||||
export function formatCurrency(
|
||||
value: number,
|
||||
code: string | undefined,
|
||||
precision: number | undefined,
|
||||
): string {
|
||||
const currency =
|
||||
code && CURRENCY_CODES.has(code) ? code : DEFAULT_CURRENCY_CODE;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency,
|
||||
...(precision != null
|
||||
? { minimumFractionDigits: precision, maximumFractionDigits: precision }
|
||||
: {}),
|
||||
}).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const BaseEditableContext = createContext<boolean>(true);
|
||||
|
||||
export function BaseEditableProvider({
|
||||
editable,
|
||||
children,
|
||||
}: {
|
||||
editable: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<BaseEditableContext.Provider value={editable}>
|
||||
{children}
|
||||
</BaseEditableContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the current base subtree is editable. Defaults to true outside a provider. */
|
||||
export function useBaseEditable(): boolean {
|
||||
return useContext(BaseEditableContext);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// Row order is only needed at interaction time (shift-select range math), so
|
||||
// rows subscribe to a stable getter instead of the array itself — appending a
|
||||
// page must not re-render every mounted row.
|
||||
const GridRowOrderContext = createContext<() => string[]>(() => []);
|
||||
|
||||
export const GridRowOrderProvider = GridRowOrderContext.Provider;
|
||||
|
||||
export function useGridRowOrder(): () => string[] {
|
||||
return useContext(GridRowOrderContext);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// Rows only need the handler at click time; a stable context value keeps the
|
||||
// expand affordance out of every GridRow/GridCell memo equality check.
|
||||
const RowExpandContext = createContext<((rowId: string) => void) | null>(null);
|
||||
|
||||
export const RowExpandProvider = RowExpandContext.Provider;
|
||||
|
||||
export function useRowExpand(): ((rowId: string) => void) | null {
|
||||
return useContext(RowExpandContext);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { formatNumber } from "@/ee/base/components/cells/cell-number";
|
||||
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
|
||||
|
||||
export { formatNumber, formatDateDisplay };
|
||||
|
||||
export function formatTimestamp(value: string | null | undefined): string {
|
||||
if (typeof value !== "string" || !value) return "";
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLongTextPreview(value: string | null | undefined): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useStore, type PrimitiveAtom } from "jotai";
|
||||
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
|
||||
|
||||
export type UseEditableTextCellParams = {
|
||||
value: unknown;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
/** value -> the draft string shown in the input when editing begins */
|
||||
toDraft: (value: unknown) => string;
|
||||
/** draft string -> the value passed to onCommit */
|
||||
parse: (draft: string) => unknown;
|
||||
rowId?: string;
|
||||
propertyId?: string;
|
||||
};
|
||||
|
||||
export type EditableTextCell = {
|
||||
draft: string;
|
||||
setDraft: (draft: string) => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handleBlur: () => void;
|
||||
};
|
||||
|
||||
export function useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse,
|
||||
rowId,
|
||||
propertyId,
|
||||
}: UseEditableTextCellParams): EditableTextCell {
|
||||
const [draft, setDraft] = useState(() => toDraft(value));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
const wasEditingRef = useRef(false);
|
||||
const toDraftRef = useRef(toDraft);
|
||||
toDraftRef.current = toDraft;
|
||||
const store = useStore();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isEditing && !wasEditingRef.current) {
|
||||
committedRef.current = false;
|
||||
const pending = store.get(pendingTypeInsertAtom);
|
||||
const seeded =
|
||||
pending != null &&
|
||||
pending.rowId === rowId &&
|
||||
pending.propertyId === propertyId;
|
||||
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
|
||||
if (seeded) {
|
||||
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
|
||||
}
|
||||
setDraft(nextDraft);
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.value = nextDraft;
|
||||
el.focus({ preventScroll: true });
|
||||
try {
|
||||
el.setSelectionRange(nextDraft.length, nextDraft.length);
|
||||
} catch {
|
||||
// email/number inputs reject setSelectionRange
|
||||
}
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
}
|
||||
}
|
||||
wasEditingRef.current = isEditing;
|
||||
}, [isEditing, value, rowId, propertyId, store]);
|
||||
|
||||
const commitOnce = useCallback(
|
||||
(val: unknown) => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(val);
|
||||
},
|
||||
[onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitOnce(parse(draft));
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
committedRef.current = true;
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[draft, parse, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(parse(draft));
|
||||
}, [draft, parse, commitOnce]);
|
||||
|
||||
return { draft, setDraft, inputRef, handleKeyDown, handleBlur };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useEscapeClose(opened: boolean, onClose: () => void) {
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [opened, onClose]);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
|
||||
import { COLUMN_DRAG_TYPE } from "@/ee/base/components/grid/grid-header-cell";
|
||||
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
|
||||
|
||||
const HEADER_BAND_REACH_PX = 60;
|
||||
const EDGE_OUTWARD_REACH_PX = 80;
|
||||
|
||||
const EARLY_PAN_MARGIN_PX = 100;
|
||||
const MIN_PAN_SPEED_PX = 3;
|
||||
const MAX_PAN_SPEED_PX = 16;
|
||||
|
||||
export function useGridAutoScroll<T extends HTMLElement>(
|
||||
bodyRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = bodyRef.current;
|
||||
if (!element) return;
|
||||
|
||||
let rafId = 0;
|
||||
let pointerX: number | null = null;
|
||||
// Captured once at drag start: cursor-to-column-left-edge distance and column width.
|
||||
let grabOffsetX = 0;
|
||||
let columnWidth = 0;
|
||||
|
||||
let lockedScrollLeft: number | null = null;
|
||||
const keepHorizontalScroll = () => {
|
||||
if (lockedScrollLeft !== null && element.scrollLeft !== lockedScrollLeft) {
|
||||
element.scrollLeft = lockedScrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
function speedForDepth(distanceFromEdge: number): number {
|
||||
const depth = Math.min(1, (EARLY_PAN_MARGIN_PX - distanceFromEdge) / EARLY_PAN_MARGIN_PX);
|
||||
return MIN_PAN_SPEED_PX + (MAX_PAN_SPEED_PX - MIN_PAN_SPEED_PX) * depth;
|
||||
}
|
||||
|
||||
function pan() {
|
||||
if (pointerX === null) {
|
||||
rafId = 0;
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const columnLeft = pointerX - grabOffsetX;
|
||||
const columnRight = columnLeft + columnWidth;
|
||||
const fromLeft = columnLeft - rect.left;
|
||||
const fromRight = rect.right - columnRight;
|
||||
let delta = 0;
|
||||
if (fromLeft < EARLY_PAN_MARGIN_PX) {
|
||||
delta = -speedForDepth(fromLeft);
|
||||
} else if (fromRight < EARLY_PAN_MARGIN_PX) {
|
||||
delta = speedForDepth(fromRight);
|
||||
}
|
||||
if (delta !== 0) element.scrollLeft += delta;
|
||||
rafId = requestAnimationFrame(pan);
|
||||
}
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE &&
|
||||
source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE &&
|
||||
source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
getOverflow: () => ({
|
||||
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
}),
|
||||
}),
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
onDragStart: ({ location, source }) => {
|
||||
const cr = source.element.getBoundingClientRect();
|
||||
grabOffsetX = location.current.input.clientX - cr.left;
|
||||
columnWidth = cr.width;
|
||||
pointerX = location.current.input.clientX;
|
||||
if (rafId === 0) rafId = requestAnimationFrame(pan);
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
pointerX = location.current.input.clientX;
|
||||
},
|
||||
onDrop: () => {
|
||||
pointerX = null;
|
||||
if (rafId !== 0) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
onDragStart: () => {
|
||||
lockedScrollLeft = element.scrollLeft;
|
||||
element.addEventListener("scroll", keepHorizontalScroll);
|
||||
},
|
||||
onDrop: () => {
|
||||
element.removeEventListener("scroll", keepHorizontalScroll);
|
||||
lockedScrollLeft = null;
|
||||
},
|
||||
}),
|
||||
() => {
|
||||
if (rafId !== 0) cancelAnimationFrame(rafId);
|
||||
element.removeEventListener("scroll", keepHorizontalScroll);
|
||||
},
|
||||
);
|
||||
}, [bodyRef, pageId]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user