mirror of
https://github.com/docmost/docmost.git
synced 2026-06-16 06:57:01 +08:00
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.
|
||||
@@ -28,8 +28,6 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.90.1",
|
||||
"version": "0.90.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.14.2",
|
||||
"@tanstack/react-virtual": "3.13.24",
|
||||
"alfaaz": "1.1.0",
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"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",
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
|
||||
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
|
||||
"Page menu for {{name}}": "Seitenmenü für {{name}}",
|
||||
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
|
||||
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
|
||||
"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",
|
||||
@@ -444,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",
|
||||
@@ -617,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",
|
||||
@@ -1125,23 +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"
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
|
||||
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
|
||||
"Page menu for {{name}}": "Menú de página para {{name}}",
|
||||
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
|
||||
"Create subpage of {{name}}": "Crear subpágina de {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
|
||||
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
|
||||
"Page menu for {{name}}": "Menu de la page pour {{name}}",
|
||||
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
|
||||
"Create subpage of {{name}}": "Créer une sous-page de {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
|
||||
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
|
||||
"Page menu for {{name}}": "Menu della pagina per {{name}}",
|
||||
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
|
||||
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
|
||||
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
|
||||
"Page menu for {{name}}": "{{name}} のページメニュー",
|
||||
"Create subpage of {{name}}": "{{name}} のサブページを作成"
|
||||
"Create subpage of {{name}}": "{{name}} のサブページを作成",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
|
||||
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
|
||||
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
|
||||
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
|
||||
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
|
||||
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
|
||||
"Page menu for {{name}}": "Paginamenu voor {{name}}",
|
||||
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
|
||||
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
|
||||
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
|
||||
"Page menu for {{name}}": "Menu da página de {{name}}",
|
||||
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
|
||||
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "{{name}} додано до обраного",
|
||||
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
|
||||
"Page menu for {{name}}": "Меню сторінки для {{name}}",
|
||||
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
|
||||
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1084,5 +1084,23 @@
|
||||
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
|
||||
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
|
||||
"Page menu for {{name}}": "{{name}} 的页面菜单",
|
||||
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
|
||||
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
|
||||
"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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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,299 +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 { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
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 editable = useBaseEditable();
|
||||
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}>
|
||||
{editable && (
|
||||
<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,526 +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,
|
||||
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,
|
||||
} 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 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(() => {
|
||||
if (!editable) return;
|
||||
createRowMutation.mutate(
|
||||
{ pageId },
|
||||
{
|
||||
onSuccess: (newRow) => {
|
||||
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)
|
||||
);
|
||||
});
|
||||
const propertyId = (
|
||||
firstEditable?.columnDef.meta?.property as IBaseProperty | undefined
|
||||
)?.id;
|
||||
if (propertyId) {
|
||||
setEditingCell({ rowId: newRow.id, propertyId });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [editable, pageId, createRowMutation, table, setEditingCell]);
|
||||
|
||||
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 { v7 as uuid7 } from "uuid";
|
||||
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: uuid7(),
|
||||
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,290 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
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 handleDoubleClick = 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") {
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property.type === "formula") {
|
||||
setActiveFormulaEditor({ propertyId: property.id, rowId });
|
||||
return;
|
||||
}
|
||||
if (isSystemPropertyType(property.type)) return;
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!property) return;
|
||||
setFocusedCell({ rowId, propertyId: property.id });
|
||||
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
},
|
||||
[property, rowId, setFocusedCell],
|
||||
);
|
||||
|
||||
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}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<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}
|
||||
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,551 +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?: () => 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],
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
useGridKeyboardNav({
|
||||
table,
|
||||
properties,
|
||||
containerRef: bodyRef,
|
||||
focusedCell,
|
||||
setFocusedCell,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
openEditor,
|
||||
clearCell,
|
||||
beginTypeToEdit,
|
||||
scrollCellIntoView,
|
||||
selectionCount,
|
||||
clearSelection,
|
||||
deleteSelected,
|
||||
toggleRowSelection,
|
||||
expandRow,
|
||||
});
|
||||
|
||||
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,102 +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 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}
|
||||
>
|
||||
<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,213 +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, 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!}
|
||||
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,76 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, Menu, Text } from "@mantine/core";
|
||||
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { 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 classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
type KanbanColumnHeaderProps = {
|
||||
column: KanbanColumn;
|
||||
pageId: string;
|
||||
count?: string;
|
||||
canEdit: boolean;
|
||||
onHide: () => void;
|
||||
onAddCard: () => void;
|
||||
};
|
||||
|
||||
export function KanbanColumnHeader({ column, pageId, 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,
|
||||
}}
|
||||
/>
|
||||
<Text fw={600} size="sm" flex={1} truncate>
|
||||
{column.isNoValue ? t("No value") : column.name}
|
||||
</Text>
|
||||
{count !== undefined && <Text className={classes.count}>{count}</Text>}
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +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 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;
|
||||
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,
|
||||
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}
|
||||
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 { v7 as uuid7 } from "uuid";
|
||||
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 = uuid7();
|
||||
const inProgressId = uuid7();
|
||||
const completeId = uuid7();
|
||||
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" justify="center" gap="md" style={{ flex: 1 }}>
|
||||
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
|
||||
<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,103 +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}
|
||||
>
|
||||
<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,75 +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
|
||||
>
|
||||
<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,117 +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}
|
||||
>
|
||||
<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,439 +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 }}
|
||||
>
|
||||
{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,102 +0,0 @@
|
||||
import { useCallback, useEffect, 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();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && !wasEditingRef.current) {
|
||||
committedRef.current = false;
|
||||
const pending = store.get(pendingTypeInsertAtom);
|
||||
const seeded =
|
||||
pending != null &&
|
||||
pending.rowId === rowId &&
|
||||
pending.propertyId === propertyId;
|
||||
if (seeded) {
|
||||
setDraft(pending.char);
|
||||
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
const len = el.value.length;
|
||||
el.setSelectionRange(len, len);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setDraft(toDraftRef.current(value));
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IBaseRow,
|
||||
IBaseProperty,
|
||||
EditingCell,
|
||||
FocusedCell,
|
||||
CellCoord,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
|
||||
|
||||
type UseGridKeyboardNavOptions = {
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
focusedCell: FocusedCell;
|
||||
setFocusedCell: (cell: FocusedCell) => void;
|
||||
editingCell: EditingCell;
|
||||
setEditingCell: (cell: EditingCell) => void;
|
||||
openEditor: (coord: CellCoord) => void;
|
||||
clearCell: (coord: CellCoord) => void;
|
||||
beginTypeToEdit: (coord: CellCoord, char: string) => void;
|
||||
scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void;
|
||||
selectionCount: number;
|
||||
clearSelection: () => void;
|
||||
deleteSelected: () => void | Promise<void>;
|
||||
toggleRowSelection: (rowId: string) => void;
|
||||
expandRow: (rowId: string) => void;
|
||||
};
|
||||
|
||||
const isPrintableKey = (e: KeyboardEvent) =>
|
||||
e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
|
||||
|
||||
const isTextEntry = (el: Element | null) =>
|
||||
!!el &&
|
||||
(el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable);
|
||||
|
||||
export function useGridKeyboardNav({
|
||||
table,
|
||||
properties,
|
||||
containerRef,
|
||||
focusedCell,
|
||||
setFocusedCell,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
openEditor,
|
||||
clearCell,
|
||||
beginTypeToEdit,
|
||||
scrollCellIntoView,
|
||||
selectionCount,
|
||||
clearSelection,
|
||||
deleteSelected,
|
||||
toggleRowSelection,
|
||||
expandRow,
|
||||
}: UseGridKeyboardNavOptions) {
|
||||
const getColIds = useCallback(
|
||||
() =>
|
||||
table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const getNavColIds = useCallback(
|
||||
() => table.getVisibleLeafColumns().map((col) => col.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const getRowIds = useCallback(
|
||||
() => table.getRowModel().rows.map((row) => row.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const propertyType = useCallback(
|
||||
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
|
||||
[properties],
|
||||
);
|
||||
|
||||
const goEditing = useCallback(
|
||||
(next: CellCoord) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditingCell(next);
|
||||
setFocusedCell(next);
|
||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
||||
},
|
||||
[setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds],
|
||||
);
|
||||
|
||||
const goFocused = useCallback(
|
||||
(next: CellCoord) => {
|
||||
setFocusedCell(next);
|
||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
||||
},
|
||||
[setFocusedCell, scrollCellIntoView, getRowIds],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (editingCell) {
|
||||
const inInput = isTextEntry(e.target as Element);
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight": {
|
||||
if (inInput) return;
|
||||
e.preventDefault();
|
||||
const d =
|
||||
e.key === "ArrowUp"
|
||||
? [-1, 0]
|
||||
: e.key === "ArrowDown"
|
||||
? [1, 0]
|
||||
: e.key === "ArrowLeft"
|
||||
? [0, -1]
|
||||
: [0, 1];
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
d[0],
|
||||
d[1],
|
||||
false,
|
||||
);
|
||||
if (next) goEditing(next);
|
||||
break;
|
||||
}
|
||||
case "Tab": {
|
||||
e.preventDefault();
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
0,
|
||||
e.shiftKey ? -1 : 1,
|
||||
true,
|
||||
);
|
||||
if (next) goEditing(next);
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditingCell(null);
|
||||
if (next) goFocused(next);
|
||||
else setFocusedCell(editingCell);
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
e.preventDefault();
|
||||
setEditingCell(null);
|
||||
setFocusedCell(editingCell);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEntry(document.activeElement)) return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
clearSelection();
|
||||
} else if (focusedCell) {
|
||||
e.preventDefault();
|
||||
setFocusedCell(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
void deleteSelected();
|
||||
} else if (focusedCell) {
|
||||
e.preventDefault();
|
||||
clearCell(focusedCell);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!focusedCell) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "Tab": {
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getNavColIds(),
|
||||
focusedCell,
|
||||
0,
|
||||
e.shiftKey ? -1 : 1,
|
||||
true,
|
||||
);
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
goFocused(next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter":
|
||||
case "F2":
|
||||
e.preventDefault();
|
||||
if (focusedCell.propertyId === "__row_number") {
|
||||
toggleRowSelection(focusedCell.rowId);
|
||||
} else {
|
||||
openEditor(focusedCell);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (focusedCell.propertyId === "__row_number") {
|
||||
toggleRowSelection(focusedCell.rowId);
|
||||
} else if (propertyType(focusedCell.propertyId) === "checkbox") {
|
||||
openEditor(focusedCell);
|
||||
} else {
|
||||
expandRow(focusedCell.rowId);
|
||||
}
|
||||
} else if (isPrintableKey(e)) {
|
||||
e.preventDefault();
|
||||
beginTypeToEdit(focusedCell, e.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
editingCell,
|
||||
focusedCell,
|
||||
getRowIds,
|
||||
getColIds,
|
||||
getNavColIds,
|
||||
goEditing,
|
||||
goFocused,
|
||||
setEditingCell,
|
||||
setFocusedCell,
|
||||
openEditor,
|
||||
clearCell,
|
||||
beginTypeToEdit,
|
||||
propertyType,
|
||||
selectionCount,
|
||||
clearSelection,
|
||||
deleteSelected,
|
||||
toggleRowSelection,
|
||||
expandRow,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("keydown", handleKeyDown);
|
||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
||||
}, [containerRef, handleKeyDown]);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
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 { KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
|
||||
const HEADER_BAND_REACH_PX = 60;
|
||||
const EDGE_OUTWARD_REACH_PX = 80;
|
||||
|
||||
export function useKanbanBoardAutoScroll<T extends HTMLElement>(
|
||||
boardRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = boardRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||
(source.data?.type === KANBAN_CARD_DRAG_TYPE ||
|
||||
source.data?.type === KANBAN_COLUMN_DRAG_TYPE) &&
|
||||
source.data?.pageId === pageId;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
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 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}, [boardRef, pageId]);
|
||||
}
|
||||
|
||||
export function useKanbanColumnAutoScroll<T extends HTMLElement>(
|
||||
listRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = listRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
getOverflow: () => ({
|
||||
forTopEdge: { top: EDGE_OUTWARD_REACH_PX },
|
||||
forBottomEdge: { bottom: EDGE_OUTWARD_REACH_PX },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}, [listRef, pageId]);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
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 { KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
import classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
export function useKanbanCardDnd({
|
||||
cardRef,
|
||||
rowId,
|
||||
columnKey,
|
||||
pageId,
|
||||
}: {
|
||||
cardRef: RefObject<HTMLDivElement | null>;
|
||||
rowId: string;
|
||||
columnKey: string;
|
||||
pageId: string;
|
||||
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cardEl = cardRef.current;
|
||||
if (!cardEl) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: cardEl,
|
||||
getInitialData: () => ({
|
||||
type: KANBAN_CARD_DRAG_TYPE,
|
||||
rowId,
|
||||
columnKey,
|
||||
pageId,
|
||||
}),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
const width = cardEl.getBoundingClientRect().width;
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
|
||||
render: ({ container }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = classes.cardDragPreview;
|
||||
card.style.width = `${width}px`;
|
||||
const clone = cardEl.cloneNode(true) as HTMLElement;
|
||||
clone.style.opacity = "1";
|
||||
card.appendChild(clone);
|
||||
container.appendChild(card);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: cardEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === KANBAN_CARD_DRAG_TYPE &&
|
||||
source.data.pageId === pageId,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ rowId, columnKey },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: () => setClosestEdge(null),
|
||||
}),
|
||||
);
|
||||
}, [cardRef, rowId, columnKey, pageId]);
|
||||
|
||||
return { closestEdge, isDragging };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { NO_VALUE_CHOICE_ID, type IBaseRow } from "@/ee/base/types/base.types";
|
||||
|
||||
export function resolveCardDrop(args: {
|
||||
draggedRowId: string;
|
||||
targetRowId: string | null;
|
||||
edge: "top" | "bottom" | null;
|
||||
targetColumnKey: string;
|
||||
sourceColumnKey: string;
|
||||
targetColumnRows: IBaseRow[];
|
||||
}): { columnChanged: boolean; destChoiceValue: string | null; position: string } | null {
|
||||
const { draggedRowId, targetRowId, edge, targetColumnKey, sourceColumnKey, targetColumnRows } = args;
|
||||
const columnChanged = sourceColumnKey !== targetColumnKey;
|
||||
if (!columnChanged && draggedRowId === targetRowId) return null;
|
||||
const destChoiceValue = targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey;
|
||||
const rows = targetColumnRows.filter((r) => r.id !== draggedRowId);
|
||||
let position: string;
|
||||
if (!targetRowId || edge === null) {
|
||||
const last = rows[rows.length - 1];
|
||||
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||
} else {
|
||||
const idx = rows.findIndex((r) => r.id === targetRowId);
|
||||
if (idx === -1) {
|
||||
const last = rows[rows.length - 1];
|
||||
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||
} else {
|
||||
const neighbor = edge === "top" ? idx - 1 : idx + 1;
|
||||
const lower = edge === "top" ? rows[neighbor]?.position ?? null : rows[idx].position;
|
||||
const upper = edge === "top" ? rows[idx].position : rows[neighbor]?.position ?? null;
|
||||
position = generateJitteredKeyBetween(lower, upper);
|
||||
}
|
||||
}
|
||||
return { columnChanged, destChoiceValue, position };
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
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 { KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
|
||||
export function useKanbanColumnDnd({
|
||||
headerRef,
|
||||
handleRef,
|
||||
columnKey,
|
||||
pageId,
|
||||
}: {
|
||||
headerRef: RefObject<HTMLDivElement | null>;
|
||||
handleRef: RefObject<HTMLDivElement | null>;
|
||||
columnKey: string;
|
||||
pageId: string;
|
||||
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const headerEl = headerRef.current;
|
||||
const handleEl = handleRef.current;
|
||||
if (!headerEl || !handleEl) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: headerEl,
|
||||
dragHandle: handleEl,
|
||||
getInitialData: () => ({
|
||||
type: KANBAN_COLUMN_DRAG_TYPE,
|
||||
columnKey,
|
||||
pageId,
|
||||
}),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: headerEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === KANBAN_COLUMN_DRAG_TYPE &&
|
||||
source.data.pageId === pageId &&
|
||||
source.data.columnKey !== columnKey,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ columnKey },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: () => setClosestEdge(null),
|
||||
}),
|
||||
);
|
||||
}, [headerRef, handleRef, columnKey, pageId]);
|
||||
|
||||
return { closestEdge, isDragging };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||
|
||||
export type KanbanGroup = KanbanColumn & { hidden: boolean };
|
||||
|
||||
export function useKanbanColumns(
|
||||
base: IBase | undefined,
|
||||
view: IBaseView | undefined,
|
||||
): {
|
||||
groupByPropertyId: string | undefined;
|
||||
columns: KanbanColumn[];
|
||||
allGroups: KanbanGroup[];
|
||||
hasValidGroupBy: boolean;
|
||||
} {
|
||||
return useMemo(() => {
|
||||
const groupByPropertyId = view?.config?.groupByPropertyId;
|
||||
const prop = groupByPropertyId ? base?.properties.find((p) => p.id === groupByPropertyId) : undefined;
|
||||
const groupable = prop && (prop.type === "select" || prop.type === "status");
|
||||
|
||||
if (!groupable || !prop || !view) {
|
||||
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
|
||||
}
|
||||
|
||||
const typeOptions = prop.typeOptions as SelectTypeOptions;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const choiceMap = new Map(choices.map((c) => [c.id, c]));
|
||||
const validKeys = new Set([NO_VALUE_CHOICE_ID, ...choices.map((c) => c.id)]);
|
||||
|
||||
const config = view.config;
|
||||
const configChoiceOrder: string[] = config.choiceOrder?.length
|
||||
? config.choiceOrder.filter((k) => validKeys.has(k))
|
||||
: [...(typeOptions?.choiceOrder ?? choices.map((c) => c.id)), NO_VALUE_CHOICE_ID];
|
||||
|
||||
const inOrder = new Set(configChoiceOrder);
|
||||
const baseOrder = [
|
||||
...configChoiceOrder,
|
||||
...choices.map((c) => c.id).filter((id) => !inOrder.has(id)),
|
||||
];
|
||||
|
||||
const hidden = new Set(config.hiddenChoiceIds ?? []);
|
||||
const allGroups: KanbanGroup[] = baseOrder.map((k) => {
|
||||
if (k === NO_VALUE_CHOICE_ID) {
|
||||
return { key: k, name: "No value", color: undefined, isNoValue: true, hidden: hidden.has(k) };
|
||||
}
|
||||
const choice = choiceMap.get(k);
|
||||
return { key: k, name: choice?.name ?? k, color: choice?.color, isNoValue: false, hidden: hidden.has(k) };
|
||||
});
|
||||
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
|
||||
|
||||
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
|
||||
}, [base, view]);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
|
||||
export type PersonSuggestion = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export function usePersonSearch(
|
||||
search: string,
|
||||
enabled: boolean,
|
||||
): PersonSuggestion[] {
|
||||
const [debounced] = useDebouncedValue(search, 250);
|
||||
const trimmed = debounced.trim();
|
||||
const { data = [] } = useQuery({
|
||||
queryKey: ["bases", "persons", "search", trimmed],
|
||||
queryFn: async () => {
|
||||
const res = await searchSuggestions({
|
||||
query: trimmed,
|
||||
includeUsers: true,
|
||||
limit: trimmed ? 25 : 10,
|
||||
});
|
||||
return (res.users ?? []) as PersonSuggestion[];
|
||||
},
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
autoScrollForElements,
|
||||
autoScrollWindowForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
|
||||
|
||||
export function useRowAutoScroll(
|
||||
scrollElement: HTMLElement | Window | null,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!scrollElement) return;
|
||||
if (scrollElement === window) {
|
||||
return autoScrollWindowForElements({
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
});
|
||||
}
|
||||
return autoScrollForElements({
|
||||
element: scrollElement as HTMLElement,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
});
|
||||
}, [scrollElement, pageId]);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
const PARAM = "row";
|
||||
const BASE_PARAM = "rowBase";
|
||||
|
||||
export function useRowDetailModal(baseId: string) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rowParam = searchParams.get(PARAM);
|
||||
const openRowId =
|
||||
rowParam && searchParams.get(BASE_PARAM) === baseId ? rowParam : null;
|
||||
|
||||
const openRow = useCallback(
|
||||
(rowId: string, options?: { replace?: boolean }) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set(PARAM, rowId);
|
||||
next.set(BASE_PARAM, baseId);
|
||||
// Prev/next inside the modal replaces the entry so Back leaves the
|
||||
// modal instead of replaying every visited record.
|
||||
setSearchParams(next, { replace: options?.replace ?? false });
|
||||
},
|
||||
[searchParams, setSearchParams, baseId],
|
||||
);
|
||||
|
||||
const closeRow = useCallback(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(PARAM);
|
||||
next.delete(BASE_PARAM);
|
||||
setSearchParams(next, { replace: false });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return { openRowId, openRow, closeRow };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type React from 'react';
|
||||
import type { IconLetterT } from '@tabler/icons-react';
|
||||
import type {
|
||||
BasePropertyType,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
TypeOptions,
|
||||
} from '@/ee/base/types/base.types';
|
||||
|
||||
export type CellComponentProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
/** When true the cell must not write; reveal content read-only. */
|
||||
readOnly?: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onValueChange: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
onTabNavigate?: (shiftKey: boolean) => void;
|
||||
};
|
||||
|
||||
export type FilterInputKind =
|
||||
| 'choices'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'text'
|
||||
| 'person'
|
||||
| 'date';
|
||||
|
||||
export type ClientPropertyTypeDescriptor = {
|
||||
type: BasePropertyType;
|
||||
cellComponent: React.ComponentType<CellComponentProps>;
|
||||
icon: typeof IconLetterT;
|
||||
labelKey: string;
|
||||
filterOperators: string[];
|
||||
filterInput: FilterInputKind;
|
||||
isSystem: boolean;
|
||||
hasOptions: boolean;
|
||||
systemAccessor?: (row: IBaseRow) => unknown;
|
||||
defaultTypeOptions?: () => TypeOptions;
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import {
|
||||
IconLetterT,
|
||||
IconAlignLeft,
|
||||
IconHash,
|
||||
IconCircleDot,
|
||||
IconProgressCheck,
|
||||
IconTags,
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconFileDescription,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconClockPlus,
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
IconMathFunction,
|
||||
} from "@tabler/icons-react";
|
||||
import type {
|
||||
BasePropertyType,
|
||||
TypeOptions,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { CellText } from "@/ee/base/components/cells/cell-text";
|
||||
import { CellLongText } from "@/ee/base/components/cells/cell-long-text";
|
||||
import { CellNumber } from "@/ee/base/components/cells/cell-number";
|
||||
import { CellSelect } from "@/ee/base/components/cells/cell-select";
|
||||
import { CellStatus } from "@/ee/base/components/cells/cell-status";
|
||||
import { CellMultiSelect } from "@/ee/base/components/cells/cell-multi-select";
|
||||
import { CellDate } from "@/ee/base/components/cells/cell-date";
|
||||
import { CellCheckbox } from "@/ee/base/components/cells/cell-checkbox";
|
||||
import { CellUrl } from "@/ee/base/components/cells/cell-url";
|
||||
import { CellEmail } from "@/ee/base/components/cells/cell-email";
|
||||
import { CellPerson } from "@/ee/base/components/cells/cell-person";
|
||||
import { CellFile } from "@/ee/base/components/cells/cell-file";
|
||||
import { CellPage } from "@/ee/base/components/cells/cell-page";
|
||||
import { CellCreatedAt } from "@/ee/base/components/cells/cell-created-at";
|
||||
import { CellLastEditedAt } from "@/ee/base/components/cells/cell-last-edited-at";
|
||||
import { CellLastEditedBy } from "@/ee/base/components/cells/cell-last-edited-by";
|
||||
import { CellFormula } from "@/ee/base/components/cells/cell-formula";
|
||||
import { defaultStatusChoices } from "@/ee/base/components/property/choice-editor";
|
||||
import type { ClientPropertyTypeDescriptor } from "./property-type.descriptor";
|
||||
|
||||
export const PROPERTY_TYPE_REGISTRY: Record<
|
||||
BasePropertyType,
|
||||
ClientPropertyTypeDescriptor
|
||||
> = {
|
||||
text: {
|
||||
type: "text",
|
||||
cellComponent: CellText,
|
||||
icon: IconLetterT,
|
||||
labelKey: "Text",
|
||||
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
longText: {
|
||||
type: "longText",
|
||||
cellComponent: CellLongText,
|
||||
icon: IconAlignLeft,
|
||||
labelKey: "Long text",
|
||||
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
number: {
|
||||
type: "number",
|
||||
cellComponent: CellNumber,
|
||||
icon: IconHash,
|
||||
labelKey: "Number",
|
||||
filterOperators: ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "number",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
defaultTypeOptions: () => ({ separators: "local" }),
|
||||
},
|
||||
select: {
|
||||
type: "select",
|
||||
cellComponent: CellSelect,
|
||||
icon: IconCircleDot,
|
||||
labelKey: "Select",
|
||||
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "choices",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
status: {
|
||||
type: "status",
|
||||
cellComponent: CellStatus,
|
||||
icon: IconProgressCheck,
|
||||
labelKey: "Status",
|
||||
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "choices",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
defaultTypeOptions: () => {
|
||||
const choices = defaultStatusChoices();
|
||||
return {
|
||||
choices,
|
||||
choiceOrder: choices.map((c) => c.id),
|
||||
defaultValue: choices[0].id,
|
||||
};
|
||||
},
|
||||
},
|
||||
multiSelect: {
|
||||
type: "multiSelect",
|
||||
cellComponent: CellMultiSelect,
|
||||
icon: IconTags,
|
||||
labelKey: "Multi-select",
|
||||
filterOperators: ["any", "none", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "choices",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
date: {
|
||||
type: "date",
|
||||
cellComponent: CellDate,
|
||||
icon: IconCalendar,
|
||||
labelKey: "Date",
|
||||
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "date",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
person: {
|
||||
type: "person",
|
||||
cellComponent: CellPerson,
|
||||
icon: IconUser,
|
||||
labelKey: "Person",
|
||||
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "person",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
file: {
|
||||
type: "file",
|
||||
cellComponent: CellFile,
|
||||
icon: IconPaperclip,
|
||||
labelKey: "File",
|
||||
filterOperators: ["isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: false,
|
||||
},
|
||||
formula: {
|
||||
type: "formula",
|
||||
cellComponent: CellFormula,
|
||||
icon: IconMathFunction,
|
||||
labelKey: "Formula",
|
||||
filterOperators: ["eq", "neq", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: true,
|
||||
hasOptions: false,
|
||||
},
|
||||
page: {
|
||||
type: "page",
|
||||
cellComponent: CellPage,
|
||||
icon: IconFileDescription,
|
||||
labelKey: "Page",
|
||||
filterOperators: ["isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: false,
|
||||
},
|
||||
checkbox: {
|
||||
type: "checkbox",
|
||||
cellComponent: CellCheckbox,
|
||||
icon: IconCheckbox,
|
||||
labelKey: "Checkbox",
|
||||
filterOperators: ["eq", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "boolean",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
url: {
|
||||
type: "url",
|
||||
cellComponent: CellUrl,
|
||||
icon: IconLink,
|
||||
labelKey: "URL",
|
||||
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
email: {
|
||||
type: "email",
|
||||
cellComponent: CellEmail,
|
||||
icon: IconMail,
|
||||
labelKey: "Email",
|
||||
filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "text",
|
||||
isSystem: false,
|
||||
hasOptions: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: "createdAt",
|
||||
cellComponent: CellCreatedAt,
|
||||
icon: IconClockPlus,
|
||||
labelKey: "Created at",
|
||||
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "date",
|
||||
isSystem: true,
|
||||
hasOptions: false,
|
||||
systemAccessor: (row) => row.createdAt,
|
||||
},
|
||||
lastEditedAt: {
|
||||
type: "lastEditedAt",
|
||||
cellComponent: CellLastEditedAt,
|
||||
icon: IconClockEdit,
|
||||
labelKey: "Last edited at",
|
||||
filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "date",
|
||||
isSystem: true,
|
||||
hasOptions: false,
|
||||
systemAccessor: (row) => row.updatedAt,
|
||||
},
|
||||
lastEditedBy: {
|
||||
type: "lastEditedBy",
|
||||
cellComponent: CellLastEditedBy,
|
||||
icon: IconUserEdit,
|
||||
labelKey: "Last edited by",
|
||||
filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
|
||||
filterInput: "person",
|
||||
isSystem: true,
|
||||
hasOptions: false,
|
||||
systemAccessor: (row) => row.lastUpdatedById ?? row.creatorId,
|
||||
},
|
||||
};
|
||||
|
||||
export function getDescriptor(type: string): ClientPropertyTypeDescriptor | undefined {
|
||||
return (PROPERTY_TYPE_REGISTRY as Record<string, ClientPropertyTypeDescriptor>)[type];
|
||||
}
|
||||
|
||||
export const SYSTEM_PROPERTY_TYPES: ReadonlySet<string> = new Set(
|
||||
Object.values(PROPERTY_TYPE_REGISTRY).filter((d) => d.isSystem).map((d) => d.type),
|
||||
);
|
||||
|
||||
export function isSystemPropertyType(type: string): boolean {
|
||||
return SYSTEM_PROPERTY_TYPES.has(type);
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTER_OPERATORS = ["eq", "neq", "isEmpty", "isNotEmpty"];
|
||||
|
||||
export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
|
||||
"text", "longText", "number", "select", "status", "multiSelect", "date",
|
||||
"person", "file", "formula", "page", "checkbox", "url", "email",
|
||||
"createdAt", "lastEditedAt", "lastEditedBy",
|
||||
];
|
||||
|
||||
export const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
|
||||
const d = getDescriptor(type)!;
|
||||
return { type, icon: d.icon, labelKey: d.labelKey };
|
||||
});
|
||||
|
||||
export function systemAccessorFor(type: string) {
|
||||
return getDescriptor(type)?.systemAccessor;
|
||||
}
|
||||
|
||||
export function defaultTypeOptionsFor(type: string): TypeOptions {
|
||||
return getDescriptor(type)?.defaultTypeOptions?.() ?? ({} as TypeOptions);
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
deleteRows,
|
||||
getRowInfo,
|
||||
listRows,
|
||||
reorderRow,
|
||||
IBaseRowsPage,
|
||||
} from "@/ee/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
DeleteRowsInput,
|
||||
ReorderRowInput,
|
||||
FilterNode,
|
||||
ViewSortConfig,
|
||||
RowReferences,
|
||||
NO_VALUE_CHOICE_ID,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getApiErrorMessage } from "@/lib/api-error";
|
||||
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
|
||||
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
|
||||
type RowCacheContext = {
|
||||
snapshots: [readonly unknown[], InfiniteData<IBaseRowsPage> | undefined][];
|
||||
};
|
||||
|
||||
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
|
||||
// Strip it at the query boundary to keep request payloads clean and cache keys stable.
|
||||
export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
|
||||
if (!filter) return undefined;
|
||||
if ('children' in filter && filter.children.length === 0) return undefined;
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Pre-register the requestId as outbound so the socket echo is suppressed by useBaseSocket.
|
||||
function newRequestId(): string {
|
||||
const id = uuid7();
|
||||
markRequestIdOutbound(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function baseRowsQueryKey(
|
||||
pageId: string | undefined,
|
||||
filter: FilterNode | undefined,
|
||||
sorts: ViewSortConfig[] | undefined,
|
||||
) {
|
||||
return [
|
||||
"base-rows",
|
||||
pageId,
|
||||
normalizeFilter(filter),
|
||||
sorts?.length ? sorts : undefined,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export function findRowInInfinite(
|
||||
data: InfiniteData<IBaseRowsPage> | undefined,
|
||||
rowId: string,
|
||||
): IBaseRow | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const page of data.pages) {
|
||||
const row = page.items.find((r) => r.id === rowId);
|
||||
if (row) return row;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useBaseRowsQuery(
|
||||
pageId: string | undefined,
|
||||
filter?: FilterNode,
|
||||
sorts?: ViewSortConfig[],
|
||||
) {
|
||||
const activeFilter = normalizeFilter(filter);
|
||||
const activeSorts = sorts?.length ? sorts : undefined;
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: baseRowsQueryKey(pageId, filter, sorts),
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(pageId!, {
|
||||
cursor: pageParam,
|
||||
limit: 100,
|
||||
filter: activeFilter,
|
||||
sorts: activeSorts,
|
||||
}),
|
||||
enabled: !!pageId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IBaseRowsPage) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const refPages = useMemo(
|
||||
() =>
|
||||
(query.data?.pages ?? [])
|
||||
.map((p) => p.references)
|
||||
.filter(Boolean) as RowReferences[],
|
||||
[query.data],
|
||||
);
|
||||
useHydrateReferences(pageId, refPages);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function flattenRows(
|
||||
data: InfiniteData<IBaseRowsPage> | undefined,
|
||||
): IBaseRow[] {
|
||||
if (!data) return [];
|
||||
return data.pages.flatMap((page) => page.items);
|
||||
}
|
||||
|
||||
export function useCreateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, CreateRowInput>({
|
||||
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
|
||||
onSuccess: (newRow) => {
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", newRow.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === lastPageIndex) {
|
||||
return { ...page, items: [...page.items, newRow] };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
|
||||
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
|
||||
invalidateBaseRows(newRow.pageId);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to create row")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Single row by id — for deep links (?row=) pointing outside the loaded
|
||||
* pages or the active view's filter. No retry: an error means the row is
|
||||
* gone and the caller should close. */
|
||||
export function useBaseRowQuery(
|
||||
pageId: string | undefined,
|
||||
rowId: string | undefined,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery<IBaseRow, Error>({
|
||||
queryKey: ["base-row", pageId, rowId],
|
||||
queryFn: () => getRowInfo(rowId!, pageId!),
|
||||
enabled: !!pageId && !!rowId && (options?.enabled ?? true),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...variables.cells },
|
||||
...(variables.position !== undefined && {
|
||||
position: variables.position,
|
||||
}),
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<IBaseRow>(
|
||||
["base-row", variables.pageId, variables.rowId],
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, cells: { ...old.cells, ...variables.cells } }
|
||||
: old,
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["base-row", variables.pageId, variables.rowId],
|
||||
});
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to update row")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedRow, variables) => {
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", updatedRow.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === updatedRow.id
|
||||
? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData<IBaseRow>(
|
||||
["base-row", updatedRow.pageId, updatedRow.id],
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
|
||||
: old,
|
||||
);
|
||||
|
||||
const base = queryClient.getQueryData<IBase>(["bases", variables.pageId]);
|
||||
const kanbanGroupByIds = new Set(
|
||||
(base?.views ?? [])
|
||||
.filter((v) => v.type === "kanban")
|
||||
.map((v) => v.config?.groupByPropertyId)
|
||||
.filter(Boolean) as string[],
|
||||
);
|
||||
const changedPropertyIds = Object.keys(variables.cells ?? {});
|
||||
if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
|
||||
invalidateBaseRows(variables.pageId);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== variables.rowId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to delete row")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowsMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
const removeSet = new Set(variables.rowIds);
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => !removeSet.has(row.id)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (error, __, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to delete rows")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function useReorderRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.pageId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IBaseRowsPage>
|
||||
>({ queryKey: ["base-rows", variables.pageId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
|
||||
{ queryKey: ["base-rows", variables.pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? { ...row, position: variables.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to reorder row")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type KanbanMoveCardInput = {
|
||||
pageId: string;
|
||||
rowId: string;
|
||||
sourceColumnFilter: FilterNode | undefined;
|
||||
destColumnFilter: FilterNode | undefined;
|
||||
columnChanged: boolean;
|
||||
groupByPropertyId: string;
|
||||
destChoiceValue: string | null;
|
||||
position: string;
|
||||
};
|
||||
|
||||
type KanbanMoveCardContext = {
|
||||
snapshots: [readonly unknown[], unknown][];
|
||||
};
|
||||
|
||||
export function useKanbanMoveCardMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, KanbanMoveCardInput, KanbanMoveCardContext>({
|
||||
mutationFn: ({ pageId, rowId, columnChanged, groupByPropertyId, destChoiceValue, position }) =>
|
||||
updateRow({
|
||||
pageId,
|
||||
rowId,
|
||||
cells: columnChanged ? { [groupByPropertyId]: destChoiceValue } : {},
|
||||
position,
|
||||
requestId: newRequestId(),
|
||||
}),
|
||||
onMutate: async (variables) => {
|
||||
const { pageId, rowId, sourceColumnFilter, destColumnFilter, columnChanged, groupByPropertyId, destChoiceValue, position } = variables;
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: ["base-rows", pageId] });
|
||||
|
||||
const sourceKey = baseRowsQueryKey(pageId, sourceColumnFilter, undefined);
|
||||
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
|
||||
|
||||
const sourceSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(sourceKey);
|
||||
const destSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(destKey);
|
||||
const snapshots: KanbanMoveCardContext["snapshots"] = [
|
||||
[sourceKey, sourceSnapshot],
|
||||
[destKey, destSnapshot],
|
||||
];
|
||||
|
||||
if (columnChanged) {
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(sourceKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((r) => r.id !== rowId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const movingRow = findRowInInfinite(sourceSnapshot, rowId);
|
||||
if (movingRow) {
|
||||
const moved: IBaseRow = {
|
||||
...movingRow,
|
||||
cells: { ...movingRow.cells, [groupByPropertyId]: destChoiceValue },
|
||||
position,
|
||||
};
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) =>
|
||||
index === lastPageIndex
|
||||
? { ...page, items: [...page.items, moved] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((r) =>
|
||||
r.id === rowId ? { ...r, position } : r,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (error, __, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to move card")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type KanbanCreateCardInput = {
|
||||
pageId: string;
|
||||
destColumnFilter: FilterNode | undefined;
|
||||
groupByPropertyId: string;
|
||||
columnKey: string;
|
||||
position?: string;
|
||||
};
|
||||
|
||||
export function useKanbanCreateCardMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, KanbanCreateCardInput>({
|
||||
mutationFn: ({ pageId, groupByPropertyId, columnKey, position }) =>
|
||||
createRow({
|
||||
pageId,
|
||||
cells: columnKey === NO_VALUE_CHOICE_ID ? {} : { [groupByPropertyId]: columnKey },
|
||||
position,
|
||||
requestId: newRequestId(),
|
||||
}),
|
||||
onSuccess: (newRow, variables) => {
|
||||
const { pageId, destColumnFilter } = variables;
|
||||
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined);
|
||||
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) =>
|
||||
index === lastPageIndex
|
||||
? { ...page, items: [...page.items, newRow] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
message: getApiErrorMessage(error, t("Failed to add card")),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateBaseRows(pageId: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { referenceStoreAtomFamily, mergeReferences } from "@/ee/base/atoms/reference-store-atom";
|
||||
import { useResolvedPages, type ResolvedPage } from "@/ee/base/queries/base-page-resolver-query";
|
||||
import type { RowReferences, UserRef } from "@/ee/base/types/base.types";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
|
||||
export function useHydrateReferences(
|
||||
pageId: string | undefined,
|
||||
pages: RowReferences[],
|
||||
): void {
|
||||
const setStore = useSetAtom(referenceStoreAtomFamily(pageId ?? ""));
|
||||
const flat = useMemo(() => pages, [pages]);
|
||||
useEffect(() => {
|
||||
if (!pageId || flat.length === 0) return;
|
||||
setStore((prev) => flat.reduce((acc, r) => mergeReferences(acc, r), prev));
|
||||
}, [pageId, flat, setStore]);
|
||||
}
|
||||
|
||||
export function useReferenceStore(pageId: string): RowReferences {
|
||||
return useAtomValue(referenceStoreAtomFamily(pageId));
|
||||
}
|
||||
|
||||
// Imperatively merges resolved users on pick so a just-selected person resolves without a rows-page refetch.
|
||||
export function useHydrateUsers(pageId: string): (users: UserRef[]) => void {
|
||||
const setStore = useSetAtom(referenceStoreAtomFamily(pageId));
|
||||
return useCallback(
|
||||
(users: UserRef[]) => {
|
||||
if (users.length === 0) return;
|
||||
const map: Record<string, UserRef> = {};
|
||||
for (const u of users) map[u.id] = u;
|
||||
setStore((prev) => mergeReferences(prev, { users: map, pages: {} }));
|
||||
},
|
||||
[setStore],
|
||||
);
|
||||
}
|
||||
|
||||
// Hydrates the signed-in user so lastEditedBy cells resolve without a refetch.
|
||||
export function useHydrateCurrentUser(pageId: string): void {
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const hydrateUsers = useHydrateUsers(pageId);
|
||||
const u = currentUser?.user;
|
||||
useEffect(() => {
|
||||
if (!u) return;
|
||||
hydrateUsers([{ id: u.id, name: u.name ?? null, avatarUrl: u.avatarUrl ?? null }]);
|
||||
}, [u?.id, u?.name, u?.avatarUrl, hydrateUsers]);
|
||||
}
|
||||
|
||||
// Store-first lookup; falls back to the batched /bases/pages/expand on a miss.
|
||||
export function useResolvePage(
|
||||
pageId: string,
|
||||
id: string | null,
|
||||
): ResolvedPage | null | undefined {
|
||||
const store = useReferenceStore(pageId);
|
||||
const stored = id ? store.pages[id] : undefined;
|
||||
const { pages } = useResolvedPages(stored || !id ? [] : [id]);
|
||||
if (!id) return undefined;
|
||||
if (stored) return stored;
|
||||
return pages.get(id);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const KANBAN_COUNT_CAP = 99;
|
||||
|
||||
export function formatKanbanCount(loaded: number, hasMore: boolean): string {
|
||||
return hasMore || loaded > KANBAN_COUNT_CAP
|
||||
? `${KANBAN_COUNT_CAP}+`
|
||||
: String(loaded);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NO_VALUE_CHOICE_ID, FilterGroup, FilterNode } from "@/ee/base/types/base.types";
|
||||
import { normalizeFilter } from "@/ee/base/queries/base-row-query";
|
||||
|
||||
export function buildColumnFilter(
|
||||
viewFilter: FilterGroup | undefined,
|
||||
groupByPropertyId: string,
|
||||
columnKey: string,
|
||||
): FilterNode | undefined {
|
||||
const condition = columnKey === NO_VALUE_CHOICE_ID
|
||||
? { propertyId: groupByPropertyId, op: "isEmpty" as const }
|
||||
: { propertyId: groupByPropertyId, op: "eq" as const, value: columnKey };
|
||||
const children: FilterGroup["children"] = viewFilter?.children?.length
|
||||
? [viewFilter, condition]
|
||||
: [condition];
|
||||
return normalizeFilter({ op: "and", children });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user