Compare commits

..

73 Commits

Author SHA1 Message Date
Philipinho 857b1143b7 sync 2026-06-21 02:35:24 +01:00
Philipinho cbd1045610 Merge branch 'main' into feat/bases 2026-06-21 02:34:49 +01:00
Philipinho e0fdc0c178 chore: update dot ignore files 2026-06-21 02:06:39 +01:00
Philipinho 31c9b8239a Merge branch 'main' into feat/bases 2026-06-20 23:17:57 +01:00
Philipinho e44ce2714f fix: safe editor 2026-06-20 23:12:32 +01:00
Philipinho 3d924550f6 Merge branch 'main' into feat/bases 2026-06-20 18:38:05 +01:00
Philipinho d99d321e4a update dependencies 2026-06-20 18:24:19 +01:00
Philip Okugbe 4f202e4ab5 feat: package updates (#2300)
* package updates
* update tiptap
* fix editor crash
* fix
* update list
* fix(theme): size badges to their content
* fix mantine avatar color regression
2026-06-20 18:11:14 +01:00
Philipinho d7fdcdec80 sync 2026-06-20 14:34:12 +01:00
Abhigyan Sinha 2dc35fa100 fix: resolve draw.io save failure on copied blocks (#2288) (#2297) 2026-06-20 14:31:16 +01:00
Philip Okugbe 4f2fbf22d4 chore: migrate to Mantine 9 and React 19 (#2293) 2026-06-20 14:28:31 +01:00
Philip Okugbe d68e241f45 feat(ee): personal spaces (#2298)
* feat(ee): personal spaces

* pref

* feat: on-demand only

* error notification
2026-06-20 14:27:41 +01:00
Philip Okugbe 510199cf04 feat(ee): docx word export (#2294)
* vendorize  prosemirror-docx - wip
* feat(ee): docx word export
* sync
2026-06-20 14:23:56 +01:00
Philip Okugbe aa5d52ad3e feat(editor): add /time slash command to insert current time (#2290) 2026-06-20 14:22:54 +01:00
Philipinho f9b4a49e25 Merge branch 'main' into feat/bases 2026-06-20 02:35:39 +01:00
Philipinho c73a010478 sync 2026-06-20 02:26:35 +01:00
Philipinho 493221ef1f sync 2026-06-20 02:14:54 +01:00
Philipinho 1c72f469de fix(base): mint kanban choice ids as nanoid 2026-06-19 23:27:09 +01:00
Philipinho d59b8c7144 chore(base): seed choice ids with nanoid 2026-06-19 23:15:54 +01:00
Philipinho c8250c0262 feat(base): generate choice ids as nanoid on the client 2026-06-19 23:12:26 +01:00
Philipinho 9bc3933167 feat(base): generate status template choice ids as nanoid 2026-06-19 23:09:34 +01:00
Philipinho 1f9a2ade4a feat(base): scope property lookups per base and generate property ids in repo 2026-06-19 23:03:10 +01:00
Philipinho c8c9fd4306 feat(base): pass property id as text to cell extractors 2026-06-19 22:57:15 +01:00
Philipinho 53a2440139 feat(base): make property id varchar with per-base composite pk 2026-06-19 22:55:25 +01:00
Philipinho d1cd64d7ea feat(base): enforce strict property/choice id validation 2026-06-19 22:53:09 +01:00
Philipinho f68c59dfcb feat(base): add prefixed nanoid id schemas and generators 2026-06-19 22:36:09 +01:00
Philipinho e717371368 fix: read-only export 2026-06-19 18:23:04 +01:00
Philipinho 61c076f48d sync 2026-06-18 23:11:54 +01:00
Philipinho 8685c32249 fix grid cells on mobile 2026-06-18 23:06:43 +01:00
Philipinho 0b7bc70a7d fix(base): prevent popover inputs from losing focus on mobile in row detail modal 2026-06-17 14:37:41 +01:00
Mayank-2-16 1867aa8bf6 fix: pdf table header alignment issue (#2259)
* fix: pdf table header alignment issue
* cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-06-17 14:32:48 +01:00
Felix 2ff8720832 fix: slash-menu suggestion search localization (#2280) 2026-06-17 14:17:13 +01:00
Philipinho 0e3549faf1 fix(base): place caret at end instead of selecting all when editing cells 2026-06-16 21:40:15 +01:00
Philipinho 2e80fc457c feat(base): insert row below via Shift+Enter on the primary cell 2026-06-16 13:22:54 +01:00
Philipinho c43ac7fc8c feat(editor): add base and kanban inserts to the toolbar 2026-06-16 12:37:08 +01:00
Philipinho ad00efe317 fix(base): enable grid cell editing on touch devices
Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.
2026-06-16 12:14:21 +01:00
Philipinho 4deb13e73e fix(base): keep add-property popover within viewport on mobile
Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.
2026-06-16 12:04:56 +01:00
Philipinho 770c9b9d98 fix: activate New row button via keyboard (Enter/Space)
The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.
2026-06-16 10:49:53 +01:00
Philipinho dc63ed8dee fix(base): select grid cell on mousedown to avoid stale focus ring flash
The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.
2026-06-16 00:30:54 +01:00
Philipinho 8cce2ffca0 fix spacing 2026-06-16 00:02:56 +01:00
Philipinho 7203e12c78 fix focus 2026-06-15 23:33:10 +01:00
Philipinho 0962fd1575 fix refetch 2026-06-15 23:33:00 +01:00
Philipinho f16c027701 fix: skip duplicate row fetch when opening new kanban card 2026-06-15 22:26:59 +01:00
Philipinho 58cead4199 fix: preserve sidebar title/icon on partial page updates 2026-06-15 22:25:50 +01:00
Philipinho e9e6fba886 fix base cache 2026-06-15 22:07:50 +01:00
Philipinho 398bd499cc fix tsconfig 2026-06-15 19:43:01 +01:00
Philipinho b386f8286c license file 2026-06-15 19:12:18 +01:00
Philipinho a45dd8919d fix: change permissions object shape 2026-06-15 17:51:12 +01:00
Philipinho ab1b3723bc fix(base): refer to the feature as 'base' rather than 'database' 2026-06-15 09:46:08 +01:00
Philipinho 748401e1d4 fix(base): render view-tab rename as an inline pill so the tab band height stays put 2026-06-15 08:29:29 +01:00
Philipinho 6a6de54c92 fix(base): show cell focus ring only while the grid body itself is focused 2026-06-15 08:05:14 +01:00
Philipinho 07bfa3ba73 fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop 2026-06-15 05:23:19 +01:00
Philipinho 1b5c081e1e fix(base): tab from long-text editor moves to next cell instead of leaving the table 2026-06-15 04:54:23 +01:00
Philipinho 8fc538386d fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order 2026-06-15 04:08:52 +01:00
Philipinho cc951ae883 feat(base): keyboard-navigate the row-number column for selection 2026-06-15 03:59:03 +01:00
Philipinho 84faf187de fix(base): show cell focus ring only while the grid is focused 2026-06-15 03:41:16 +01:00
Philipinho 7af1695c85 fix(base): remove focus outline on grid container 2026-06-15 03:26:32 +01:00
Philipinho 308079af8b feat(base): make column headers keyboard-focusable as tab stops 2026-06-15 03:13:27 +01:00
Philipinho 16708cd7cc feat(base): seed editor value on type-to-edit for free-text cells 2026-06-15 02:39:04 +01:00
Philipinho 514c68e049 feat(base): row ARIA index and selected state 2026-06-15 02:34:00 +01:00
Philipinho dec87947cf feat(base): cell focus ring, click-to-focus, and gridcell ARIA 2026-06-15 02:31:32 +01:00
Philipinho bb7fddfc54 update offerings 2026-06-15 02:27:31 +01:00
Philipinho f5831a256a feat(base): keyboard navigation controller and grid wiring 2026-06-15 02:13:59 +01:00
Philipinho b8f56fee03 feat(base): add pure next-cell navigation helper 2026-06-15 02:08:33 +01:00
Philipinho b88f56cb90 feat(base): add cell focus-ring style 2026-06-15 02:07:08 +01:00
Philipinho 3bfe0c0b7d feat(base): add focused-cell atom and cell coordinate types 2026-06-15 02:02:38 +01:00
Philipinho 6187150224 fix number precision 2026-06-15 01:14:17 +01:00
Philipinho 4b5f42cc9b feat: translation 2026-06-14 21:45:31 +01:00
Philipinho f1af3e78a1 feat: base nodeview menu 2026-06-14 15:32:36 +01:00
Philipinho 7d98c7c069 fix: base trash list handling 2026-06-14 15:31:50 +01:00
Philipinho d4bcc43ec9 - default status
- type fix
- error helper
2026-06-14 11:28:39 +01:00
Philipinho 4e5bff6d55 feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
2026-06-14 01:29:06 +01:00
Philip Okugbe 6191acfa14 fix: a11y (#2275) 2026-06-09 22:51:55 +01:00
213 changed files with 6118 additions and 11228 deletions
File diff suppressed because it is too large Load Diff
@@ -1,309 +0,0 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -1,479 +0,0 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 510MB per-origin cap, but practically negligible. No cleanup logic needed.
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
1. "Save for everyone" (current behavior)
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
## Future extension
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
+5 -5
View File
@@ -1,6 +1,6 @@
node_modules
.git
dist
**/node_modules
**/.git
**/dist
/data
.env*
.nx
**/.env*
**/.nx
+1 -1
View File
@@ -3,7 +3,7 @@
.env.prod
data
# compiled output
/dist
dist
/node_modules
# Logs
+17 -16
View File
@@ -21,20 +21,20 @@
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
"@mantine/dates": "8.3.18",
"@mantine/form": "8.3.18",
"@mantine/hooks": "8.3.18",
"@mantine/modals": "8.3.18",
"@mantine/notifications": "8.3.18",
"@mantine/spotlight": "8.3.18",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
"@mantine/hooks": "9.3.2",
"@mantine/modals": "9.3.2",
"@mantine/notifications": "9.3.2",
"@mantine/spotlight": "9.3.2",
"@slidoapp/emoji-mart": "5.8.7",
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.2",
"@tanstack/react-virtual": "3.14.3",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
@@ -43,7 +43,7 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "2.18.1",
"jotai": "2.20.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
"jwt-decode": "4.0.0",
@@ -52,15 +52,16 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mitt": "3.0.1",
"posthog-js": "1.372.2",
"react": "18.3.1",
"nanoid": "3.3.8",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-dom": "19.2.7",
"react-drawio": "1.0.7",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.13.1",
"react-router-dom": "7.18.0",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -75,8 +76,8 @@
"@types/js-cookie": "3.0.6",
"@types/katex": "0.16.8",
"@types/node": "22.19.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
@@ -91,7 +92,7 @@
"prettier": "3.8.1",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite": "8.0.16",
"vitest": "4.1.6"
}
}
@@ -1084,23 +1084,5 @@
"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",
"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."
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
}
@@ -41,6 +41,8 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
@@ -76,6 +78,24 @@
"Failed to import pages": "Failed to import pages",
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
"Failed to update data": "Failed to update data",
"Failed to create base": "Failed to create base",
"Failed to update base": "Failed to update base",
"Failed to delete base": "Failed to delete base",
"Failed to create property": "Failed to create property",
"Failed to update property": "Failed to update property",
"Failed to delete property": "Failed to delete property",
"Failed to reorder property": "Failed to reorder property",
"Failed to create view": "Failed to create view",
"Failed to update view": "Failed to update view",
"Failed to delete view": "Failed to delete view",
"Failed to create row": "Failed to create row",
"Failed to update row": "Failed to update row",
"Failed to delete row": "Failed to delete row",
"Failed to delete rows": "Failed to delete rows",
"Failed to reorder row": "Failed to reorder row",
"Failed to move card": "Failed to move card",
"Failed to add card": "Failed to add card",
"Failed to export CSV": "Failed to export CSV",
"Favorite spaces": "Favorite spaces",
"Favorite spaces appear here": "Favorite spaces appear here",
"Favorites": "Favorites",
@@ -398,6 +418,8 @@
"Insert mermaid diagram": "Insert mermaid diagram",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
@@ -597,6 +619,8 @@
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview",
"Base preview unavailable": "Base preview unavailable",
"Restore this base to view its contents.": "Restore this base to view its contents.",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
@@ -978,7 +1002,7 @@
"Search pages and spaces...": "Search pages and spaces...",
"No results found": "No results found",
"You don't have permission to create pages here": "You don't have permission to create pages here",
"Chat menu": "Chat menu",
"Chat menu for {{title}}": "Chat menu for {{title}}",
"API key menu": "API key menu",
"Jump to comment selection": "Jump to comment selection",
"Slash commands": "Slash commands",
@@ -1064,7 +1088,7 @@
"Filter": "Filter",
"Page title": "Page title",
"Page content": "Page content",
"Member actions": "Member actions",
"Member actions for {{name}}": "Member actions for {{name}}",
"Toggle password visibility": "Toggle password visibility",
"Send comment": "Send comment",
"Token actions": "Token actions",
@@ -1086,6 +1110,12 @@
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Allow personal spaces": "Allow personal spaces",
"Members can create their own personal space.": "Members can create their own personal space.",
"Toggle allow personal spaces": "Toggle allow personal spaces",
"Create personal space": "Create personal space",
"Personal space": "Personal space",
"{{name}}'s space": "{{name}}'s space",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
@@ -1084,23 +1084,5 @@
"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}}",
"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."
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
}
@@ -1084,23 +1084,5 @@
"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}}",
"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."
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
}
@@ -1084,23 +1084,5 @@
"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}}",
"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."
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
}
@@ -1084,23 +1084,5 @@
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Create subpage of {{name}}": "{{name}} のサブページを作成"
}
@@ -1084,23 +1084,5 @@
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
}
@@ -1084,23 +1084,5 @@
"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",
"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."
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
}
@@ -1084,23 +1084,5 @@
"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}}",
"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."
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
}
@@ -1084,23 +1084,5 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
}
@@ -1084,23 +1084,5 @@
"Added {{name}} to favorites": "{{name}} додано до обраного",
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
}
@@ -1084,23 +1084,5 @@
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
}
@@ -6,13 +6,21 @@ import {
Select,
Switch,
Divider,
Tooltip,
Badge,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import {
exportPage,
exportPageToDocx,
} from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
import { Feature } from "@/ee/features";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
interface ExportModalProps {
id: string;
@@ -32,17 +40,25 @@ export default function ExportModal({
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const [isExporting, setIsExporting] = useState<boolean>(false);
const { t } = useTranslation();
const upgradeLabel = useUpgradeLabel();
const isDocx = format === ExportFormat.Docx;
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
const blockedByLicense = isDocx && !docxEntitled;
const handleExport = async () => {
setIsExporting(true);
try {
if (type === "page") {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
if (format === ExportFormat.Docx) {
await exportPageToDocx({ pageId: id });
} else {
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
@@ -88,10 +104,15 @@ export default function ExportModal({
<div>
<Text size="md">{t("Format")}</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
<ExportFormatSelection
format={format}
onChange={handleChange}
includeDocx={type === "page"}
docxEntitled={docxEntitled}
/>
</Group>
{type === "page" && (
{type === "page" && !isDocx && (
<>
<Divider my="sm" />
@@ -143,7 +164,16 @@ export default function ExportModal({
<Button onClick={onClose} variant="default">
{t("Cancel")}
</Button>
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
<Button
onClick={handleExport}
loading={isExporting}
disabled={blockedByLicense}
data-disabled={blockedByLicense || undefined}
>
{t("Export")}
</Button>
</Tooltip>
</Group>
</Modal.Body>
</Modal.Content>
@@ -154,23 +184,49 @@ export default function ExportModal({
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
includeDocx?: boolean;
docxEntitled?: boolean;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
function ExportFormatSelection({
format,
onChange,
includeDocx,
docxEntitled,
}: ExportFormatSelection) {
const { t } = useTranslation();
const data = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
...(includeDocx
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
: []),
];
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
data={data}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
comboboxProps={{ width: 200 }}
allowDeselect={false}
withCheckIcon={false}
aria-label={t("Select export format")}
renderOption={({ option }) =>
option.value === "docx" && !docxEntitled ? (
<div>
<Text size="sm" c="dimmed">
{option.label}
</Text>
<Badge size="xs" mt={4}>
{t("Enterprise")}
</Badge>
</div>
) : (
<Text size="sm">{option.label}</Text>
)
}
/>
);
}
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
IconUser,
IconUserCircle,
IconUsers,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { getSpaceUrl } from "@/lib/config.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
const [
createOpened,
{ open: openCreate, close: closeCreate },
] = useDisclosure(false);
if (!user || !workspace) {
return <></>;
}
return (
<>
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
<Menu.Target>
<UnstyledButton>
@@ -115,6 +131,26 @@ export default function TopMenu() {
{t("My preferences")}
</Menu.Item>
{personalSpace ? (
<Menu.Item
component={Link}
to={getSpaceUrl(personalSpace.slug)}
leftSection={<IconUser size={16} />}
>
{t("Personal space")}
</Menu.Item>
) : (
hasPersonalSpaces &&
settingEnabled && (
<Menu.Item
onClick={openCreate}
leftSection={<IconUser size={16} />}
>
{t("Create personal space")}
</Menu.Item>
)
)}
<Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
@@ -160,5 +196,8 @@ export default function TopMenu() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
</>
);
}
+21 -10
View File
@@ -16,13 +16,10 @@ interface CustomAvatarProps {
mt?: string | number;
}
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
// - filled: white text on the shade as bg
// - light: shade as text on the color's light-bg (10% color.6 over white)
// Avoids lime/yellow/green/orange — even their dark shades have weak
// contrast. grape and indigo were bumped from .7 to darker shades because
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
// indigo.7 was 4.98/4.39 (light fails by a hair).
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
// Avoids lime/yellow/green/orange, too light even at dark shades.
// For non-filled variants, initials text is forced to the .9 shade at render time:
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
const SAFE_INITIALS_COLORS: MantineColor[] = [
"blue.8",
"cyan.9",
@@ -54,12 +51,24 @@ function sanitizeInitialsSource(name: string) {
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
const resolvedColor =
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
const isInitials = !color || color === "initials";
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
const hue = pickedColor.split(".")[0];
const initialsSource = sanitizeInitialsSource(name ?? "");
const resolvedColor = variant === "filled" ? pickedColor : hue;
const placeholderStyles =
isInitials && variant !== "filled"
? {
placeholder: {
color: `var(--mantine-color-${hue}-9)`,
},
}
: undefined;
return (
<Avatar
ref={ref}
@@ -67,6 +76,8 @@ export const CustomAvatar = React.forwardRef<
name={initialsSource}
alt={name}
color={resolvedColor}
variant={variant}
styles={placeholderStyles}
{...props}
/>
);
@@ -0,0 +1,12 @@
import { UnstyledButton } from "@mantine/core";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
export const RadioMenuItem = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button">
>((props, ref) => (
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
));
RadioMenuItem.displayName = "RadioMenuItem";
+39 -1
View File
@@ -1 +1,39 @@
Files in this directory are subject to the Docmost Enterprise Edition license.
Files in this directory are subject to the Docmost Enterprise Edition license.
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -66,6 +66,8 @@ export default function AiChatSidebarItem({
[chat.updatedAt, i18n.language],
);
const chatTitle = chat.title || t("Untitled chat");
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
@@ -120,9 +122,7 @@ export default function AiChatSidebarItem({
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemTitle}>{chatTitle}</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
>
<IconDots size={14} />
</ActionIcon>
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useCallback, useId, useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
@@ -107,6 +107,7 @@ export default function ChatInput({
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const plusMenuId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
@@ -176,7 +177,7 @@ export default function ChatInput({
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
if (!editor || editor.isDestroyed || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
@@ -263,7 +264,7 @@ export default function ChatInput({
});
useEffect(() => {
if (editor && autofocus) {
if (editor && !editor.isDestroyed && autofocus) {
editor.commands.focus();
}
}, [editor]);
@@ -342,6 +343,7 @@ export default function ChatInput({
position="top-start"
width={220}
shadow="md"
withRoles={false}
trapFocus
returnFocus
>
@@ -351,13 +353,17 @@ export default function ChatInput({
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
aria-haspopup="menu"
aria-expanded={plusMenuOpen}
aria-controls={plusMenuOpen ? plusMenuId : undefined}
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
@@ -377,6 +383,7 @@ export default function ChatInput({
</button>
<button
type="button"
role="menuitem"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
@@ -385,7 +392,7 @@ export default function ChatInput({
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
{t("Mention a page")}
</button>
</Popover.Dropdown>
</Popover>
@@ -76,7 +76,6 @@
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
}
/* Empty state - Notion AI style centered layout */
.emptyState {
flex: 1;
display: flex;
@@ -1,7 +1,14 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
@@ -14,7 +21,7 @@ import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
@@ -49,7 +56,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
if (!isEditorReady(editor) || !showAiMenu) return;
const { view } = editor;
const { from, to } = editor.state.selection;
@@ -95,7 +102,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
if (!isEditorReady(editor) || isLoading) return;
let command: CommandItem | null = item || null;
@@ -158,6 +165,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
@@ -183,6 +191,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
if (!isEditorReady(editor)) return setShowAiMenu(false);
editor
.chain()
.focus()
@@ -246,7 +255,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
);
useEffect(() => {
if (!editor) return;
if (!isEditorReady(editor)) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Collapse expanded={isExpanded}>
<Box
px="md"
py="sm"
+13 -1
View File
@@ -1,6 +1,6 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell } from "@/ee/base/types/base.types";
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
// the same page don't share UI state.
@@ -41,3 +41,15 @@ export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
export const focusedCellAtomFamily = atomFamily((_pageId: string) =>
atom<FocusedCell>(null),
);
export type PendingTypeInsert = {
rowId: string;
propertyId: string;
char: string;
} | null;
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
@@ -18,7 +18,7 @@ type BaseTableProps = {
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
@@ -20,7 +20,7 @@ import {
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { getApiErrorMessage } from "@/lib/api-error";
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
@@ -61,7 +61,6 @@ export function BaseToolbar({
getViewShareUrl,
}: BaseToolbarProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false);
@@ -78,7 +77,7 @@ export function BaseToolbar({
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
message: getApiErrorMessage(err, t("Failed to export CSV")),
});
} finally {
setExporting(false);
@@ -138,19 +137,17 @@ export function BaseToolbar({
/>
<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>
)}
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
<ViewFilterConfigPopover
opened={filterOpened}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
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";
@@ -12,6 +12,7 @@ import {
FilterGroup,
ViewSortConfig,
EditingCell,
FocusedCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
@@ -25,6 +26,7 @@ import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
focusedCellAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
@@ -89,6 +91,10 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setFocusedCell] = useAtom(
focusedCellAtomFamily(pageId),
) as unknown as [FocusedCell, (val: FocusedCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
@@ -221,33 +227,42 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
[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 });
}
const handleAddRow = useCallback(
(afterRowId?: string, focusPropertyId?: string) => {
if (!editable) return;
createRowMutation.mutate(
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
{
onSuccess: (newRow) => {
let propertyId = focusPropertyId;
if (!propertyId) {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
propertyId = (
firstEditable?.columnDef.meta?.property as
| IBaseProperty
| undefined
)?.id;
}
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
setFocusedCell({ rowId: newRow.id, propertyId });
}
},
},
},
);
}, [editable, pageId, createRowMutation, table, setEditingCell]);
);
},
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
);
const handleViewChange = useCallback(
(viewId: string) => {
@@ -364,14 +379,14 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<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 database" affordance, not a "filter matched nothing" state.
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
const banner = (
@@ -15,9 +15,18 @@ type CellEmailProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) {
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -125,6 +125,7 @@ export function CellFile({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -13,6 +13,7 @@ type CellLongTextProps = {
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
@@ -27,6 +28,7 @@ export function CellLongText({
onCommit,
onValueChange,
onCancel,
onTabNavigate,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
@@ -127,7 +129,11 @@ export function CellLongText({
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
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") {
@@ -16,7 +16,7 @@ import {
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
@@ -109,7 +109,7 @@ export function CellMultiSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -3,6 +3,7 @@ import {
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";
@@ -16,49 +17,98 @@ type CellNumberProps = {
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 ?? 0;
const precision = options?.precision;
const format = options?.format ?? "plain";
const style = options?.separators ?? "none";
const v = precision == null ? snapNumber(val) : val;
switch (format) {
case "separators":
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
}).format(val);
case "currency":
return formatCurrency(val, options?.currencyCode, options?.precision);
return formatCurrency(v, options?.currencyCode, precision);
case "percent":
return `${val.toFixed(precision)}%`;
return `${formatPlain(v, precision, style)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
return formatPlain(v, precision, style);
}
}
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
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 });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse: parseNumberDraft,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -74,6 +124,17 @@ export function CellNumber({
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}
/>
@@ -237,6 +237,7 @@ function PagePicker({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -142,6 +142,7 @@ export function CellPerson({
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
@@ -9,7 +9,7 @@ import {
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
@@ -96,7 +96,7 @@ export function CellSelect({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -16,9 +16,18 @@ type CellTextProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, isEditing, onCommit, onCancel }: CellTextProps) {
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -16,9 +16,18 @@ type CellUrlProps = {
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, isEditing, onCommit, onCancel }: CellUrlProps) {
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
useEditableTextCell({
value,
isEditing,
onCommit,
onCancel,
toDraft,
parse,
rowId,
propertyId: property.id,
});
if (isEditing) {
return (
@@ -9,7 +9,7 @@ import {
} 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 { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
@@ -124,7 +124,7 @@ export function ChoicePicker({
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
id: generateBaseChoiceId(),
name: trimmedSearch,
color: addOptionColor,
};
@@ -16,6 +16,12 @@ export const AddRowButton = memo(function AddRowButton({
<div
className={classes.addRowButton}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role="button"
tabIndex={0}
>
@@ -1,12 +1,15 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { flushSync } from "react-dom";
import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
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";
@@ -16,6 +19,7 @@ import {
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";
@@ -24,6 +28,7 @@ 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;
};
@@ -31,6 +36,7 @@ type GridCellProps = {
export const GridCell = memo(function GridCell({
cell,
rowIndex,
colIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
@@ -44,6 +50,18 @@ export const GridCell = memo(function GridCell({
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;
@@ -55,14 +73,14 @@ export const GridCell = memo(function GridCell({
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleDoubleClick = useCallback(() => {
const handleEdit = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable.
if (property.type === "file") {
setEditingCell({ rowId, propertyId: property.id });
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}
return;
}
@@ -71,9 +89,32 @@ export const GridCell = memo(function GridCell({
return;
}
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || e.button !== 0) return;
setFocusedCell({ rowId, propertyId: property.id });
},
[property, rowId, setFocusedCell],
);
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],
@@ -101,6 +142,31 @@ export const GridCell = memo(function GridCell({
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
@@ -122,13 +188,19 @@ export const GridCell = memo(function GridCell({
const cellInner = (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
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
}
onDoubleClick={handleDoubleClick}
onClick={handleClick}
onMouseDown={handleMouseDown}
onDoubleClick={handleEdit}
>
<CellComponent
value={value}
@@ -139,12 +211,15 @@ export const GridCell = memo(function GridCell({
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
onTabNavigate={handleTabNavigate}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
tabIndex={-1}
data-base-row-expand=""
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
@@ -209,6 +284,7 @@ gridCellPropsEqual);
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.colIndex !== next.colIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
@@ -6,9 +6,24 @@ import {
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms";
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";
@@ -22,6 +37,7 @@ 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";
@@ -51,7 +67,7 @@ type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
onAddRow?: (afterRowId?: string, focusPropertyId?: string) => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
@@ -105,14 +121,23 @@ export function GridContainer({
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 } = useRowSelection(pageId);
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
@@ -141,12 +166,6 @@ export function GridContainer({
useColumnResize(table, onResizeEnd ?? (() => {}));
useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef: bodyRef,
});
// When the scroll container is the window (inline embed mode), the default
// Element-mode observers read scrollTop/scrollLeft, which Window does not
@@ -226,6 +245,177 @@ export function GridContainer({
const virtualItems = virtualizer.getVirtualItems();
const pinnedLeftWidth = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((c) => c.getIsPinned() === "left")
.reduce((sum, c) => sum + c.getSize(), 0),
[table],
);
const scrollCellIntoView = useCallback(
(coord: CellCoord, rowIndex: number) => {
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
requestAnimationFrame(() => {
const scroller = bodyRef.current;
const el = document.getElementById(
`base-cell-${coord.rowId}-${coord.propertyId}`,
);
if (!scroller || !el) return;
const cellRect = el.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const pinned = pinnedLeftWidth();
if (cellRect.left < scRect.left + pinned) {
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
} else if (cellRect.right > scRect.right) {
scroller.scrollLeft += cellRect.right - scRect.right;
}
});
},
[virtualizer, pinnedLeftWidth],
);
useEffect(() => {
if (!editingCell) return;
const idx = rowIdsRef.current.indexOf(editingCell.rowId);
if (idx >= 0) scrollCellIntoView(editingCell, idx);
}, [editingCell, scrollCellIntoView]);
const openEditor = useCallback(
(coord: CellCoord) => {
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop) return;
if (prop.type === "checkbox") {
if (!editable) return;
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
onCellUpdate(coord.rowId, coord.propertyId, !current);
return;
}
if (!editable) {
if (prop.type === "file") setEditingCell(coord);
return;
}
if (prop.type === "formula") {
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
return;
}
if (isSystemPropertyType(prop.type)) return;
setEditingCell(coord);
},
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
);
const clearCell = useCallback(
(coord: CellCoord) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type)) return;
onCellUpdate(coord.rowId, coord.propertyId, null);
},
[editable, properties, onCellUpdate],
);
const beginTypeToEdit = useCallback(
(coord: CellCoord, char: string) => {
if (!editable) return;
const prop = properties.find((p) => p.id === coord.propertyId);
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
if (["text", "number", "url", "email"].includes(prop.type)) {
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
setEditingCell(coord);
} else {
openEditor(coord);
}
},
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
);
const toggleRowSelection = useCallback(
(rowId: string) => {
toggleRow(rowId, {
shiftKey: false,
rowIndex: rowIdsRef.current.indexOf(rowId),
orderedRowIds: rowIdsRef.current,
});
},
[toggleRow],
);
const expandRow = useCallback(
(rowId: string) => {
onExpandRow?.(rowId);
},
[onExpandRow],
);
const prevEditingRef = useRef(editingCell);
useEffect(() => {
const prev = prevEditingRef.current;
prevEditingRef.current = editingCell;
if (prev && !editingCell) {
if (!focusedCellRef.current) setFocusedCell(prev);
const grid = bodyRef.current;
const active = document.activeElement;
if (grid && active && !grid.contains(active)) {
grid.focus({ preventScroll: true });
}
}
}, [editingCell, setFocusedCell]);
useEffect(() => {
const fc = focusedCellRef.current;
if (!fc) return;
const rowOk = rowIds.includes(fc.rowId);
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
if (!rowOk || !colOk) setFocusedCell(null);
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
const handleGridFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (editingCellRef.current || focusedCellRef.current) return;
const firstRow = rowIdsRef.current[0];
const firstCol = table
.getVisibleLeafColumns()
.find((c) => c.id !== "__row_number")?.id;
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
},
[table, setFocusedCell],
);
const handleAddRowBelow = useCallback(
(afterRowId: string, focusPropertyId: string) => {
onAddRow?.(afterRowId, focusPropertyId);
},
[onAddRow],
);
useGridKeyboardNav({
table,
properties,
containerRef: bodyRef,
focusedCell,
setFocusedCell,
editingCell,
setEditingCell,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
addRow: handleAddRowBelow,
});
const activeCell = editingCell ?? focusedCell;
const activeDescendantId = activeCell
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
: undefined;
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
@@ -244,29 +434,6 @@ export function GridContainer({
}
}, [rows.length]);
useEffect(() => {
const el = bodyRef.current;
if (!el || !pageId) return;
const handler = (e: KeyboardEvent) => {
if (editingCell) return;
const active = document.activeElement as HTMLElement | null;
if (!active || !el.contains(active)) return;
const tag = active.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
return;
}
if (e.key === "Escape" && selectionCount > 0) {
clearSelection();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
e.preventDefault();
void deleteSelected();
}
};
el.addEventListener("keydown", handler);
return () => el.removeEventListener("keydown", handler);
}, [editingCell, selectionCount, clearSelection, deleteSelected, pageId]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
@@ -314,7 +481,7 @@ export function GridContainer({
);
return (
<div role="grid" style={GRID_ROOT_STYLE}>
<div style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
@@ -341,6 +508,13 @@ export function GridContainer({
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,
@@ -57,9 +57,12 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection(pageId);
const { selectionCount, toggleAll } = useRowSelection(pageId);
const hasSelection = selectionCount > 0;
const editable = useBaseEditable();
const isHeaderInteractive = editable && !!property && !isRowNumber;
const isRowNumberHeaderInteractive =
isRowNumber && editable && loadedRowIds.length > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
@@ -208,6 +211,10 @@ export const GridHeaderCell = memo(function GridHeaderCell({
return (
<div
ref={cellRef}
role="columnheader"
tabIndex={isHeaderInteractive || isRowNumberHeaderInteractive ? 0 : undefined}
aria-haspopup={isHeaderInteractive ? "menu" : undefined}
aria-label={isRowNumberHeaderInteractive ? t("Select all loaded rows") : undefined}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
@@ -220,6 +227,16 @@ export const GridHeaderCell = memo(function GridHeaderCell({
resizeIntentRef.current = false;
}}
onClick={handleHeaderClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isRowNumber) {
if (isRowNumberHeaderInteractive) toggleAll(loadedRowIds);
} else {
handleHeaderClick();
}
}
}}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
@@ -269,6 +286,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
shadow="md"
width={260}
trapFocus
returnFocus
withinPortal
closeOnClickOutside
closeOnEscape
@@ -160,12 +160,15 @@ export const GridRow = memo(function GridRow({
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) => (
{row.getVisibleCells().map((cell, colIndex) => (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
colIndex={colIndex}
onCellUpdate={onCellUpdate}
pageId={pageId}
/>
@@ -1,7 +1,11 @@
import { memo, useCallback } from "react";
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";
@@ -26,6 +30,38 @@ export const RowNumberCell = memo(function RowNumberCell({
const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder();
const setFocusedCell = useSetAtom(
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
);
const isFocused = useAtomValue(
useMemo(
() =>
selectAtom(
focusedCellAtomFamily(pageId),
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
),
[pageId, rowId],
),
);
const handleCellMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
setFocusedCell({ rowId, propertyId: "__row_number" });
},
[rowId, setFocusedCell],
);
const handleCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setFocusedCell({ rowId, propertyId: "__row_number" });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[rowId, setFocusedCell],
);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
@@ -40,12 +76,16 @@ export const RowNumberCell = memo(function RowNumberCell({
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
id={`base-cell-${rowId}-__row_number`}
role="gridcell"
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
onClick={handleCellClick}
onMouseDown={handleCellMouseDown}
>
<div className={classes.rowNumberCellInner}>
{editable && (
@@ -60,6 +100,7 @@ export const RowNumberCell = memo(function RowNumberCell({
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
tabIndex={-1}
/>
</span>
)}
@@ -42,6 +42,7 @@ export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
tabIndex={-1}
/>
</Tooltip>
</span>
@@ -4,7 +4,7 @@ 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/components/property/property-type-picker";
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 {
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { Stack, Text, Select, Button } from "@mantine/core";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
@@ -36,9 +36,9 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
);
const handleCreateStatus = useCallback(() => {
const todoId = uuid7();
const inProgressId = uuid7();
const completeId = uuid7();
const todoId = generateBaseChoiceId();
const inProgressId = generateBaseChoiceId();
const completeId = generateBaseChoiceId();
createProperty.mutate(
{
pageId,
@@ -67,14 +67,14 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
if (!editable) {
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
</Stack>
);
}
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
{groupableProperties.length > 0 ? (
<Select
@@ -35,7 +35,7 @@ import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
import { DefaultValuePicker } from "./default-value-picker";
const CHOICE_COLORS = [
@@ -52,9 +52,9 @@ const STATUS_CATEGORIES = [
// Default choices for a new status property, one per category.
export function defaultStatusChoices(): Choice[] {
return [
{ id: uuid7(), name: "Not started", color: "gray", category: "todo" },
{ id: uuid7(), name: "In progress", color: "blue", category: "inProgress" },
{ id: uuid7(), name: "Done", color: "green", category: "complete" },
{ id: generateBaseChoiceId(), name: "Not started", color: "gray", category: "todo" },
{ id: generateBaseChoiceId(), name: "In progress", color: "blue", category: "inProgress" },
{ id: generateBaseChoiceId(), name: "Done", color: "green", category: "complete" },
];
}
@@ -169,7 +169,7 @@ export function ChoiceEditor({
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = uuid7();
const id = generateBaseChoiceId();
setDraft((prev) => {
const colorIndex = prev.length % CHOICE_COLORS.length;
const newChoice: Choice = {
@@ -18,11 +18,12 @@ import {
TypeOptions,
} from "@/ee/base/types/base.types";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
getDescriptor,
defaultTypeOptionsFor,
propertyTypes,
} from "@/ee/base/property-types/property-type.registry";
import { FormulaEditor } from "../formula/formula-editor";
import classes from "@/ee/base/styles/grid.module.css";
@@ -50,6 +51,10 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<"bottom-start" | "top-start">(
"bottom-start",
);
const createPropertyMutation = useCreatePropertyMutation();
@@ -93,10 +98,20 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
setTypeOptions({});
}, []);
const handleOpen = useCallback(() => {
resetState();
setOpened(true);
}, [resetState]);
const handleOpen = useCallback(
(event?: React.SyntheticEvent) => {
resetState();
const trigger = event?.currentTarget as HTMLElement | undefined;
if (trigger) {
const rect = trigger.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
}
setOpened(true);
},
[resetState],
);
const handleClose = useCallback(() => {
// Don't reset state here: resetting mid-close flashes the type picker.
@@ -215,11 +230,23 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
onChange={(o) => {
if (!o) attemptClose();
}}
position="bottom-start"
position={position}
shadow="md"
closeOnClickOutside
closeOnEscape={false}
withinPortal
hideDetached={false}
middlewares={{
flip: false,
shift: true,
size: {
padding: 8,
apply: ({ availableHeight }) => {
const el = scrollRef.current;
if (el) el.style.maxHeight = `${availableHeight}px`;
},
},
}}
>
<Popover.Target>
{renderTarget ? (
@@ -247,6 +274,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
maxWidth: "calc(100vw - 32px)",
}}
>
<div ref={scrollRef} style={{ overflowY: "auto", overflowX: "hidden" }}>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<ScrollArea.Autosize
@@ -381,6 +409,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
</Group>
</Stack>
)}
</div>
</Popover.Dropdown>
</Popover>
</>
@@ -21,6 +21,8 @@ import {
import {
IBaseProperty,
BasePropertyType,
TypeOptions,
SelectTypeOptions,
} from "@/ee/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
@@ -28,7 +30,7 @@ import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
conversionWarning,
@@ -36,7 +38,11 @@ import {
NON_USER_TARGET_TYPES,
} from "./conversion-warning";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import {
isSystemPropertyType,
propertyTypes,
defaultTypeOptionsFor,
} from "@/ee/base/property-types/property-type.registry";
import cellClasses from "@/ee/base/styles/cells.module.css";
import classes from "@/ee/base/styles/property.module.css";
@@ -58,6 +64,31 @@ type MenuPanel =
| "confirmDelete"
| "confirmDiscard";
const CHOICE_TYPES = new Set<BasePropertyType>([
"select",
"multiSelect",
"status",
]);
function typeOptionsForConversion(
source: IBaseProperty,
target: BasePropertyType,
): TypeOptions {
if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) {
return defaultTypeOptionsFor(target);
}
const opts = source.typeOptions as SelectTypeOptions | undefined;
const choices = opts?.choices ?? [];
const choiceOrder = opts?.choiceOrder?.length
? opts.choiceOrder
: choices.map((c) => c.id);
const carried: SelectTypeOptions = { choices, choiceOrder };
if (target === "status") {
carried.defaultValue = choices[0]?.id ?? null;
}
return carried;
}
export function PropertyMenuContent({
property,
opened,
@@ -185,16 +216,10 @@ export function PropertyMenuContent({
propertyId: property.id,
pageId: property.pageId,
type: pendingTargetType,
typeOptions: {},
typeOptions: typeOptionsForConversion(property, pendingTargetType),
});
onClose();
}, [
pendingTargetType,
property.id,
property.pageId,
updatePropertyMutation,
onClose,
]);
}, [pendingTargetType, property, updatePropertyMutation, onClose]);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
@@ -342,7 +342,6 @@ function NumberOptions({
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "separators", label: t("Number with separators") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
@@ -367,13 +366,40 @@ function NumberOptions({
}
/>
)}
<NumberInput
<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")}
min={0}
max={8}
value={options.precision ?? 0}
onChange={(val) => update({ precision: val })}
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"
@@ -1,19 +1,11 @@
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import { IconCheck, IconSearch } from "@tabler/icons-react";
import { BasePropertyType } from "@/ee/base/types/base.types";
import {
PROPERTY_PICKER_ORDER,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useState, useRef, useEffect } from "react";
import classes from "@/ee/base/styles/cells.module.css";
const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
const d = getDescriptor(type)!;
return { type, icon: d.icon, labelKey: d.labelKey };
});
type PropertyTypePickerProps = {
onSelect: (type: BasePropertyType) => void;
currentType?: BasePropertyType;
@@ -77,5 +69,3 @@ export function PropertyTypePicker({
</>
);
}
export { propertyTypes };
@@ -66,6 +66,7 @@ export function FieldChoice({ property, value, readOnly, onChange }: FieldProps)
trapFocus
closeOnClickOutside
closeOnEscape={false}
hideDetached={false}
>
<Popover.Target>
<FieldShell
@@ -41,6 +41,7 @@ export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
trapFocus
closeOnClickOutside
closeOnEscape
hideDetached={false}
>
<Popover.Target>
<FieldShell
@@ -1,17 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import { formatNumber } from "@/ee/base/components/cells/cell-number";
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) : "";
const parse = (draft: string) => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
};
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
@@ -42,7 +41,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
setDraft(toDraft(value));
return;
}
if (parse(draft) !== numValue) onChange(parse(draft));
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
};
return (
@@ -62,6 +61,17 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
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") {
@@ -75,6 +75,7 @@ export function PropertyRow({
withinPortal
closeOnClickOutside={false}
closeOnEscape={false}
hideDetached={false}
>
<Popover.Target>
<button
@@ -217,6 +217,7 @@ export function RowDetailModal({
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
removeScrollProps={{ noIsolation: true }}
>
{row ? (
<>
@@ -292,6 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,6 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -16,35 +17,28 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const inputRef = useRef<HTMLInputElement>(null);
const didAutofocusRef = useRef(false);
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
setValue(initial);
}, [initial]);
useEffect(() => {
if (didAutofocusRef.current || !canEdit || initial) return;
didAutofocusRef.current = true;
inputRef.current?.focus();
}, [canEdit, initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
return (
<header className={classes.header}>
{canEdit ? (
<input
ref={inputRef}
type="text"
className={classes.titleInput}
{...(!initial ? { "data-autofocus": true } : {})}
placeholder={t("Untitled")}
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
@@ -57,6 +51,10 @@ export function RowDetailTitle({
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -24,6 +24,7 @@ import {
} from "@/ee/base/property-types/property-type.registry";
import { FilterPersonInput } from "./filter-person-input";
import { FilterDateInput } from "./filter-date-input";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import viewClasses from "@/ee/base/styles/views.module.css";
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
@@ -191,6 +192,7 @@ export function ViewFilterConfigPopover({
children,
}: ViewFilterConfigProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const propertyOptions = properties.map((p) => ({
value: p.id,
@@ -2,8 +2,9 @@ import { useMemo, useCallback } from "react";
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
import { useTranslation } from "react-i18next";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import cellClasses from "@/ee/base/styles/cells.module.css";
import viewClasses from "@/ee/base/styles/views.module.css";
@@ -25,6 +26,7 @@ export function ViewPropertyVisibility({
children,
}: ViewPropertyVisibilityProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const columns = useMemo(() => {
return table
@@ -122,6 +124,9 @@ export function ViewPropertyVisibility({
return (
<UnstyledButton
key={col.id}
role="switch"
aria-checked={isVisible}
aria-disabled={!canHide || undefined}
className={cellClasses.menuItem}
onClick={() => {
if (canHide) {
@@ -140,6 +145,8 @@ export function ViewPropertyVisibility({
size="xs"
checked={isVisible}
disabled={!canHide}
tabIndex={-1}
aria-hidden
onChange={() => {}}
// Clicking the track synthesizes a second click on the hidden input which bubbles
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
@@ -21,7 +21,7 @@ type ViewRendererProps = {
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
@@ -15,6 +15,7 @@ import {
ViewSortConfig,
} from "@/ee/base/types/base.types";
import { useTranslation } from "react-i18next";
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
import viewClasses from "@/ee/base/styles/views.module.css";
type ViewSortConfigProps = {
@@ -35,6 +36,7 @@ export function ViewSortConfigPopover({
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
useEscapeClose(opened, onClose);
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
useEffect(() => {
@@ -288,15 +288,35 @@ function ViewTab({
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
<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>
);
}
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useStore, type PrimitiveAtom } from "jotai";
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
export type UseEditableTextCellParams = {
value: unknown;
@@ -9,6 +11,8 @@ export type UseEditableTextCellParams = {
toDraft: (value: unknown) => string;
/** draft string -> the value passed to onCommit */
parse: (draft: string) => unknown;
rowId?: string;
propertyId?: string;
};
export type EditableTextCell = {
@@ -26,6 +30,8 @@ export function useEditableTextCell({
onCancel,
toDraft,
parse,
rowId,
propertyId,
}: UseEditableTextCellParams): EditableTextCell {
const [draft, setDraft] = useState(() => toDraft(value));
const inputRef = useRef<HTMLInputElement>(null);
@@ -33,18 +39,35 @@ export function useEditableTextCell({
const wasEditingRef = useRef(false);
const toDraftRef = useRef(toDraft);
toDraftRef.current = toDraft;
const store = useStore();
useEffect(() => {
useLayoutEffect(() => {
if (isEditing && !wasEditingRef.current) {
committedRef.current = false;
setDraft(toDraftRef.current(value));
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
const pending = store.get(pendingTypeInsertAtom);
const seeded =
pending != null &&
pending.rowId === rowId &&
pending.propertyId === propertyId;
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
if (seeded) {
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
}
setDraft(nextDraft);
const el = inputRef.current;
if (el) {
el.value = nextDraft;
el.focus({ preventScroll: true });
try {
el.setSelectionRange(nextDraft.length, nextDraft.length);
} catch {
// email/number inputs reject setSelectionRange
}
el.scrollLeft = el.scrollWidth;
}
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
}, [isEditing, value, rowId, propertyId, store]);
const commitOnce = useCallback(
(val: unknown) => {
@@ -0,0 +1,12 @@
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 +1,317 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
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;
containerRef: React.RefObject<HTMLDivElement | null>;
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;
addRow: (afterRowId: string, focusPropertyId: 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,
containerRef,
openEditor,
clearCell,
beginTypeToEdit,
scrollCellIntoView,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
addRow,
}: UseGridKeyboardNavOptions) {
const getNavigableColumns = useCallback(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table]);
const getColIds = useCallback(
() =>
table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id),
[table],
);
const getRowIds = useCallback(() => {
return table.getRowModel().rows.map((row) => row.id);
}, [table]);
const getNavColIds = useCallback(
() => table.getVisibleLeafColumns().map((col) => col.id),
[table],
);
const navigate = useCallback(
(rowDelta: number, colDelta: number) => {
if (!editingCell) return;
const getRowIds = useCallback(
() => table.getRowModel().rows.map((row) => row.id),
[table],
);
const columns = getNavigableColumns();
const rowIds = getRowIds();
const propertyType = useCallback(
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
[properties],
);
const currentColIndex = columns.indexOf(editingCell.propertyId);
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
const primaryPropertyId = useMemo(
() => properties.find((p) => p.isPrimary)?.id,
[properties],
);
if (currentColIndex === -1 || currentRowIndex === -1) return;
let nextColIndex = currentColIndex + colDelta;
let nextRowIndex = currentRowIndex + rowDelta;
if (nextColIndex < 0) {
nextColIndex = columns.length - 1;
nextRowIndex -= 1;
} else if (nextColIndex >= columns.length) {
nextColIndex = 0;
nextRowIndex += 1;
}
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
// Blur fires onBlur->commit before React unmounts the input
const goEditing = useCallback(
(next: CellCoord) => {
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell({
rowId: rowIds[nextRowIndex],
propertyId: columns[nextColIndex],
});
setEditingCell(next);
setFocusedCell(next);
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
},
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
[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) return;
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();
if (e.shiftKey && editingCell.propertyId === primaryPropertyId) {
(document.activeElement as HTMLElement | null)?.blur();
setEditingCell(null);
addRow(editingCell.rowId, editingCell.propertyId);
break;
}
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;
}
const target = e.target as HTMLElement;
const isInputActive =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
if (e.target !== containerRef.current) 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":
if (!isInputActive) {
e.preventDefault();
navigate(-1, 0);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowDown":
if (!isInputActive) {
e.preventDefault();
navigate(1, 0);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
if (next) goFocused(next);
}
break;
case "ArrowLeft":
if (!isInputActive) {
e.preventDefault();
navigate(0, -1);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
if (next) goFocused(next);
}
break;
case "ArrowRight":
if (!isInputActive) {
e.preventDefault();
navigate(0, 1);
e.preventDefault();
{
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
if (next) goFocused(next);
}
break;
case "Tab":
e.preventDefault();
navigate(0, e.shiftKey ? -1 : 1);
case "Tab": {
const next = computeNextCell(
getRowIds(),
getNavColIds(),
focusedCell,
0,
e.shiftKey ? -1 : 1,
true,
);
if (next) {
e.preventDefault();
goFocused(next);
}
break;
case "Escape":
}
case "Enter":
case "F2":
e.preventDefault();
setEditingCell(null);
if (
e.key === "Enter" &&
e.shiftKey &&
focusedCell.propertyId === primaryPropertyId
) {
addRow(focusedCell.rowId, focusedCell.propertyId);
} else 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, navigate, setEditingCell],
[
containerRef,
editingCell,
focusedCell,
getRowIds,
getColIds,
getNavColIds,
goEditing,
goFocused,
setEditingCell,
setFocusedCell,
openEditor,
clearCell,
beginTypeToEdit,
propertyType,
selectionCount,
clearSelection,
deleteSelected,
toggleRowSelection,
expandRow,
primaryPropertyId,
addRow,
],
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
const el = containerRef.current;
if (!el) return;
el.addEventListener("keydown", handleKeyDown);
return () => el.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleKeyDown]);
}
@@ -38,7 +38,15 @@ export function useHorizontalScrollSync<
body.scrollLeft += e.deltaY;
};
const onHeaderScroll = () => {
if (rafId !== 0) return;
if (body.scrollLeft !== header.scrollLeft) {
body.scrollLeft = header.scrollLeft;
}
};
body.addEventListener("scroll", onBodyScroll, { passive: true });
header.addEventListener("scroll", onHeaderScroll, { passive: true });
header.addEventListener("wheel", onHeaderWheel, { passive: false });
// Initial sync in case the body is already scrolled when the hook mounts.
@@ -46,6 +54,7 @@ export function useHorizontalScrollSync<
return () => {
body.removeEventListener("scroll", onBodyScroll);
header.removeEventListener("scroll", onHeaderScroll);
header.removeEventListener("wheel", onHeaderWheel);
if (rafId !== 0) cancelAnimationFrame(rafId);
};
+1 -1
View File
@@ -29,7 +29,7 @@ export default function BasePage() {
{base.icon ? `${base.icon} ` : ""}{base.name}
</Title>
)}
<BaseView pageId={pageId} editable={hasBases && (base?.canEdit ?? false)} />
<BaseView pageId={pageId} editable={hasBases && (base?.permissions?.canEdit ?? false)} />
</Container>
);
}
@@ -17,6 +17,7 @@ export type CellComponentProps = {
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
onTabNavigate?: (shiftKey: boolean) => void;
};
export type FilterInputKind =
@@ -74,6 +74,7 @@ export const PROPERTY_TYPE_REGISTRY: Record<
filterInput: "number",
isSystem: false,
hasOptions: true,
defaultTypeOptions: () => ({ separators: "local" }),
},
select: {
type: "select",
@@ -96,7 +97,11 @@ export const PROPERTY_TYPE_REGISTRY: Record<
hasOptions: true,
defaultTypeOptions: () => {
const choices = defaultStatusChoices();
return { choices, choiceOrder: choices.map((c) => c.id) };
return {
choices,
choiceOrder: choices.map((c) => c.id),
defaultValue: choices[0].id,
};
},
},
multiSelect: {
@@ -244,6 +249,11 @@ export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
"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;
}
@@ -18,6 +18,7 @@ import {
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { IPagination } from "@/lib/types";
export function useCreatePropertyMutation() {
@@ -36,9 +37,9 @@ export function useCreatePropertyMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create property"),
message: getApiErrorMessage(error, t("Failed to create property")),
color: "red",
});
},
@@ -69,9 +70,9 @@ export function useUpdatePropertyMutation() {
});
}
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to update property"),
message: getApiErrorMessage(error, t("Failed to update property")),
color: "red",
});
},
@@ -114,9 +115,9 @@ export function useDeletePropertyMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete property"),
message: getApiErrorMessage(error, t("Failed to delete property")),
color: "red",
});
},
@@ -154,7 +155,7 @@ export function useReorderPropertyMutation() {
return { previous };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.pageId],
@@ -162,7 +163,7 @@ export function useReorderPropertyMutation() {
);
}
notifications.show({
message: t("Failed to reorder property"),
message: getApiErrorMessage(error, t("Failed to reorder property")),
color: "red",
});
},
+15 -9
View File
@@ -15,9 +15,11 @@ import {
CreateBaseInput,
UpdateBaseInput,
} from "@/ee/base/types/base.types";
import { IPage } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -44,9 +46,9 @@ export function useCreateBaseMutation() {
queryKey: ["bases", "list", data.spaceId],
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create base"),
message: getApiErrorMessage(error, t("Failed to create base")),
color: "red",
});
},
@@ -61,6 +63,10 @@ export function useConvertPageToBaseMutation() {
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
onSuccess: (base) => {
const markAsBase = (old?: IPage) => (old ? { ...old, isBase: true } : old);
queryClient.setQueryData<IPage>(["pages", base.id], markAsBase);
queryClient.setQueryData<IPage>(["pages", base.slugId], markAsBase);
queryClient.invalidateQueries({ queryKey: ["pages"] });
queryClient.invalidateQueries({
queryKey: ["root-sidebar-pages", base.spaceId],
@@ -74,12 +80,12 @@ export function useConvertPageToBaseMutation() {
spaceId: base.spaceId,
entity: ["pages"],
id: base.id,
payload: { isBase: true },
payload: { isBase: true, slugId: base.slugId },
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create base"),
message: getApiErrorMessage(error, t("Failed to create base")),
color: "red",
});
},
@@ -96,9 +102,9 @@ export function useUpdateBaseMutation() {
return { ...old, ...data };
});
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to update base"),
message: getApiErrorMessage(error, t("Failed to update base")),
color: "red",
});
},
@@ -116,9 +122,9 @@ export function useDeleteBaseMutation() {
});
notifications.show({ message: t("Base deleted") });
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete base"),
message: getApiErrorMessage(error, t("Failed to delete base")),
color: "red",
});
},
@@ -31,6 +31,7 @@ import {
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";
@@ -146,12 +147,15 @@ export function useCreateRowMutation() {
);
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
invalidateBaseRows(newRow.pageId);
queryClient.invalidateQueries({
queryKey: ["base-rows", newRow.pageId],
refetchType: "none",
});
}
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create row"),
message: getApiErrorMessage(error, t("Failed to create row")),
color: "red",
});
},
@@ -221,7 +225,7 @@ export function useUpdateRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
@@ -231,7 +235,7 @@ export function useUpdateRowMutation() {
queryKey: ["base-row", variables.pageId, variables.rowId],
});
notifications.show({
message: t("Failed to update row"),
message: getApiErrorMessage(error, t("Failed to update row")),
color: "red",
});
},
@@ -305,14 +309,14 @@ export function useDeleteRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete row"),
message: getApiErrorMessage(error, t("Failed to delete row")),
color: "red",
});
},
@@ -349,14 +353,14 @@ export function useDeleteRowsMutation() {
return { snapshots };
},
onError: (_, __, context) => {
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete rows"),
message: getApiErrorMessage(error, t("Failed to delete rows")),
color: "red",
});
},
@@ -399,14 +403,14 @@ export function useReorderRowMutation() {
return { snapshots };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to reorder row"),
message: getApiErrorMessage(error, t("Failed to reorder row")),
color: "red",
});
},
@@ -504,14 +508,14 @@ export function useKanbanMoveCardMutation() {
return { snapshots };
},
onError: (_, __, context) => {
onError: (error, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to move card"),
message: getApiErrorMessage(error, t("Failed to move card")),
color: "red",
});
},
@@ -551,10 +555,14 @@ export function useKanbanCreateCardMutation() {
),
};
});
queryClient.setQueryData<IBaseRow>(
["base-row", newRow.pageId, newRow.id],
newRow,
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to add card"),
message: getApiErrorMessage(error, t("Failed to add card")),
color: "red",
});
},
@@ -28,6 +28,7 @@ function applyConfigPatch(
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { getApiErrorMessage } from "@/lib/api-error";
export function useCreateViewMutation() {
const { t } = useTranslation();
@@ -45,9 +46,9 @@ export function useCreateViewMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to create view"),
message: getApiErrorMessage(error, t("Failed to create view")),
color: "red",
});
},
@@ -99,7 +100,7 @@ export function useUpdateViewMutation() {
return { previous };
},
onError: (_, variables, context) => {
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.pageId],
@@ -107,7 +108,7 @@ export function useUpdateViewMutation() {
);
}
notifications.show({
message: t("Failed to update view"),
message: getApiErrorMessage(error, t("Failed to update view")),
color: "red",
});
},
@@ -144,9 +145,9 @@ export function useDeleteViewMutation() {
},
);
},
onError: () => {
onError: (error) => {
notifications.show({
message: t("Failed to delete view"),
message: getApiErrorMessage(error, t("Failed to delete view")),
color: "red",
});
},
+86 -21
View File
@@ -36,6 +36,11 @@
scrollbar-width: thin;
}
.bodyGrid:focus,
.bodyGrid:focus-visible {
outline: none;
}
.bodyGrid::-webkit-scrollbar {
height: 8px;
}
@@ -179,11 +184,13 @@
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
@media (hover: hover) {
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
}
.cell {
@@ -191,6 +198,7 @@
align-items: center;
min-height: 36px;
padding: 0 8px;
touch-action: manipulation;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
background-color: light-dark(
@@ -222,11 +230,13 @@
);
}
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
@media (hover: hover) {
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
}
.cellEditing {
@@ -236,6 +246,26 @@
padding: 0;
}
.bodyGrid:focus .cellFocused {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
@media (hover: none) {
.cellFocused {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
}
.headerCell:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
}
.cellContent {
flex: 1;
min-width: 0;
@@ -453,6 +483,7 @@
cursor: pointer;
opacity: 0;
pointer-events: none;
touch-action: manipulation;
transition: opacity 80ms ease, color 80ms ease;
}
@@ -462,17 +493,28 @@
pointer-events: auto;
}
.rowExpandButton:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
@media (hover: none) {
.rowExpandButton {
opacity: 1;
pointer-events: auto;
}
}
.row:hover .rowNumberIndex {
display: none;
@media (hover: hover) {
.rowExpandButton:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
@media (hover: hover) {
.row:hover .rowNumberIndex {
display: none;
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
}
}
.rowSelected .rowNumberIndex {
@@ -481,13 +523,29 @@
.rowSelected .rowNumberCheckbox {
display: inline-flex;
}
.bodyGrid:focus .cellFocused .rowNumberIndex {
display: none;
}
.bodyGrid:focus .cellFocused .rowNumberCheckbox {
display: inline-flex;
}
@media (hover: none) {
.cellFocused .rowNumberIndex {
display: none;
}
.cellFocused .rowNumberCheckbox {
display: inline-flex;
}
}
.rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
@media (hover: hover) {
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
}
}
.rowNumberHeaderInner {
@@ -517,6 +575,13 @@
display: inline-flex;
}
.headerCell:focus-visible .rowNumberHeaderHash {
display: none;
}
.headerCell:focus-visible .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper {
position: fixed;
left: 50%;
+21 -5
View File
@@ -41,8 +41,17 @@ export type SelectTypeOptions = {
defaultValue?: string | string[] | null;
};
export type NumberSeparatorStyle =
| 'none'
| 'local'
| 'comma_period'
| 'period_comma'
| 'space_comma'
| 'space_period';
export type NumberTypeOptions = {
format?: 'plain' | 'separators' | 'currency' | 'percent' | 'progress';
format?: 'plain' | 'currency' | 'percent' | 'progress';
separators?: NumberSeparatorStyle;
precision?: number;
currencyCode?: string;
currencySymbol?: string;
@@ -226,6 +235,7 @@ export type IBaseView = {
export type IBase = {
id: string;
slugId: string;
name: string;
description?: string;
icon?: string;
@@ -237,15 +247,21 @@ export type IBase = {
views: IBaseView[];
createdAt: string;
updatedAt: string;
/** Effective edit permission for the current user (page-restrictions included). */
canEdit?: boolean;
permissions?: {
canEdit: boolean;
hasRestriction: boolean;
};
baseSchemaVersion: number;
};
export type EditingCell = {
export type CellCoord = {
rowId: string;
propertyId: string;
} | null;
};
export type EditingCell = CellCoord | null;
export type FocusedCell = CellCoord | null;
export type CreateBaseInput = {
name: string;
@@ -0,0 +1,5 @@
import { customAlphabet } from "nanoid";
const baseIdSuffix = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 9);
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
@@ -0,0 +1,33 @@
import { CellCoord } from "@/ee/base/types/base.types";
export function computeNextCell(
rowIds: string[],
colIds: string[],
current: CellCoord,
rowDelta: number,
colDelta: number,
wrap: boolean,
): CellCoord | null {
const colIndex = colIds.indexOf(current.propertyId);
const rowIndex = rowIds.indexOf(current.rowId);
if (colIndex === -1 || rowIndex === -1) return null;
let nextCol = colIndex + colDelta;
let nextRow = rowIndex + rowDelta;
if (wrap) {
if (nextCol < 0) {
nextCol = colIds.length - 1;
nextRow -= 1;
} else if (nextCol >= colIds.length) {
nextCol = 0;
nextRow += 1;
}
} else if (nextCol < 0 || nextCol >= colIds.length) {
return null;
}
if (nextRow < 0 || nextRow >= rowIds.length) return null;
return { rowId: rowIds[nextRow], propertyId: colIds[nextCol] };
}
@@ -103,7 +103,7 @@ export default function BillingPlans() {
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
onChange={(value) => setSelectedTierValue(value)}
data={selectData}
w={250}
size="md"
@@ -3,6 +3,7 @@ import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
interface ResolveCommentProps {
editor: Editor;
@@ -31,7 +32,7 @@ function ResolveComment({
resolved: !isResolved,
});
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
+2
View File
@@ -19,5 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
} as const;
@@ -17,7 +17,10 @@ const enterpriseFeatures = [
"Resolve Comments",
"Confluence Import",
"PDF & DOCX Import",
"Bases",
"Kanban",
"Templates",
"Personal Spaces"
];
export default function OssDetails() {
@@ -193,7 +193,7 @@ export function MfaSetupModal({
</Group>
</UnstyledButton>
<Collapse in={manualEntryOpen}>
<Collapse expanded={manualEntryOpen}>
<Alert
icon={<IconAlertCircle size={20} />}
color="gray"
@@ -58,6 +58,7 @@ export default function PdfRenderPage() {
title={data.title}
content={data.content}
pageId={data.pageId}
printMode
/>
</Container>
);
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
import { getSpaceUrl } from "@/lib/config.ts";
import { notifications } from "@mantine/notifications";
const formSchema = z.object({
name: z.string().trim().min(2).max(100),
});
type FormValues = z.infer<typeof formSchema>;
type Props = {
opened: boolean;
onClose: () => void;
};
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const currentUser = useAtomValue(currentUserAtom);
const createMutation = useCreatePersonalSpaceMutation();
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
},
});
const handleSubmit = async (values: FormValues) => {
try {
const createdSpace = await createMutation.mutateAsync({
name: values.name,
});
onClose();
navigate(getSpaceUrl(createdSpace.slug));
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Create personal space")}
closeButtonProps={{ "aria-label": t("Close") }}
>
<Divider size="xs" mb="md" />
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
withAsterisk
data-autofocus
label={t("Space name")}
variant="filled"
errorProps={{ role: "alert" }}
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</form>
</Modal>
);
}
@@ -0,0 +1,64 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function PersonalSpacesSetting() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow personal spaces")}</Text>
<Text size="sm" c="dimmed">
{t("Members can create their own personal space.")}
</Text>
</div>
<PersonalSpacesToggle />
</Group>
);
}
function PersonalSpacesToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.spaces?.allowPersonal === true,
);
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
allowPersonalSpaces: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasPersonalSpaces}
aria-label={t("Toggle allow personal spaces")}
/>
</Tooltip>
);
}
@@ -0,0 +1,34 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { ISpace } from "@/features/space/types/space.types";
import {
createPersonalSpace,
getPersonalSpace,
} from "@/ee/personal-space/services/personal-space-service";
export function usePersonalSpaceQuery(
enabled: boolean,
): UseQueryResult<ISpace | null, Error> {
return useQuery({
queryKey: ["personal-space"],
queryFn: () => getPersonalSpace(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useCreatePersonalSpaceMutation() {
const queryClient = useQueryClient();
return useMutation<ISpace, Error, { name?: string }>({
mutationFn: (data) => createPersonalSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
queryClient.invalidateQueries({ queryKey: ["spaces"] });
},
});
}
@@ -0,0 +1,14 @@
import api from "@/lib/api-client";
import { ISpace } from "@/features/space/types/space.types";
export async function getPersonalSpace(): Promise<ISpace | null> {
const req = await api.post<ISpace | null>("/personal-space/info", {});
return req.data;
}
export async function createPersonalSpace(data: {
name?: string;
}): Promise<ISpace> {
const req = await api.post<ISpace>("/personal-space/create", data);
return req.data;
}
@@ -105,7 +105,7 @@ export default function TemplateEditor() {
// Load template data into editor
useEffect(() => {
if (existingTemplate && editor) {
if (existingTemplate && editor && !editor.isDestroyed) {
loadedRef.current = false;
setTitle(existingTemplate.title || "");
setIcon(existingTemplate.icon || null);
@@ -383,7 +383,8 @@ export default function TemplateEditor() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editor?.commands.focus("start");
if (editor && !editor.isDestroyed)
editor.commands.focus("start");
}
}}
/>
@@ -15,6 +15,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
@@ -48,11 +49,14 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
if (isEditorReady(editor)) {
editor.chain().focus().unsetCommentDecoration().run();
}
}
};
const getSelectedText = () => {
if (!isEditorReady(editor)) return "";
const { from, to } = editor.state.selection;
return editor.state.doc.textBetween(from, to);
};
@@ -74,24 +78,28 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const createdComment =
await createCommentMutation.mutateAsync(commentData);
editor
.chain()
.setComment(createdComment.id)
.unsetCommentDecoration()
.run();
if (isEditorReady(editor)) {
editor
.chain()
.setComment(createdComment.id)
.unsetCommentDecoration()
.run();
editor.commands.setTextSelection({
from: editor.view.state.selection.from,
to: editor.view.state.selection.from,
});
}
setActiveCommentId(createdComment.id);
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
editor.view.dispatch(
editor.state.tr.scrollIntoView()
);
if (isEditorReady(editor)) {
editor.view.dispatch(editor.state.tr.scrollIntoView());
}
}, 400);
} finally {
@@ -112,22 +112,24 @@ const CommentEditor = forwardRef(
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
if (!editable && commentEditor && defaultContent) {
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => {
setTimeout(() => {
if (autofocus) {
commentEditor?.commands.focus("end");
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
commentEditor.commands.focus("end");
}
}, 10);
}, [commentEditor, autofocus]);
useImperativeHandle(ref, () => ({
clearContent: () => {
commentEditor.commands.clearContent();
if (commentEditor && !commentEditor.isDestroyed) {
commentEditor.commands.clearContent();
}
},
}));
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { isEditorReady } from "@docmost/editor-ext";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
@@ -75,7 +76,9 @@ function CommentListItem({
async function handleDeleteComment() {
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
if (isEditorReady(editor)) {
editor.commands.unsetComment(comment.id);
}
} catch (error) {
console.error("Failed to delete comment:", error);
}
@@ -93,7 +96,7 @@ function CommentListItem({
resolved: !isResolved,
});
if (editor) {
if (isEditorReady(editor)) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
} catch (error) {
@@ -1,13 +1,14 @@
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
import { Box, Text } from "@mantine/core";
import { useEffect, useRef } from "react";
import { ActionIcon, Box, Menu, Text } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BaseView } from "@/ee/base/components/base-view";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { pinOffsetWatcher } from "@docmost/editor-ext";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { IconTable } from "@tabler/icons-react";
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
import { usePageQuery } from "@/features/page/queries/page-query";
import classes from "./base-embed.module.css";
@@ -49,11 +50,13 @@ function applyExtension(wrapper: HTMLDivElement) {
);
}
export function BaseEmbedView({ node, editor }: NodeViewProps) {
export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
const { t } = useTranslation();
const pageId = node.attrs.pageId as string | null;
const pendingKey = node.attrs.pendingKey as string | null;
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasBases = useHasFeature(Feature.BASES);
const [menuOpen, setMenuOpen] = useState(false);
// Suppress the query while the slash command awaits the server-assigned
// pageId; useBaseQuery would otherwise fire with an empty key.
const { data: base, isLoading, isError } = useBaseQuery(
@@ -95,6 +98,8 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
// mounts) is reserved only for the skeleton/loading/table states.
const isCompact = !pendingKey && (!pageId || isError);
const showControls = editor.isEditable && !pendingKey;
let content: React.ReactNode;
if (pendingKey) {
// Slash command inserted the embed and is awaiting the server's
@@ -119,7 +124,7 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
} else if (isError) {
content = (
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
<Text c="dimmed">You don't have access to this database.</Text>
<Text c="dimmed">You don't have access to this base.</Text>
</Box>
);
} else {
@@ -127,13 +132,43 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
<BaseView
pageId={pageId}
embedded
editable={hasBases && editor.isEditable && (base?.canEdit ?? false)}
editable={hasBases && editor.isEditable && (base?.permissions?.canEdit ?? false)}
/>
);
}
return (
<NodeViewWrapper className={classes.handleGutter}>
<NodeViewWrapper
className={classes.handleGutter}
data-menu-open={menuOpen ? "true" : "false"}
>
{showControls && (
<div
className={classes.controls}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
<Menu position="bottom-end" withinPortal onChange={setMenuOpen}>
<Menu.Target>
<ActionIcon
variant="default"
size="sm"
aria-label={t("Base options")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconX size={14} />}
onClick={() => deleteNode()}
>
{t("Remove from page")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
)}
<div data-drag-preview hidden className={classes.dragPreview}>
<IconTable size={16} />
<span>{page?.title?.trim() || "Untitled base"}</span>
@@ -1,8 +1,40 @@
.handleGutter {
position: relative;
margin-left: -1.5rem;
padding-left: 1.5rem;
}
.controls {
position: absolute;
bottom: calc(100% + 4px);
right: 0;
z-index: 20;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.controls::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 8px;
}
.handleGutter:hover .controls,
.handleGutter[data-menu-open="true"] .controls {
opacity: 1;
pointer-events: auto;
}
@media print {
.controls {
display: none !important;
}
}
@media (max-width: 48em) {
.handleGutter {
margin-left: -1rem;

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