feat: bases

Add the bases feature: formula engine package, grid/table UI, and the base-embed editor extension, with supporting client and server changes.
This commit is contained in:
Philipinho
2026-06-13 01:26:37 +01:00
parent d86d51c27e
commit 572452c80b
237 changed files with 28203 additions and 146 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -0,0 +1,479 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 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.
+3 -1
View File
@@ -18,6 +18,7 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0",
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
"@casl/react": "5.0.1",
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "8.3.18",
@@ -32,7 +33,8 @@
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.2",
"alfaaz": "1.1.0",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
"Page menu for {{name}}": "Seitenmenü für {{name}}",
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1085,5 +1085,41 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}"
"Create subpage of {{name}}": "Create subpage of {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.",
"Previous record": "Previous record",
"Next record": "Next record",
"Record actions": "Record actions",
"Delete record": "Delete record",
"Delete record?": "Delete record?",
"This action cannot be undone.": "This action cannot be undone.",
"to navigate": "to navigate",
"to close": "to close",
"Expand row {{number}}": "Expand row {{number}}",
"Saving…": "Saving…",
"Read-only": "Read-only",
"Loading…": "Loading…",
"Updated {{when}}": "Updated {{when}}",
"Add property": "Add property",
"Create property": "Create property",
"Hide properties": "Hide properties",
"Find a property type": "Find a property type",
"Properties": "Properties"
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
"Page menu for {{name}}": "Menú de página para {{name}}",
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
"Create subpage of {{name}}": "Crear subpágina de {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
"Page menu for {{name}}": "Menu de la page pour {{name}}",
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
"Create subpage of {{name}}": "Créer une sous-page de {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
"Page menu for {{name}}": "Menu della pagina per {{name}}",
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
"Page menu for {{name}}": "{{name}} のページメニュー",
"Create subpage of {{name}}": "{{name}} のサブページを作成"
"Create subpage of {{name}}": "{{name}} のサブページを作成",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
"Page menu for {{name}}": "Paginamenu voor {{name}}",
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
"Page menu for {{name}}": "Menu da página de {{name}}",
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
"Page menu for {{name}}": "Меню страницы для {{name}}",
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "{{name}} додано до обраного",
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
"Page menu for {{name}}": "Меню сторінки для {{name}}",
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
@@ -1084,5 +1084,23 @@
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
"Page menu for {{name}}": "{{name}} 的页面菜单",
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
"Apply": "Apply",
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
"Change type": "Change type",
"Change type to {{label}}?": "Change type to {{label}}?",
"Converting…": "Converting…",
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
}
+3
View File
@@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import BasePage from "@/ee/base/pages/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
@@ -106,6 +107,8 @@ export default function App() {
element={<Page />}
/>
<Route path={"/base/:pageId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
@@ -0,0 +1,18 @@
import { ThemeIcon } from "@mantine/core";
import { IconFileDescription, IconTable } from "@tabler/icons-react";
type Props = {
icon?: string | null;
isBase?: boolean;
};
export function PageListIcon({ icon, isBase }: Props) {
if (icon) {
return <>{icon}</>;
}
return (
<ThemeIcon variant="transparent" color="gray" size={18}>
{isBase ? <IconTable size={18} /> : <IconFileDescription size={18} />}
</ThemeIcon>
);
}
@@ -4,15 +4,15 @@ import {
UnstyledButton,
Badge,
Table,
ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { PageListIcon } from "@/components/common/page-list-icon";
import { IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
@@ -50,14 +50,10 @@ export default function RecentChanges({ spaceId }: Props) {
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || (
<ThemeIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
</ThemeIcon>
)}
<PageListIcon icon={page.icon} isBase={page.isBase} />
<Text fw={500} size="md" lineClamp={1}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</Text>
</Group>
</UnstyledButton>
@@ -3,6 +3,7 @@ import { ActionIcon } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IPage } from "@/features/page/types/page.types";
import { getPageTitle } from "@/features/page/page.utils";
import { PageChildren } from "./page-children";
import classes from "./destination-picker.module.css";
@@ -95,7 +96,7 @@ export function PageRow({
</div>
<div className={classes.pageTitle}>
{page.title || t("Untitled")}
{getPageTitle(page.title, page.isBase, t)}
</div>
</div>
@@ -0,0 +1,43 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import { EditingCell } from "@/ee/base/types/base.types";
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
// the same page don't share UI state.
export const activeViewIdAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const editingCellAtomFamily = atomFamily((_pageId: string) =>
atom<EditingCell>(null),
);
export type FormulaEditorTarget = {
propertyId: string;
rowId: string | null;
} | null;
export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) =>
atom<FormulaEditorTarget>(null),
);
export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) =>
atom<string | null>(null),
);
export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) =>
atom<boolean>(false),
);
export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) =>
atom<number>(0),
);
export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
atom<Set<string>>(new Set<string>()),
);
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
atom<number | null>(null),
);
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const formulaRecomputeAtom = atom<Record<string, string[]>>({});
@@ -0,0 +1,20 @@
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
import type { RowReferences } from "@/ee/base/types/base.types";
// Per-base normalized store of resolved reference entities, hydrated from each
// rows-page `references`. Keyed by pageId, matching base-atoms.ts.
export const referenceStoreAtomFamily = atomFamily((_pageId: string) =>
atom<RowReferences>({ users: {}, pages: {} }),
);
export function mergeReferences(
prev: RowReferences,
next: RowReferences | undefined,
): RowReferences {
if (!next) return prev;
return {
users: { ...prev.users, ...next.users },
pages: { ...prev.pages, ...next.pages },
};
}
@@ -0,0 +1,21 @@
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/ee/base/types/base.types";
export type ViewDraftKey = {
userId: string;
pageId: string;
viewId: string;
};
export const viewDraftStorageKey = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`;
// atomWithStorage handles JSON serialization and cross-tab sync. The custom
// comparator ensures the same userId/pageId/viewId triple resolves to the
// same atom instance, so Jotai's identity-equality cache hits still work.
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(viewDraftStorageKey(k), null),
(a, b) =>
a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId,
);
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebouncedCallback } from "@mantine/hooks";
import {
usePageQuery,
useUpdateTitlePageMutation,
updatePageData,
} from "@/features/page/queries/page-query";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter";
import classes from "@/ee/base/styles/grid.module.css";
// Editable base name for the inline embed. Follows the TitleEditor convention
// (updatePageData + localEmitter + websocket emit) so the sidebar and other
// clients stay in sync. Standalone pages use the page TitleEditor instead.
export function BaseEmbedTitle({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId });
const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation();
const emit = useQueryEmit();
const [value, setValue] = useState("");
const focusedRef = useRef(false);
// Keep in sync with the persisted title but never clobber active user input.
useEffect(() => {
if (!focusedRef.current) setValue(page?.title ?? "");
}, [page?.title]);
const commit = useCallback(() => {
const trimmed = value.trim();
if (!page || trimmed === (page.title ?? "")) return;
updateTitleAsync({ pageId, title: trimmed }).then((updated) => {
if (updated.title !== trimmed) return;
const event: UpdateEvent = {
operation: "updateOne",
spaceId: updated.spaceId,
entity: ["pages"],
id: updated.id,
payload: {
title: updated.title,
slugId: updated.slugId,
parentPageId: updated.parentPageId,
icon: updated.icon,
},
};
updatePageData(updated);
localEmitter.emit("message", event);
emit(event);
});
}, [value, page, pageId, updateTitleAsync, emit]);
const debouncedCommit = useDebouncedCallback(commit, 500);
// Force-save any pending edit on unmount (e.g. navigating away mid-type).
const commitRef = useRef(commit);
useEffect(() => {
commitRef.current = commit;
}, [commit]);
useEffect(() => () => commitRef.current(), []);
return (
<input
className={classes.embedTitleInput}
value={value}
placeholder={t("Untitled base")}
aria-label={t("Base name")}
onChange={(e) => {
setValue(e.currentTarget.value);
debouncedCommit();
}}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={() => {
focusedRef.current = false;
commit();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
if (e.key === "Escape") {
setValue(page?.title ?? "");
e.currentTarget.blur();
}
}}
/>
);
}
@@ -0,0 +1,92 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/ee/base/styles/grid.module.css";
import classes from "@/ee/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const DEFAULT_COLUMN_COUNT = 6;
const DEFAULT_ROW_COUNT = 10;
// Deterministic widths prevent flicker between renders.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
type BaseTableSkeletonProps = {
// Match the eventual content shape to avoid a jarring size jump on swap.
rows?: number;
columns?: number;
};
export function BaseTableSkeleton({
rows = DEFAULT_ROW_COUNT,
columns = DEFAULT_COLUMN_COUNT,
}: BaseTableSkeletonProps = {}) {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div className={classes.root}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: columns }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex % HEADER_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { GridContainer } from "@/ee/base/components/grid/grid-container";
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/ee/base/types/base.types";
type BaseTableProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
isFiltered: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode;
};
export function BaseTable({
base,
rows: _rows,
table,
pageId,
embedded,
isFiltered,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
onCellUpdate,
onAddRow,
onColumnReorder,
onResizeEnd,
onRowReorder,
scrollportRef,
aboveBand,
}: BaseTableProps) {
return (
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={onCellUpdate}
onAddRow={onAddRow}
pageId={pageId}
isFiltered={isFiltered}
onColumnReorder={onColumnReorder}
onResizeEnd={onResizeEnd}
onRowReorder={onRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={onFetchNextPage}
scrollElement={embedded ? window : scrollportRef.current}
aboveBand={aboveBand ?? null}
/>
);
}
@@ -0,0 +1,251 @@
import { useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
IconArrowsDiagonal,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css";
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
type BaseToolbarProps = {
base: IBase;
// Effective view (baseline merged with local draft). Badge counts and popover
// seed data read from this; the real baseline only enters via the draft callbacks.
activeView: IBaseView | undefined;
views: IBaseView[];
table: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
onPersistViewConfig: () => void;
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
onExpand?: () => void;
getViewShareUrl?: (viewId: string) => string | null;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
onPersistViewConfig,
onDraftSortsChange,
onDraftFiltersChange,
onExpand,
getViewShareUrl,
}: BaseToolbarProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setPropertiesOpened(panel === "properties" ? (v) => !v : false);
}, []);
const sorts = activeView?.config?.sorts ?? [];
// The stored filter is a tree; the popover edits an AND-only flat list.
// Unwrap the top-level group's children when reading, rewrap on save.
const conditions = useMemo<FilterCondition[]>(() => {
const filter = activeView?.config?.filter;
if (!filter || filter.op !== "and") return [];
return filter.children.filter(
(c): c is FilterCondition => !("children" in c),
);
}, [activeView?.config?.filter]);
const hiddenPropertyCount = useMemo(() => {
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table.getState().columnVisibility]);
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
// Normalize empty to undefined so the draft hook drops the sorts axis.
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
},
[onDraftSortsChange],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
// Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis.
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
onDraftFiltersChange(filter);
},
[onDraftFiltersChange],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
pageId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
getViewShareUrl={getViewShareUrl}
/>
<div className={classes.toolbarRight}>
{editable && (
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
)}
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
conditions={conditions}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={conditions.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{conditions.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{conditions.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
<ViewPropertyVisibility
opened={propertiesOpened}
onClose={() => setPropertiesOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
>
<IconEye size={16} />
{hiddenPropertyCount > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{hiddenPropertyCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewPropertyVisibility>
{onExpand && (
<Tooltip label={t("Open as page")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onExpand}
>
<IconArrowsDiagonal size={16} />
</ActionIcon>
</Tooltip>
)}
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { Group, Button, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({
isDirty,
canSave,
onReset,
onSave,
saving,
}: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Group justify="flex-end" gap="xs" px="md" py={6} wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>
{t("Reset")}
</Button>
{canSave && (
<Tooltip
label={t("Filter and sort changes are visible only to you")}
position="top"
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={onSave}
loading={saving}
>
{t("Save for everyone")}
</Button>
</Tooltip>
)}
</Group>
);
}
@@ -0,0 +1,508 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
EditingCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
useBaseRowsQuery,
useBaseRowsCountQuery,
flattenRows,
useCreateRowMutation,
useUpdateRowMutation,
useReorderRowMutation,
} from "@/ee/base/queries/base-row-query";
import {
useCreateViewMutation,
useUpdateViewMutation,
} from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store";
import { useViewDraft } from "@/ee/base/hooks/use-view-draft";
import { BaseToolbar } from "@/ee/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner";
import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { ViewRenderer } from "@/ee/base/components/views/view-renderer";
import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { BaseEditableProvider } from "@/ee/base/context/base-editable";
import { RowExpandProvider } from "@/ee/base/context/row-expand";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { getAppUrl } from "@/lib/config.ts";
import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css";
type BaseViewProps = {
pageId: string;
embedded?: boolean;
/** False makes the view read-only. Standalone passes page.permissions.canEdit;
* embedded ANDs that with the host editor's editability. */
editable?: boolean;
titleSlot?: React.ReactNode;
};
export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) {
const { t } = useTranslation();
// Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } =
useBaseQuery(pageId);
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId });
const handleExpand = useCallback(() => {
if (!page) return;
navigate(buildPageUrl(page.space?.slug, page.slugId, page.title));
}, [navigate, page]);
// Share URL for a specific view; always points at the standalone page where ?view= is honored.
const getViewShareUrl = useCallback(
(viewId: string) =>
page
? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}`
: null,
[page],
);
const [activeViewId, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const [, setEditingCell] = useAtom(
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[base?.views],
);
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const { data: currentUser } = useCurrentUser();
useHydrateCurrentUser(pageId);
const {
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
pageId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Baseline merged with local draft. Used for table state and toolbar badge counts.
// The real activeView remains the auto-persist baseline so drafts can't leak into layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
const canSave = editable;
// Gate on base to avoid a "bland" list request before the active view's
// config resolves, which would double network traffic for sorted/filtered views.
const {
data: rowsData,
isLoading: rowsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts);
// Warm the count cache alongside the rows query. Gate on currentUser so
// useViewDraft has hydrated from localStorage before the count fires.
const canFetchCount = !!base && !!currentUser;
useBaseRowsCountQuery(canFetchCount ? pageId : undefined, activeFilter);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
// Deep link: apply ?view=<id> once after views load; skip if the id is
// unrecognised so we fall back to the default without fighting a later tab switch.
const appliedViewParamRef = useRef(false);
useEffect(() => {
if (appliedViewParamRef.current || views.length === 0) return;
const viewParam = new URLSearchParams(window.location.search).get("view");
if (viewParam && views.some((v) => v.id === viewParam)) {
setActiveViewId(viewParam);
}
appliedViewParamRef.current = true;
}, [views, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// With an active sort the server returns rows in sort order via keyset
// pagination; re-sorting by position on the client would break it as more
// pages load. Position sort only applies when no view sort is active.
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const rowsRef = useRef(rows);
rowsRef.current = rows;
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView);
const guardedPersistViewConfig = useCallback(() => {
if (!editable) return;
persistViewConfig();
}, [editable, persistViewConfig]);
// Mutation result objects change identity every render; only .mutate is
// stable. Rows are memoized on these callbacks' identities, so they must
// not churn with unrelated re-renders.
const updateRow = updateRowMutation.mutate;
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
if (!editable) return;
updateRow({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[editable, pageId, updateRow],
);
const handleAddRow = useCallback(() => {
if (!editable) return;
createRowMutation.mutate(
{ pageId },
{
onSuccess: (newRow) => {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
const propertyId = (
firstEditable?.columnDef.meta?.property as IBaseProperty | undefined
)?.id;
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
}
},
},
);
}, [editable, pageId, createRowMutation, table, setEditingCell]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
if (!editable) return;
createViewMutation.mutate({
pageId,
name: t("New view"),
type: "table",
});
}, [editable, pageId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder;
const startIndex = order.indexOf(columnId);
if (startIndex === -1 || startIndex === finishIndex) return;
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
guardedPersistViewConfig();
},
[table, guardedPersistViewConfig],
);
const handleResizeEnd = useCallback(() => {
guardedPersistViewConfig();
}, [guardedPersistViewConfig]);
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[] | undefined) => {
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
},
[setDraftSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => {
setDraftFilter(filter);
},
[setDraftFilter],
);
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
// Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
pageId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const { openRowId, openRow, closeRow } = useRowDetailModal();
// openRow's identity tracks searchParams; rows subscribe to the expand
// context, so hand them a stable wrapper instead.
const openRowRef = useRef(openRow);
openRowRef.current = openRow;
const handleExpandRow = useCallback((rowId: string) => {
openRowRef.current(rowId);
}, []);
const handleRowNavigate = useCallback((rowId: string) => {
openRowRef.current(rowId, { replace: true });
}, []);
const reorderRow = reorderRowMutation.mutate;
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
if (!editable) return;
const remainingRows = rowsRef.current.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos =
targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos =
targetIndex < remainingRows.length - 1
? remainingRows[targetIndex + 1]?.position
: null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRow({ rowId, pageId, position: newPosition });
} catch {
// Position computation failed; skip silently.
}
},
[editable, pageId, reorderRow],
);
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase 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.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={editable ? handleAddView : undefined}
onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
onExpand={embedded ? handleExpand : undefined}
getViewShareUrl={getViewShareUrl}
/>
);
if (embedded) {
// Banner and toolbar go into aboveBand so they scroll with the host document;
// only the column-header row stays pinned (via --sticky-band-top).
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
aboveBand={
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>
}
/>
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned.
return (
<BaseEditableProvider editable={editable}>
<div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}>
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
aboveBand={
<>
{titleSlot}
{banner}
{toolbar}
</>
}
/>
</RowExpandProvider>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
@@ -0,0 +1,100 @@
import { ReactElement, useLayoutEffect, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function computeVisibleBadgeCount(
itemWidths: number[],
gap: number,
available: number,
badgeWidth: number,
): number {
const count = itemWidths.length;
if (count === 0) return 0;
if (available <= 0) return count;
let lineWidth = 0;
for (let i = 0; i < count; i++) {
lineWidth += itemWidths[i] + (i > 0 ? gap : 0);
}
if (lineWidth <= available) return count;
let used = 0;
let fit = 0;
for (let i = 0; i < count; i++) {
const advance = itemWidths[i] + (i > 0 ? gap : 0);
if (used + advance + gap + badgeWidth <= available) {
used += advance;
fit = i + 1;
} else {
break;
}
}
return Math.max(fit, 1);
}
const BADGE_GAP = 4;
type BadgeOverflowListProps = {
chips: ReactElement[];
measureKey: string;
tooltipLabel?: string;
};
export function BadgeOverflowList({
chips,
measureKey,
tooltipLabel,
}: BadgeOverflowListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(chips.length);
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const recompute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth);
const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0;
setVisibleCount(
computeVisibleBadgeCount(
chipWidths,
BADGE_GAP,
container.clientWidth,
badgeWidth,
),
);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(container);
return () => observer.disconnect();
}, [measureKey]);
const visible = chips.slice(0, visibleCount);
const overflow = chips.length - visibleCount;
return (
<Tooltip
label={tooltipLabel}
multiline
withinPortal
openDelay={400}
disabled={!tooltipLabel || overflow <= 0}
>
<div className={cellClasses.badgeGroup} ref={containerRef}>
<div className={cellClasses.badgeMeasure} ref={measureRef} aria-hidden>
{chips}
<span className={cellClasses.overflowCount}>+{chips.length}</span>
</div>
{visible}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
</Tooltip>
);
}
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
if (readOnly) return;
onCommit(!checked);
}, [readOnly, checked, onCommit]);
return (
<div
className={cellClasses.checkboxCell}
onClick={handleChange}
style={readOnly ? { cursor: "default" } : undefined}
>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{
input: {
cursor: readOnly ? "default" : "pointer",
pointerEvents: "none",
},
}}
/>
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,146 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -0,0 +1,52 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,235 @@
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { uploadFile } from "@/features/page/services/page-service";
import { getFileUrl } from "@/lib/config";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
url?: string;
};
function buildFileUrl(file: Pick<FileValue, "id" | "fileName" | "url">): string {
return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`;
}
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
readOnly?: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
readOnly,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
if (readOnly) return;
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[readOnly, files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
// Reuse the page-attachment upload pipeline: the base's pageId is passed
// to the standard /files/upload endpoint, which enforces the same edit
// access check as any other page attachment.
for (const file of Array.from(fileList)) {
try {
const attachment = await uploadFile(file, property.pageId);
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.pageId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{!readOnly && files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div key={file.id} className={cellClasses.fileItemRow}>
<IconFile size={14} className={cellClasses.fileItemIcon} />
<a
href={getFileUrl(buildFileUrl(file))}
target="_blank"
rel="noreferrer"
className={cellClasses.fileItemLink}
>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</a>
{!readOnly && (
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
)}
</div>
))}
{!readOnly && (
<>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
<UnstyledButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={cellClasses.fileUploadBtn}
style={{
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
}
function FileList({
files,
maxVisible,
}: {
files: FileValue[];
maxVisible: number;
}) {
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
<IconPaperclip size={12} />
{file.fileName}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { Badge, Tooltip } from "@mantine/core";
import {
IBaseProperty,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { CellText } from "./cell-text";
import { CellNumber } from "./cell-number";
import { CellCheckbox } from "./cell-checkbox";
import { CellDate } from "./cell-date";
type Props = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellFormula(props: Props) {
const { value, property } = props;
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`}>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
const readOnlyProps = { ...props, isEditing: false };
if (resultType === "number") return <CellNumber {...readOnlyProps} />;
if (resultType === "boolean") return <CellCheckbox {...readOnlyProps} />;
if (resultType === "date") return <CellDate {...readOnlyProps} />;
return <CellText {...readOnlyProps} />;
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,41 @@
import { Group, Tooltip } from "@mantine/core";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useReferenceStore } from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedByProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedBy({ value, property }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(property.pageId);
const user = userId ? store.users[userId] ?? null : null;
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<span className={cellClasses.lastEditedByName}>{name}</span>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from "react";
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLongTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function CellLongText({
value,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellLongTextProps) {
const [draft, setDraft] = useState(() => toText(value));
const cancelledRef = useRef(false);
const committedRef = useRef(false);
const wasEditingRef = useRef(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Seed draft and focus on the false->true editing transition only; ignore
// value changes mid-edit so the user's typing is not clobbered.
useEffect(() => {
if (isEditing && !wasEditingRef.current) {
cancelledRef.current = false;
committedRef.current = false;
setDraft(toText(value));
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
}
wasEditingRef.current = isEditing;
}, [isEditing, value]);
// Autosave after a typing pause; commit/cancel clear the pending fire so
// a closed editor can never write a stale or discarded draft.
const debouncedAutosave = useDebouncedCallback(() => {
onValueChange(normalize(draft));
}, 10_000);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
debouncedAutosave.cancel();
onCommit(normalize(draft));
};
const cancel = () => {
cancelledRef.current = true;
debouncedAutosave.cancel();
onCancel();
};
const preview = toText(value).replace(/\s+/g, " ").trim();
return (
<Popover
opened={isEditing}
onChange={(opened) => {
if (opened) return;
// Programmatic close after cancel must not re-commit.
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
commit();
}}
position="bottom-start"
width={320}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={cellClasses.popoverTargetFlex}>
{preview ? (
<Tooltip label={toText(value)} multiline withinPortal openDelay={400} maw={420}>
<span className={cellClasses.longTextPreview}>{preview}</span>
</Tooltip>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown
p={4}
onClick={(e) => e.stopPropagation()}
className={cellClasses.longTextDropdown}
>
{isEditing && (
<>
<Group justify="flex-end" mb={2}>
<CloseButton size="sm" onClick={commit} aria-label="Close" />
</Group>
<Textarea
ref={textareaRef}
data-autofocus
autosize
minRows={3}
maxRows={12}
maxLength={25000}
variant="unstyled"
value={draft}
onChange={(e) => {
setDraft(e.currentTarget.value);
debouncedAutosave();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
commit();
}
}}
styles={{ input: { padding: 4 } }}
/>
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,289 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import { Popover, TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellMultiSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onValueChange,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedSet = new Set(selectedIds);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = (
search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices
).filter((c) => !selectedSet.has(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onValueChange(newIds);
},
[selectedIds, selectedSet, onValueChange],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onValueChange([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onValueChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<BadgeList choices={selectedChoices} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
{selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
handleToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (selectedChoices.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return (
<BadgeList
choices={selectedChoices}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function BadgeList({
choices,
tooltipLabel,
}: {
choices: Choice[];
tooltipLabel?: string;
}) {
const chips = choices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={choices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={tooltipLabel}
/>
);
}
@@ -0,0 +1,97 @@
import {
IBaseProperty,
NumberTypeOptions,
} from "@/ee/base/types/base.types";
import { formatCurrency } from "@/ee/base/constants/currencies";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision ?? 0;
const format = options?.format ?? "plain";
switch (format) {
case "separators":
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
}).format(val);
case "currency":
return formatCurrency(val, options?.currencyCode, options?.precision);
case "percent":
return `${val.toFixed(precision)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
}
}
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 CellNumber({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={`${cellClasses.cellInput} ${cellClasses.numberInput}`}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const numValue = typeof value === "number" ? value : null;
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={cellClasses.numberValue}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{formatNumber(numValue, typeOptions)}
</AutoTooltipText>
);
}
@@ -0,0 +1,338 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { IconX, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useResolvePage } from "@/ee/base/reference/reference-store";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { searchSuggestions } from "@/features/search/services/search-service";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type PageSuggestion = {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space?: { id: string; slug: string; name: string } | null;
};
function parsePageId(value: unknown): string | null {
if (typeof value === "string" && value.length > 0) return value;
return null;
}
function parsePastedPageSlugId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
let path = trimmed;
if (/^https?:\/\//i.test(trimmed)) {
try {
path = new URL(trimmed).pathname;
} catch {
return null;
}
}
const match = path.match(/\/p\/([^/?#]+)/);
if (!match) return null;
return extractPageSlugId(match[1]) ?? null;
}
export function CellPage({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPageProps) {
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.pageId);
const resolvedPage = useResolvePage(property.pageId, pageId);
if (isEditing) {
return (
<PagePicker
pageId={pageId}
resolvedPage={resolvedPage ?? null}
spaceId={base?.spaceId}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
if (!pageId) {
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === undefined) {
// placeholder to avoid "Page not found" flicker on initial load
return <span className={cellClasses.emptyValue} />;
}
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
return <PagePill page={resolvedPage} />;
}
type PillPage = {
slugId: string;
title: string | null;
icon: string | null;
space: { slug: string } | null;
};
function PagePill({ page }: { page: PillPage }) {
const { t } = useTranslation();
const title = getPageTitle(page.title, undefined, t);
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{page.icon ? (
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
type PagePickerProps = {
pageId: string | null;
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
spaceId?: string;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function PagePicker({
pageId,
resolvedPage,
spaceId,
onCommit,
onCancel,
}: PagePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const trimmed = debouncedSearch.trim();
const pastedSlugId = useMemo(
() => parsePastedPageSlugId(debouncedSearch),
[debouncedSearch],
);
const { data: suggestions = [] } = useQuery({
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
queryFn: async () => {
const res = await searchSuggestions({
query: trimmed,
includePages: true,
spaceId,
limit: trimmed ? 25 : 5,
});
return (res.pages ?? []) as PageSuggestion[];
},
enabled: !pastedSlugId,
staleTime: 15_000,
});
// Once the pasted link resolves via slugId lookup, commit and close.
const { data: linkedPage, isFetching: resolvingLink } = usePageQuery(
pastedSlugId ? { pageId: pastedSlugId } : {},
);
const linkedRef = useRef(false);
useEffect(() => {
if (!pastedSlugId) {
linkedRef.current = false;
return;
}
if (linkedPage && !linkedRef.current) {
linkedRef.current = true;
onCommit(linkedPage.id);
}
}, [pastedSlugId, linkedPage, onCommit]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(suggestions.length, [debouncedSearch]);
const handleSelect = useCallback(
(id: string) => {
onCommit(id === pageId ? null : id);
},
[pageId, onCommit],
);
const handleRemove = useCallback(() => {
onCommit(null);
}, [onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= suggestions.length) return;
e.preventDefault();
handleSelect(suggestions[activeIndex].id);
}
},
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
);
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={320}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{pageId && resolvedPage && (
<span className={cellClasses.personTag}>
{resolvedPage.icon ? (
<span>{resolvedPage.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<span className={cellClasses.personTagName}>
{getPageTitle(resolvedPage.title, undefined, t)}
</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
>
<IconX size={10} />
</button>
</span>
)}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={pageId ? "" : "Search for a page..."}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{pastedSlugId ? (
<div className={cellClasses.personDropdownHint}>
{resolvingLink || linkedPage ? "Linking page…" : "Page not found"}
</div>
) : (
<>
{suggestions.length === 0 && (
<div className={cellClasses.personDropdownHint}>
{trimmed ? "No pages found" : "No pages yet"}
</div>
)}
{suggestions.map((page, idx) => {
const isSelected = page.id === pageId;
return (
<div
key={page.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription
size={14}
color="var(--mantine-color-dimmed)"
/>
)}
<div className={cellClasses.pageOptionText}>
<span className={cellClasses.personOptionName}>
{getPageTitle(page.title, undefined, t)}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" truncate>
{page.space.name}
</Text>
)}
</div>
</div>
);
})}
</>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,282 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
UserRef,
} from "@/ee/base/types/base.types";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onValueChange: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onValueChange,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple === true;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const store = useReferenceStore(property.pageId);
const hydrateUsers = useHydrateUsers(property.pageId);
const suggestions = usePersonSearch(search, isEditing);
// In multi mode omit already-selected from the list (they appear as tags above).
// Single mode keeps the selected row visible so it can be deselected.
const filteredMembers = allowMultiple
? suggestions.filter((m) => !selectedSet.has(m.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
const picked = suggestions.find((s) => s.id === memberId);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (allowMultiple) {
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onValueChange([...personIds, memberId]);
}
} else {
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[suggestions, hydrateUsers, allowMultiple, personIds, onCommit, onValueChange],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onValueChange(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, onCommit, onValueChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
<PersonReadList personIds={personIds} users={store.users} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = store.users[id];
const name = member?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={personIds.length === 0 ? "Search for a person..." : ""}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
data-autofocus
/>
</div>
<div className={cellClasses.personDropdownDivider} />
{allowMultiple && (
<div className={cellClasses.personDropdownHint}>
Select as many as you like
</div>
)}
<div className={cellClasses.selectDropdown}>
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
return (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
);
})}
{filteredMembers.length === 0 && (
<div className={cellClasses.personDropdownHint}>
No members found
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <PersonReadList personIds={personIds} users={store.users} />;
}
function PersonReadList({
personIds,
users,
}: {
personIds: string[];
users: Record<string, UserRef>;
}) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -0,0 +1,239 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type CellSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit(newChoice.id);
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onCommit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
);
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import cellClasses from "@/ee/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
type CellStatusProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type CategoryGroup = {
label: string;
choices: Choice[];
};
const categoryLabels: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
export function CellStatus({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellStatusProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const groups = useMemo(() => {
const filtered = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const grouped: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(choice);
}
const result: CategoryGroup[] = [];
for (const key of ["todo", "inProgress", "complete"]) {
if (grouped[key]?.length) {
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
}
}
return result;
}, [choices, search]);
const flatChoices = useMemo(
() => groups.flatMap((g) => g.choices),
[groups],
);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(flatChoices.length, [search, isEditing]);
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[activeIndex]);
}
},
[onCancel, handleNavKey, activeIndex, flatChoices, handleSelect],
);
if (isEditing) {
return (
<Popover
opened
onChange={(o) => {
if (!o) onCancel();
}}
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={cellClasses.popoverTarget}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label}>
<div className={cellClasses.selectCategoryLabel}>
{group.label}
</div>
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<ChoiceBadge
name={selectedChoice.name}
style={choiceColor(selectedChoice.color)}
/>
);
}
@@ -0,0 +1,51 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import cellClasses from "@/ee/base/styles/cells.module.css";
import gridClasses from "@/ee/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft;
export function CellText({ value, isEditing, onCommit, onCancel }: CellTextProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
maxLength={1000}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<AutoTooltipText
className={gridClasses.cellContent}
fz="sm"
tooltipProps={{ withinPortal: true }}
>
{displayValue}
</AutoTooltipText>
);
}
@@ -0,0 +1,61 @@
import { IBaseProperty } from "@/ee/base/types/base.types";
import { sanitizeUrl } from "@docmost/editor-ext";
import { Tooltip } from "@mantine/core";
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
const parse = (draft: string) => draft || null;
export function CellUrl({ value, isEditing, onCommit, onCancel }: CellUrlProps) {
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
const displayValue = toDraft(value);
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return <span>{displayValue}</span>;
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
</Tooltip>
);
}
@@ -0,0 +1,30 @@
function isEmpty(v: unknown): boolean {
return v === null || v === undefined || v === "";
}
/**
* Deep equality for cell values. Treats null/undefined/"" as equivalent
* "empty", compares arrays by ordered content, objects by serialized form.
* Note: an empty array [] is a real value, distinct from "empty".
*/
export function cellValuesEqual(a: unknown, b: unknown): boolean {
const aArr = Array.isArray(a);
const bArr = Array.isArray(b);
if (!aArr && !bArr) {
if (isEmpty(a) && isEmpty(b)) return true;
if (isEmpty(a) !== isEmpty(b)) return false;
}
if (aArr || bArr) {
if (!aArr || !bArr) return false;
if (a.length !== b.length) return false;
return a.every((x, i) => cellValuesEqual(x, b[i]));
}
if (typeof a === "object" && typeof b === "object" && a && b) {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
@@ -0,0 +1,29 @@
import { CSSProperties, useRef, useState } from "react";
import { Tooltip } from "@mantine/core";
import cellClasses from "@/ee/base/styles/cells.module.css";
type ChoiceBadgeProps = {
name: string;
style: CSSProperties;
};
export function ChoiceBadge({ name, style }: ChoiceBadgeProps) {
const ref = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
return (
<Tooltip label={name} withinPortal openDelay={400} disabled={!truncated}>
<span
ref={ref}
className={cellClasses.badge}
style={style}
onMouseEnter={() => {
const el = ref.current;
if (el) setTruncated(el.scrollWidth > el.clientWidth);
}}
>
{name}
</span>
</Tooltip>
);
}
@@ -0,0 +1,25 @@
import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1864ab", textDark: "#74c0fc" },
cyan: { bg: "#c5f6fa", bgDark: "#1a3a3a", text: "#0b7285", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#066649", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#1b5e20", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#44700a", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#8a5200", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#a63508", textDark: "#ffa94d" },
};
export function choiceColor(color: string): CSSProperties {
const c = colorMap[color] ?? colorMap.gray;
return {
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
color: `light-dark(${c.text}, ${c.textDark})`,
};
}
@@ -0,0 +1,258 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { TextInput } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/ee/base/styles/cells.module.css";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORY_LABELS: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"];
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
type ChoiceGroup = { label: string | null; choices: Choice[] };
type ChoicePickerProps = {
property: IBaseProperty;
selectedIds: string[];
/** Multi keeps the picker open, hides picked options from the list and
* shows them as removable tags instead. */
multiple?: boolean;
/** Group options under status category headings. */
grouped?: boolean;
/** Offer "Add option: <search>" when the search has no exact match. */
allowCreate?: boolean;
onToggle: (choice: Choice) => void;
onEscape: () => void;
};
/** Searchable choice list shared by select-like editors (modal fields; the
* grid cells render the same UI and can migrate here). */
export function ChoicePicker({
property,
selectedIds,
multiple = false,
grouped = false,
allowCreate = false,
onToggle,
onEscape,
}: ChoicePickerProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const groups = useMemo<ChoiceGroup[]>(() => {
const filtered = (
search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices
).filter((c) => !multiple || !selectedSet.has(c.id));
if (!grouped) return [{ label: null, choices: filtered }];
const byCategory: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
(byCategory[cat] ??= []).push(choice);
}
return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map(
(key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }),
);
}, [choices, search, grouped, multiple, selectedSet]);
const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const updatePropertyMutation = useUpdatePropertyMutation();
const trimmedSearch = search.trim();
const hasExactMatch = useMemo(
() =>
trimmedSearch.length > 0 &&
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
[choices, trimmedSearch],
);
const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch;
const addOptionColor = useMemo(
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
[choices.length],
);
const navItems = useMemo<NavItem[]>(
() => [
...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[flatChoices, showAddOption],
);
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(navItems.length, [search, showAddOption]);
const handleAddOption = useCallback(() => {
if (!trimmedSearch) return;
const newChoice: Choice = {
id: uuid7(),
name: trimmedSearch,
color: addOptionColor,
};
const newChoices = [...choices, newChoice];
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onToggle(newChoice);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape();
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex >= 0 && activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[activeIndex];
if (item.kind === "choice") onToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption],
);
const addOptionIdx = flatChoices.length;
return (
<>
{multiple && selectedChoices.length > 0 && (
<div className={cellClasses.personTagArea}>
{selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
<button
type="button"
className={`${cellClasses.personTagRemove} ${cellClasses.badgeRemoveBtn}`}
onClick={(e) => {
e.stopPropagation();
onToggle(choice);
}}
>
<IconX size={10} />
</button>
</span>
))}
</div>
)}
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
data-autofocus
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label ?? "all"}>
{group.label && (
<div className={cellClasses.selectCategoryLabel}>{group.label}</div>
)}
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = !multiple && selectedSet.has(choice.id);
return (
<div
key={choice.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
isSelected && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => onToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
</div>
))}
{showAddOption && (
<div
ref={setOptionRef(addOptionIdx)}
className={clsx(
cellClasses.addOptionRow,
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(addOptionIdx)}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span className={cellClasses.badge} style={choiceColor(addOptionColor)}>
{trimmedSearch}
</span>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,189 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Divider,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconMathFunction,
IconPointFilled,
} from "@tabler/icons-react";
import { registry } from "@docmost/base-formula/client";
import { FormulaInput } from "./formula-input";
import { PropertyChipRow } from "./property-chip-row";
import { FunctionPalette } from "./function-palette";
import { useFormulaParser } from "@/ee/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
type Props = {
properties: IBaseProperty[];
editingPropertyId: string | null;
initialSource?: string;
name?: string;
disabled?: boolean;
onSave: (
source: string,
ast: unknown,
resultType: string,
dependencies: string[],
) => void;
onCancel: () => void;
};
export function FormulaEditor({
properties,
editingPropertyId,
initialSource = "",
name,
disabled = false,
onSave,
onCancel,
}: Props) {
const [source, setSource] = useState(initialSource);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingCursorRef = useRef<number | null>(null);
const parseState = useFormulaParser(
source,
properties,
editingPropertyId,
registry,
);
const canSave = parseState.state === "ok" && !disabled;
// useEffect (not RAF) ensures the DOM update ran before restoring cursor.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(pos, pos);
}, [source]);
const insertAtCursor = (snippet: string, cursorOffsetFromEnd = 0) => {
const ta = textareaRef.current;
const start = ta?.selectionStart ?? source.length;
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
const next = before + prefix + snippet + after;
pendingCursorRef.current =
before.length + prefix.length + snippet.length - cursorOffsetFromEnd;
setSource(next);
};
return (
<Paper
withBorder
radius="md"
shadow="sm"
p={0}
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={12}
className={classes.formulaHeaderRow}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<div className={classes.formulaIconBadge}>
<IconMathFunction size={14} />
</div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed" truncate>
· {name}
</Text>
)}
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Button variant="subtle" size="xs" onClick={onCancel}>
Cancel
</Button>
<Button
size="xs"
disabled={!canSave}
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}}
>
Save
</Button>
</Group>
</Group>
<Stack gap={6} px={14} pt={10} pb={8}>
<FormulaInput
ref={textareaRef}
value={source}
onChange={setSource}
hasError={parseState.state === "error"}
/>
<Group justify="space-between" gap={8} mih={16}>
{parseState.state === "error" ? (
<Group gap={6} c="red.7">
<IconAlertTriangle size={12} />
<Text size="xs">{parseState.message}</Text>
</Group>
) : parseState.state === "ok" ? (
<Group gap={6} c="dimmed">
<IconPointFilled size={10} color="var(--mantine-color-teal-6)" />
<Text size="xs">
Returns{" "}
<Text span fw={600} c="gray.8">
{parseState.resultType}
</Text>
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Click a property or function below to insert.
</Text>
)}
</Group>
</Stack>
<Divider />
<Stack gap={8} px={14} pt={10} pb={10}>
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtCursor(`prop("${name}")`)}
/>
</Stack>
<Divider />
<Stack gap={6} px={14} pt={10} pb={10}>
<Text size="xs" fw={600} c="gray.7">
Functions
</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,40 @@
import { forwardRef } from "react";
import { Textarea } from "@mantine/core";
type Props = {
value: string;
onChange: (v: string) => void;
hasError?: boolean;
};
export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
function FormulaInput({ value, onChange, hasError }, ref) {
return (
<Textarea
ref={ref}
autosize
minRows={3}
maxRows={8}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder='prop("Price") * prop("Qty")'
styles={{
input: {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 13,
lineHeight: 1.65,
backgroundColor: "var(--mantine-color-gray-0)",
borderColor: hasError
? "var(--mantine-color-red-6)"
: "var(--mantine-color-blue-6)",
borderWidth: 1.5,
boxShadow: hasError
? "0 0 0 3px var(--mantine-color-red-1)"
: "0 0 0 3px var(--mantine-color-blue-1)",
},
}}
/>
);
},
);
@@ -0,0 +1,48 @@
import { FormulaEditor } from "./formula-editor";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import {
IBaseProperty,
FormulaTypeOptions,
TypeOptions,
} from "@/ee/base/types/base.types";
type Props = {
property: IBaseProperty;
pageId: string;
onClose: () => void;
};
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
const { data: base } = useBaseQuery(pageId);
const updatePropertyMutation = useUpdatePropertyMutation();
const opts = property.typeOptions as FormulaTypeOptions | undefined;
return (
<FormulaEditor
properties={base?.properties ?? []}
editingPropertyId={property.id}
initialSource={opts?.source ?? ""}
name={property.name}
onCancel={onClose}
onSave={(source, ast, resultType, dependencies) => {
if (source === (opts?.source ?? "")) {
onClose();
return;
}
updatePropertyMutation.mutate({
propertyId: property.id,
pageId,
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
});
onClose();
}}
/>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import {
Accordion,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client";
import classes from "@/ee/base/styles/formula.module.css";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
export function FunctionPalette({
registry,
onInsert,
}: {
registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState<string | null>("logic");
const byCat = new Map<string, FormulaFn[]>();
for (const fn of registry.values()) {
if (!byCat.has(fn.category)) byCat.set(fn.category, []);
byCat.get(fn.category)!.push(fn);
}
return (
<Accordion
value={open}
onChange={setOpen}
variant="contained"
radius="md"
chevronSize={14}
styles={{
item: { borderColor: "var(--mantine-color-gray-2)" },
control: { padding: "7px 12px", minHeight: 0 },
label: {
padding: 0,
fontSize: 13,
fontWeight: 600,
textTransform: "capitalize",
},
content: { padding: "6px 10px 10px" },
panel: { background: "var(--mantine-color-gray-0)" },
}}
>
{CATEGORIES.map((cat) => {
const fns = byCat.get(cat) ?? [];
return (
<Accordion.Item key={cat} value={cat}>
<Accordion.Control>
<Group gap={8}>
<span>{cat}</span>
<Text size="xs" c="dimmed" ff="monospace">
{fns.length}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Group gap={6}>
{fns.map((fn) => (
<Tooltip key={fn.name} label={fn.doc} withArrow>
<UnstyledButton
onClick={() => onInsert(fn.name)}
className={classes.fnChip}
>
{fn.name}
<span className={classes.fnChipParens}>
()
</span>
</UnstyledButton>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
@@ -0,0 +1,56 @@
import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
export function PropertyChipRow({
properties,
onInsert,
}: {
properties: IBaseProperty[];
onInsert: (name: string) => void;
}) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return properties;
return properties.filter((p) => p.name.toLowerCase().includes(q));
}, [properties, query]);
return (
<div>
<Group justify="space-between" mb={8}>
<Text size="xs" fw={600} c="gray.7">
Properties
</Text>
<TextInput
size="xs"
placeholder="Search"
leftSection={<IconSearch size={12} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
w={140}
/>
</Group>
{visible.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
No matches.
</Text>
) : (
<Group gap={6}>
{visible.map((p) => (
<UnstyledButton
key={p.id}
onClick={() => onInsert(p.name)}
className={classes.propChip}
>
{p.name}
</UnstyledButton>
))}
</Group>
)}
</div>
);
}
@@ -0,0 +1,26 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css";
type AddRowButtonProps = {
onClick?: () => void;
};
export const AddRowButton = memo(function AddRowButton({
onClick,
}: AddRowButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addRowButton}
onClick={onClick}
role="button"
tabIndex={0}
>
<IconPlus size={14} />
<span>{t("New row")}</span>
</div>
);
});
@@ -0,0 +1,10 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import classes from "@/ee/base/styles/grid.module.css";
type Props = {
edge: Edge;
};
export function BaseDropEdgeIndicator({ edge }: Props) {
return <div className={classes.dropEdgeLine} data-edge={edge} aria-hidden />;
}
@@ -0,0 +1,225 @@
import { memo, useCallback } from "react";
import { Cell } from "@tanstack/react-table";
import { Popover, Tooltip } from "@mantine/core";
import { IconArrowsDiagonal } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
import {
editingCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import {
isSystemPropertyType,
getDescriptor,
} from "@/ee/base/property-types/property-type.registry";
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useRowExpand } from "@/ee/base/context/row-expand";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/ee/base/styles/grid.module.css";
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
pageId: string;
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
onCellUpdate,
pageId,
}: GridCellProps) {
const property = cell.column.columnDef.meta?.property;
const isRowNumber = cell.column.id === "__row_number";
const isPinned = cell.column.getIsPinned();
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const { t } = useTranslation();
const editable = useBaseEditable();
const readOnly = !editable;
const onExpandRow = useRowExpand();
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id &&
(editable || property?.type === "file");
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (readOnly) {
// Read-only: only the file cell opens (a download-only popover) so
// attachments stay reachable.
if (property.type === "file") {
setEditingCell({ rowId, propertyId: property.id });
}
return;
}
if (property.type === "formula") {
setActiveFormulaEditor({ propertyId: property.id, rowId });
return;
}
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const handleValueChange = useCallback(
(value: unknown) => {
if (!property) return;
if (!cellValuesEqual(value, cell.getValue())) {
onCellUpdate(rowId, property.id, value);
}
},
[property, rowId, cell, onCellUpdate],
);
const handleCommit = useCallback(
(value: unknown) => {
handleValueChange(value);
setEditingCell(null);
},
[handleValueChange, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
pageId={pageId}
/>
);
}
if (!property) return null;
const CellComponent = getDescriptor(property.type)?.cellComponent;
if (!CellComponent) return null;
const value = cell.getValue();
const cellInner = (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={handleValueChange}
onCancel={handleCancel}
/>
{property.isPrimary && onExpandRow && !isEditing && (
<span className={classes.rowExpandAnchor}>
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
<button
type="button"
className={classes.rowExpandButton}
onClick={() => onExpandRow(rowId)}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
>
<IconArrowsDiagonal size={13} />
</button>
</Tooltip>
</span>
)}
</div>
);
if (property.type !== "formula") return cellInner;
const formulaEditorOpen =
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === rowId;
return (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>{cellInner}</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
);
},
gridCellPropsEqual);
// Cell instances are re-created whenever the table data identity changes;
// compare by coordinates + value so unchanged cells skip re-rendering.
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
if (
prev.rowIndex !== next.rowIndex ||
prev.pageId !== next.pageId ||
prev.onCellUpdate !== next.onCellUpdate
) {
return false;
}
if (prev.cell === next.cell) return true;
return (
prev.cell.row.id === next.cell.row.id &&
prev.cell.column.id === next.cell.column.id &&
prev.cell.column.columnDef.meta?.property ===
next.cell.column.columnDef.meta?.property &&
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
);
}
@@ -0,0 +1,391 @@
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import {
observeWindowOffset,
observeWindowRect,
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms";
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { GridGhostRows } from "./grid-ghost-rows";
import { SelectionActionBar } from "./selection-action-bar";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
// var(--base-row-height) from this constant so the rendered height can
// never drift from the virtualizer estimate.
const ROW_HEIGHT = 37;
const OVERSCAN = 25;
const GRID_ROOT_STYLE = {
"--base-row-height": `${ROW_HEIGHT}px`,
} as React.CSSProperties;
const ADD_COLUMN_TRACK_WIDTH = 40;
// Hoisted to module scope to avoid allocating a fresh options object on
// every GridContainer render. The function refs from virtual-core are
// stable; only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
pageId: string;
onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/** true when a view filter with at least one condition is active; suppresses ghost rows */
isFiltered?: boolean;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
* with the content while only the column-header row stays pinned. In
* inline mode BaseTable injects banner + toolbar here; standalone passes
* null (they render outside the scrollport instead).
*/
aboveBand?: React.ReactNode;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
pageId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
isFiltered,
scrollElement,
aboveBand,
}: GridContainerProps) {
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const rowsContainerRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
useGridAutoScroll(bodyRef, pageId);
useRowAutoScroll(scrollElement, pageId);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const rowIdsRef = useRef(rowIds);
rowIdsRef.current = rowIds;
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
const editable = useBaseEditable();
const [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 { deleteSelected } = useDeleteSelectedRows(pageId);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
// Only act while an inline cell editor is open. Popover-based cells
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
// This handler's sole job is to commit an inline input editor
// (text/number/url/email) when the user clicks elsewhere, since clicking
// a non-focusable cell does not natively blur the input. Gating on
// editingCell also stops it from stealing focus from unrelated inputs.
if (!editingCellRef.current) return;
const target = e.target as HTMLElement;
if (target.closest(`.${classes.headerCell}`)) return;
if (target.closest("[role=\"dialog\"]")) return;
if (target.closest("[role=\"listbox\"]")) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
if (target.closest(`.${classes.cellEditing}`)) return;
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
// which also clears editingCell. No setEditingCell(null) needed here.
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body && typeof active.blur === "function") {
active.blur();
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, []);
useColumnResize(table, onResizeEnd ?? (() => {}));
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
// have. Swap in the Window-mode observers so the virtualizer reads
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
// upcast on getScrollElement; virtual-core's runtime accepts Window when
// the observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Rows are positioned inside .rowsContainer, which sits below the sticky
// band (and aboveBand content) within the scroll content. scrollMargin =
// the container's offset from the scroll content top, in both modes, so
// virtual indexing lines up with what is actually on screen.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const el = rowsContainerRef.current;
if (!el || !scrollElement) return;
const update = () => {
const rect = el.getBoundingClientRect();
if (isWindowScroll) {
setScrollMargin(rect.top + window.scrollY);
} else {
const scrollport = scrollElement as HTMLElement;
setScrollMargin(
rect.top -
scrollport.getBoundingClientRect().top +
scrollport.scrollTop,
);
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) can move the
// grid without resizing it, so listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll, scrollElement]);
// Stable row-id keys: the direct-update element cache and measurement
// cache are keyed by item key, so index keys would go stale whenever rows
// are inserted or reordered above the viewport.
const getItemKey = useCallback(
(index: number) => rowIds[index] ?? index,
[rowIds],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
getItemKey,
directDomUpdates: true,
// 'position' (writes `top`), not 'transform': a transform on the row
// creates a containing block that breaks the position:sticky pinned
// cells inside it.
directDomUpdatesMode: "position",
...windowScrollOptions,
// virtual-core bug: on first attach _willUpdate calls
// _scrollToOffset(getScrollOffset()), which returns undefined when no
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
// mid-page. Seeding initialOffset to the current scroll position makes
// the first _scrollToOffset a no-op.
initialOffset: isWindowScroll
? () => window.scrollY
: () =>
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
});
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
useEffect(() => {
// When the row set shrinks (filter/sort/view change) or resets to zero,
// un-gate the trigger so the first page can trigger the next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
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();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return (
columnWidths.join(" ") +
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
);
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
const totalColumnsWidth = useMemo(
() =>
table
.getVisibleLeafColumns()
.reduce((sum, col) => sum + col.getSize(), 0) +
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
);
const showGhostRows = rows.length === 0 && !isFiltered;
// Append a flexible trailing track so every row spans the full width.
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
// fills remaining width otherwise. The header grid keeps the plain template.
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const getColumnOrder = useCallback(
() => table.getState().columnOrder,
[table],
);
return (
<div role="grid" style={GRID_ROOT_STYLE}>
{aboveBand}
<div className={classes.stickyBand}>
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<GridRowOrderProvider value={getOrderedRowIds}>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
style={
{
"--base-grid-cols": bodyGridTemplateColumns,
} as React.CSSProperties
}
>
<div
className={classes.rowsContainer}
ref={(node) => {
rowsContainerRef.current = node;
virtualizer.containerRef(node);
}}
role="rowgroup"
style={{ width: totalColumnsWidth, minWidth: "100%" }}
>
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
measureRef={virtualizer.measureElement}
onCellUpdate={onCellUpdate}
properties={properties}
columnVisibility={table.getState().columnVisibility}
columnOrder={table.getState().columnOrder}
pageId={pageId}
onRowReorder={onRowReorder}
/>
);
})}
</div>
{showGhostRows && (
<GridGhostRows
count={3}
columnCount={table.getVisibleLeafColumns().length}
onCreate={editable ? handleAddRow : undefined}
/>
)}
{editable && <AddRowButton onClick={handleAddRow} />}
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</GridRowOrderProvider>
</div>
);
}
@@ -0,0 +1,32 @@
import classes from "@/ee/base/styles/grid.module.css";
type GridGhostRowsProps = {
/** how many placeholder rows to render */
count: number;
/** number of visible leaf columns (incl. the row-number column) */
columnCount: number;
/** create the first real row (clicking any ghost cell); omit when read-only */
onCreate?: () => void;
};
// Empty-state ghost rows shown when no data rows exist and no filter is active.
// Clicking any ghost row creates the first real row; cells align via subgrid.
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
return (
<>
{Array.from({ length: count }).map((_, rowIdx) => (
<div
key={rowIdx}
className={`${classes.row} ${classes.ghostRow}`}
role={onCreate ? "button" : undefined}
aria-label={onCreate ? "Create first row" : undefined}
onClick={onCreate}
>
{Array.from({ length: columnCount }).map((_, colIdx) => (
<div key={colIdx} className={classes.cell} aria-hidden="true" />
))}
</div>
))}
</>
);
}
@@ -0,0 +1,338 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { Badge, Popover } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
import {
activePropertyMenuAtomFamily,
propertyMenuDirtyAtomFamily,
propertyMenuCloseRequestAtomFamily,
editingCellAtomFamily,
activeFormulaEditorAtomFamily,
FormulaEditorTarget,
} from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import classes from "@/ee/base/styles/grid.module.css";
export const COLUMN_DRAG_TYPE = "base-column";
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
pageId: string;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
pageId,
getColumnOrder,
onColumnReorder,
}: GridHeaderCellProps) {
const { t } = useTranslation();
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection(pageId);
const hasSelection = selectionCount > 0;
const editable = useBaseEditable();
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
activeFormulaEditorAtomFamily(pageId),
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const resizeIntentRef = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || !!isPinned || !editable;
// onColumnReorder ultimately depends on React Query result objects
// (activeView, base) via persistViewConfig, and their identity changes on
// every cache invalidation (every WS-driven collab refresh). Holding the
// callback in a ref keeps it out of the DnD effect's dep array so we don't
// tear down and re-register the pragmatic-dnd adapter on every header cell
// each time another user edits the base.
const onColumnReorderRef = useRef(onColumnReorder);
useLayoutEffect(() => {
onColumnReorderRef.current = onColumnReorder;
});
useEffect(() => {
const el = cellRef.current;
if (!el || isSortableDisabled) return;
return combine(
draggable({
element: el,
canDrag: () => !resizeIntentRef.current,
getInitialData: () => ({
type: COLUMN_DRAG_TYPE,
columnId: header.column.id,
pageId,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === COLUMN_DRAG_TYPE &&
source.data.columnId !== header.column.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ columnId: header.column.id },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
const order = getColumnOrder();
const startIndex = order.indexOf(source.data.columnId as string);
const indexOfTarget = order.indexOf(header.column.id);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
onColumnReorderRef.current?.(source.data.columnId as string, finishIndex);
triggerPostMoveFlash(el);
liveRegion.announce(`Moved column to position ${finishIndex + 1}`);
},
}),
);
}, [header.column.id, isSortableDisabled, getColumnOrder]);
const handleHeaderClick = useCallback(() => {
if (resizeIntentRef.current) {
resizeIntentRef.current = false;
return;
}
setEditingCell(null);
if (!editable) return;
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [editable, isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
const handleEditFormula = useCallback(() => {
if (!property) return;
handleMenuClose();
setActiveFormulaEditor({ propertyId: property.id, rowId: null });
}, [property, handleMenuClose, setActiveFormulaEditor]);
const closeFormulaEditor = useCallback(
() => setActiveFormulaEditor(null),
[setActiveFormulaEditor],
);
const formulaEditorOpen =
!!property &&
activeFormulaEditor?.propertyId === property.id &&
activeFormulaEditor?.rowId === null;
// A closed property menu can never hold unsaved changes. Saving a rename
// must clear propertyMenuDirty; otherwise it stays stuck true and
// handleHeaderClick refuses to reopen any property menu, making the menu
// appear dead after the first save. Reset only on the open-to-closed
// transition so a sibling header cell can't clear the flag while another
// column's menu is mid-edit.
const wasMenuOpenedRef = useRef(menuOpened);
useEffect(() => {
if (wasMenuOpenedRef.current && !menuOpened) {
setPropertyMenuDirty(false);
}
wasMenuOpenedRef.current = menuOpened;
}, [menuOpened, setPropertyMenuDirty]);
const handleMenuOpenChange = useCallback(
(next: boolean) => {
if (next) return; // opening is driven by the atom, not Mantine
if (propertyMenuDirty) {
// Veto the close and route through the discard-confirm flow.
setCloseRequest(closeRequest + 1);
} else {
handleMenuClose();
}
},
[propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose],
);
const TypeIcon = property ? getDescriptor(property.type)?.icon : undefined;
return (
<div
ref={cellRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber || !editable ? {} : { cursor: "pointer" }),
opacity: isDragging ? 0.4 : 1,
}}
onPointerDown={() => {
resizeIntentRef.current = false;
}}
onClick={handleHeaderClick}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{property?.pendingType && (
<Badge size="xs" color="gray" variant="light" ml={6}>
{t("Converting…")}
</Badge>
)}
</div>
)}
{editable && header.column.getCanResize() && (
<div
className={`${classes.resizeHandle} ${
header.column.getIsResizing() ? classes.resizeHandleActive : ""
}`}
onMouseDown={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onPointerDown={(e) => {
resizeIntentRef.current = true;
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{editable && property && !isRowNumber && (
<Popover
opened={menuOpened}
onChange={handleMenuOpenChange}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
trapFocus
withinPortal
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
onEditFormula={
property.type === "formula" ? handleEditFormula : undefined
}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
)}
{property && !isRowNumber && property.type === "formula" && (
<Popover
opened={formulaEditorOpen}
onChange={(o) => {
if (!o) closeFormulaEditor();
}}
position="bottom-start"
width={460}
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape={false}
trapFocus
>
<Popover.Target>
<div className={classes.popoverAnchor} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
closeFormulaEditor();
}
}}
style={{ maxWidth: "calc(100vw - 32px)" }}
>
{formulaEditorOpen && (
<FormulaPropertyEditor
property={property}
pageId={pageId}
onClose={closeFormulaEditor}
/>
)}
</Popover.Dropdown>
</Popover>
)}
</div>
);
});
@@ -0,0 +1,64 @@
import { memo, useMemo } from "react";
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import classes from "@/ee/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
pageId: string;
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
properties: IBaseProperty[];
loadedRowIds: string[];
onPropertyCreated?: () => void;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
};
export const GridHeader = memo(function GridHeader({
table,
pageId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnOrder: _columnOrder,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnVisibility: _columnVisibility,
properties,
loadedRowIds,
onPropertyCreated,
getColumnOrder,
onColumnReorder,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
const editable = useBaseEditable();
const propertyById = useMemo(() => {
const map = new Map<string, IBaseProperty>();
for (const p of properties) map.set(p.id, p);
return map;
}, [properties]);
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell
key={header.id}
header={header}
property={propertyById.get(header.column.id)}
loadedRowIds={loadedRowIds}
pageId={pageId}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
))}
{editable && (
<CreatePropertyPopover
pageId={pageId}
properties={properties}
onPropertyCreated={onPropertyCreated}
/>
)}
</div>
);
});
@@ -0,0 +1,195 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Row, VisibilityState } from "@tanstack/react-table";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { GridCell } from "./grid-cell";
import classes from "@/ee/base/styles/grid.module.css";
export const ROW_DRAG_TYPE = "base-row";
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
measureRef: (node: Element | null) => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onRowReorder?: (
rowId: string,
targetRowId: string,
position: "above" | "below",
) => void;
properties: IBaseProperty[];
columnVisibility: VisibilityState;
columnOrder: string[];
pageId: string;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
measureRef,
onCellUpdate,
onRowReorder,
pageId,
}: GridRowProps) {
const rowId = row.id;
const isSelected = useRowSelection(pageId).isSelected(rowId);
const rowRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const setRowEl = useCallback(
(node: HTMLDivElement | null) => {
rowRef.current = node;
measureRef(node);
},
[measureRef],
);
// onRowReorder ultimately depends on React Query result objects (activeView,
// base) via persistViewConfig, and its identity changes on every WS-driven
// cache invalidation. Holding it in a ref keeps it out of the DnD effect's
// dep array so we don't tear down and re-register every row's pragmatic-dnd
// adapter each time another user edits the base. Same pattern as the column
// header's onColumnReorderRef.
const onRowReorderRef = useRef(onRowReorder);
useLayoutEffect(() => {
onRowReorderRef.current = onRowReorder;
});
useEffect(() => {
const rowEl = rowRef.current;
if (!rowEl || !onRowReorder) return;
// The whole row is the draggable element (full-row native preview).
// dragHandle limits initiation to the grip, leaving cell clicks and
// inline editing untouched.
const handle = rowEl.querySelector<HTMLElement>(
`.${classes.rowNumberDragHandle}`,
);
if (!handle) return;
return combine(
draggable({
element: rowEl,
dragHandle: handle,
getInitialData: () => ({ type: ROW_DRAG_TYPE, rowId, pageId }),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
// Native preview of the full-width sticky subgrid row rasterizes
// garbled (it pulls in surrounding page paint, e.g. the sidebar).
// Render a compact card that clones just the title cell instead.
const titleCell =
rowEl.querySelector<HTMLElement>(`.${classes.primaryCell}`) ??
rowEl.querySelector<HTMLElement>(`.${classes.cell}`);
if (!titleCell) return;
const width = titleCell.getBoundingClientRect().width;
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
render: ({ container }) => {
const card = document.createElement("div");
card.className = classes.rowDragPreview;
card.style.width = `${width}px`;
const clone = titleCell.cloneNode(true) as HTMLElement;
clone.style.position = "static";
clone.style.left = "auto";
clone.style.width = "100%";
clone.style.opacity = "1";
clone.style.borderRight = "none";
card.appendChild(clone);
container.appendChild(card);
},
});
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: rowEl,
canDrop: ({ source }) =>
source.data.type === ROW_DRAG_TYPE &&
source.data.pageId === pageId &&
source.data.rowId !== rowId,
getData: ({ input, element }) =>
attachClosestEdge(
{ rowId },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onRowReorderRef.current?.(
source.data.rowId as string,
rowId,
edge === "top" ? "above" : "below",
);
triggerPostMoveFlash(rowEl);
liveRegion.announce("Moved row");
},
}),
);
// onRowReorder is read through onRowReorderRef; only its presence gates
// registration, and that does not change across a row's mounted life.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowId, pageId]);
const dropIndicatorClass = closestEdge
? closestEdge === "top"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
ref={setRowEl}
data-index={rowIndex}
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
>
{row.getVisibleCells().map((cell) => (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
onCellUpdate={onCellUpdate}
pageId={pageId}
/>
))}
</div>
);
},
gridRowPropsEqual);
// row compares by row.original: React Query structural sharing keeps
// unchanged rows reference-stable, while TanStack re-instantiates Row/Cell
// wrappers on every data change. properties/columnVisibility/columnOrder are
// layout busters — schema or column-state changes must re-render rows.
function gridRowPropsEqual(prev: GridRowProps, next: GridRowProps) {
return (
prev.row.id === next.row.id &&
prev.row.original === next.row.original &&
prev.rowIndex === next.rowIndex &&
prev.pageId === next.pageId &&
prev.onCellUpdate === next.onCellUpdate &&
prev.onRowReorder === next.onRowReorder &&
prev.measureRef === next.measureRef &&
prev.properties === next.properties &&
prev.columnVisibility === next.columnVisibility &&
prev.columnOrder === next.columnOrder
);
}
@@ -0,0 +1,70 @@
import { memo, useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberCellProps = {
rowId: string;
rowIndex: number;
isPinned: boolean;
pinOffset?: number;
pageId: string;
};
export const RowNumberCell = memo(function RowNumberCell({
rowId,
rowIndex,
isPinned,
pinOffset,
pageId,
}: RowNumberCellProps) {
const { isSelected, toggle } = useRowSelection(pageId);
const selected = isSelected(rowId);
const editable = useBaseEditable();
const getOrderedRowIds = useGridRowOrder();
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds: getOrderedRowIds(),
});
},
[rowId, rowIndex, getOrderedRowIds, toggle],
);
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
>
<div className={classes.rowNumberCellInner}>
{editable && (
<span className={classes.rowNumberDragHandle} aria-label="Drag row">
<IconGripVertical size={12} />
</span>
)}
{editable && (
<span className={classes.rowNumberCheckbox}>
<Checkbox
size="xs"
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
/>
</span>
)}
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
</div>
</div>
);
});
@@ -0,0 +1,50 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import classes from "@/ee/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
pageId: string;
};
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
loadedRowIds,
pageId,
}: RowNumberHeaderCellProps) {
const { selectedIds, toggleAll } = useRowSelection(pageId);
const { checked, indeterminate } = useMemo(() => {
if (loadedRowIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedInLoaded = loadedRowIds.reduce(
(acc, id) => (selectedIds.has(id) ? acc + 1 : acc),
0,
);
return {
checked: selectedInLoaded === loadedRowIds.length,
indeterminate:
selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length,
};
}, [loadedRowIds, selectedIds]);
if (loadedRowIds.length === 0) return null;
return (
<div className={classes.rowNumberHeaderInner}>
<span className={classes.rowNumberHeaderHash}>#</span>
<span className={classes.rowNumberHeaderCheckbox}>
<Tooltip label="Select all loaded rows" withinPortal>
<Checkbox
size="xs"
checked={checked}
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
/>
</Tooltip>
</span>
</div>
);
});
@@ -0,0 +1,52 @@
import { memo } from "react";
import { Transition } from "@mantine/core";
import { IconTrash, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
import classes from "@/ee/base/styles/grid.module.css";
type SelectionActionBarProps = {
pageId: string;
};
export const SelectionActionBar = memo(function SelectionActionBar({
pageId,
}: SelectionActionBarProps) {
const { t } = useTranslation();
const { selectionCount, clear } = useRowSelection(pageId);
const { deleteSelected, isPending } = useDeleteSelectedRows(pageId);
const isOpen = selectionCount > 0;
return (
<Transition mounted={isOpen} transition="slide-up" duration={150}>
{(styles) => (
<div className={classes.selectionActionBarWrapper} style={styles}>
<div className={classes.selectionActionBar} role="toolbar">
<span className={classes.selectionActionBarCount}>
{t("{{count}} selected", { count: selectionCount })}
</span>
<button
type="button"
className={classes.selectionActionBarDelete}
disabled={isPending}
onClick={() => void deleteSelected()}
>
<IconTrash size={14} />
{t("Delete")}
</button>
<button
type="button"
className={classes.selectionActionBarClose}
onClick={clear}
aria-label={t("Clear selection")}
>
<IconX size={14} />
</button>
</div>
</div>
)}
</Transition>
);
});
@@ -0,0 +1,673 @@
import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from "react";
import {
TextInput,
Group,
Stack,
Text,
Button,
Popover,
SimpleGrid,
UnstyledButton,
CloseButton,
Divider,
} from "@mantine/core";
import {
IconPlus,
IconGripVertical,
IconArrowsSort,
} from "@tabler/icons-react";
import classes from "@/ee/base/styles/property.module.css";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
import { DefaultValuePicker } from "./default-value-picker";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORIES = [
{ value: "todo", label: "To Do" },
{ value: "inProgress", label: "In Progress" },
{ value: "complete", label: "Complete" },
] as const;
// Default choices for a new status property, one per category.
export function defaultStatusChoices(): Choice[] {
return [
{ id: 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" },
];
}
function pruneDefault(
value: string | string[] | null,
choices: Choice[],
): string | string[] | null {
if (value === null) return null;
const ids = new Set(choices.map((c) => c.id));
if (Array.isArray(value)) {
const live = value.filter((id) => ids.has(id));
return live.length ? live : null;
}
return ids.has(value) ? value : null;
}
function defaultsEqual(
a: string | string[] | null,
b: string | string[] | null,
): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
return a === b;
}
type ChoiceEditorProps = {
initialChoices: Choice[];
onSave: (choices: Choice[], defaultValue: string | string[] | null) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
hideButtons?: boolean;
initialDefaultValue?: string | string[] | null;
multiDefault?: boolean;
/**
* Where the per-choice color-picker popover portals. Pass the enclosing
* property-menu dropdown node so the picker renders INSIDE that subtree
* otherwise a color click registers as "outside" and closes the menu.
*/
dropdownPortalTarget?: HTMLElement | null;
};
export function ChoiceEditor({
initialChoices,
onSave,
onClose,
onDirtyChange,
showCategories = false,
hideButtons = false,
initialDefaultValue = null,
multiDefault = false,
dropdownPortalTarget,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Choice[]>(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
const [defaultDraft, setDefaultDraft] = useState<string | string[] | null>(
initialDefaultValue,
);
useEffect(() => {
if (!hideButtons) {
setDraft(initialChoices);
setDefaultDraft(initialDefaultValue);
}
}, [initialChoices, initialDefaultValue, hideButtons]);
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
if (hideButtons) {
const cleaned = draft.filter((c) => c.name.trim());
onSaveRef.current(cleaned, pruneDefault(defaultDraft, cleaned));
}
}, [hideButtons, draft, defaultDraft]);
const isDirty = useMemo(() => {
if (!defaultsEqual(defaultDraft, initialDefaultValue)) return true;
if (draft.length !== initialChoices.length) return true;
return draft.some((d, i) => {
const o = initialChoices[i];
return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
});
}, [draft, initialChoices, defaultDraft, initialDefaultValue]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const hasEmptyNames = draft.some((c) => !c.name.trim());
const handleRename = useCallback((choiceId: string, name: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
}, []);
const handleColorChange = useCallback((choiceId: string, color: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
}, []);
const handleRemove = useCallback((choiceId: string) => {
setDraft((prev) => prev.filter((c) => c.id !== choiceId));
setDefaultDraft((prev) => {
if (prev === null) return prev;
if (Array.isArray(prev)) {
const next = prev.filter((id) => id !== choiceId);
return next.length ? next : null;
}
return prev === choiceId ? null : prev;
});
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = uuid7();
setDraft((prev) => {
const colorIndex = prev.length % CHOICE_COLORS.length;
const newChoice: Choice = {
id,
name: "",
color: CHOICE_COLORS[colorIndex],
...(category ? { category } : {}),
};
return [...prev, newChoice];
});
setFocusChoiceId(id);
}, []);
const handleAlphabetize = useCallback(() => {
setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
}, []);
const handleSave = useCallback(() => {
const cleaned = draft.filter((c) => c.name.trim());
onSave(cleaned, pruneDefault(defaultDraft, cleaned));
onClose();
}, [draft, defaultDraft, onSave, onClose]);
const handleCancel = useCallback(() => {
setDraft(initialChoices);
setDefaultDraft(initialDefaultValue);
onDirtyChange?.(false);
onClose();
}, [initialChoices, initialDefaultValue, onDirtyChange, onClose]);
const handleReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
setDraft((prev) => {
const startIndex = prev.findIndex((c) => c.id === activeId);
const indexOfTarget = prev.findIndex((c) => c.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return prev;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return prev;
return reorder({ list: prev, startIndex, finishIndex });
});
},
[],
);
const handleCategoryReorder = useCallback(
(category: string, activeId: string, targetId: string, edge: Edge) => {
setDraft((prev) => {
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
const startIndex = catChoices.findIndex((c) => c.id === activeId);
const indexOfTarget = catChoices.findIndex((c) => c.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return prev;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return prev;
const reordered = reorder({
list: catChoices,
startIndex,
finishIndex,
});
const result: Choice[] = [];
for (const cat of ["todo", "inProgress", "complete"]) {
if (cat === category) {
result.push(...reordered);
} else {
result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
}
}
return result;
});
},
[],
);
return (
<Stack gap="xs">
<Group justify="space-between">
<Text size="xs" fw={600}>
{t("Options")}
</Text>
<UnstyledButton onClick={handleAlphabetize} className={classes.alphabetizeBtn}>
<IconArrowsSort size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Alphabetize")}</Text>
</UnstyledButton>
</Group>
{showCategories ? (
<StatusChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onCategoryReorder={handleCategoryReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
) : (
<FlatChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onReorder={handleReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
)}
<DefaultValuePicker
choices={draft.filter((c) => c.name.trim())}
value={defaultDraft}
multiple={multiDefault}
onChange={setDefaultDraft}
dropdownPortalTarget={dropdownPortalTarget}
/>
{!hideButtons && (
<>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
{t("Save")}
</Button>
</Group>
</>
)}
</Stack>
);
}
function FlatChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
dropdownPortalTarget,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: () => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const { t } = useTranslation();
return (
<Stack gap={4}>
{draft.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
dragType="base-choice-flat"
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onReorder={onReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
<UnstyledButton
onClick={() => onAdd()}
className={classes.addOptionBtn}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function StatusChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onCategoryReorder,
dropdownPortalTarget,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onCategoryReorder: (category: string, activeId: string, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const grouped = useMemo(() => {
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
for (const choice of draft) {
const cat = choice.category ?? "todo";
(groups[cat] ?? groups.todo).push(choice);
}
return groups;
}, [draft]);
return (
<Stack gap="sm">
{STATUS_CATEGORIES.map(({ value: category, label }) => (
<CategorySection
key={category}
category={category as "todo" | "inProgress" | "complete"}
label={label}
choices={grouped[category] ?? []}
focusChoiceId={focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onAdd={onAdd}
onReorder={onCategoryReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
</Stack>
);
}
function CategorySection({
category,
label,
choices,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
dropdownPortalTarget,
}: {
category: "todo" | "inProgress" | "complete";
label: string;
choices: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onReorder: (
category: string,
activeId: string,
targetId: string,
edge: Edge,
) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const { t } = useTranslation();
const handleRowReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
onReorder(category, activeId, targetId, edge);
},
[category, onReorder],
);
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t(label)}
</Text>
{choices.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
// Per-category drag type prevents cross-category drops.
dragType={`base-choice-status:${category}`}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onReorder={handleRowReorder}
dropdownPortalTarget={dropdownPortalTarget}
/>
))}
<UnstyledButton
onClick={() => onAdd(category)}
className={classes.addOptionBtn}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function SortableChoiceRow({
choice,
dragType,
autoFocus,
onFocused,
onRename,
onColorChange,
onRemove,
onReorder,
dropdownPortalTarget,
}: {
choice: Choice;
dragType: string;
autoFocus?: boolean;
onFocused?: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
// Stable ref so the DnD effect doesn't re-register on every parent render.
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus();
onFocused?.();
}
}, [autoFocus, onFocused]);
useEffect(() => {
const row = rowRef.current;
const handle = handleRef.current;
if (!row || !handle) return;
return combine(
draggable({
element: row,
dragHandle: handle,
getInitialData: () => ({ type: dragType, choiceId: choice.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: row,
canDrop: ({ source }) =>
source.data.type === dragType &&
source.data.choiceId !== choice.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ choiceId: choice.id },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(
source.data.choiceId as string,
choice.id,
edge,
);
triggerPostMoveFlash(row);
liveRegion.announce("Moved option");
},
}),
);
}, [choice.id, dragType]);
const hasError = !choice.name.trim();
return (
<Group
ref={rowRef}
gap={6}
wrap="nowrap"
align="center"
style={{
position: "relative",
opacity: isDragging ? 0.4 : 1,
}}
data-dragging={isDragging || undefined}
>
<div ref={handleRef} className={classes.dragHandle}>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<ColorDot
color={choice.color}
onChange={(c) => onColorChange(choice.id, c)}
dropdownPortalTarget={dropdownPortalTarget}
/>
<TextInput
ref={inputRef}
size="xs"
value={choice.name}
onChange={(e) => onRename(choice.id, e.currentTarget.value)}
style={{ flex: 1 }}
error={hasError}
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
/>
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</Group>
);
}
function ColorDot({
color,
onChange,
dropdownPortalTarget,
}: {
color: string;
onChange: (color: string) => void;
dropdownPortalTarget?: HTMLElement | null;
}) {
const [opened, setOpened] = useState(false);
const colors = choiceColor(color);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
shadow="sm"
withinPortal
portalProps={{ target: dropdownPortalTarget ?? undefined }}
>
<Popover.Target>
<UnstyledButton
onClick={() => setOpened((o) => !o)}
style={{
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
</Popover.Target>
<Popover.Dropdown p={8}>
<SimpleGrid cols={5} spacing={6}>
{CHOICE_COLORS.map((c) => {
const dotColors = choiceColor(c);
return (
<UnstyledButton
key={c}
onClick={() => {
onChange(c);
setOpened(false);
}}
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: dotColors.backgroundColor as string,
border: c === color
? `2px solid ${dotColors.color as string}`
: "2px solid transparent",
}}
/>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,127 @@
import type { BasePropertyType } from "@/ee/base/types/base.types";
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
"createdAt",
"lastEditedAt",
"lastEditedBy",
"formula",
]);
type ConversionInfo = {
// i18n source key (translation files key them by their exact text).
message: string;
// True when cells can be cleared, discarded, truncated, or have their
// structured value flattened, i.e. the change is not safely reversible.
// Drives the destructive (red) "Apply" button in the confirm panel.
lossy: boolean;
};
// Buckets ordered most-specific first; default covers safe reinterpretations.
function describeConversion(
from: BasePropertyType,
to: BasePropertyType,
): ConversionInfo {
if (to === "text" || to === "longText") {
if (from === "longText" && to === "text") {
return {
message:
"Cells longer than the Text limit will be truncated and the extra content permanently lost.",
lossy: true,
};
}
if (from === "select" || from === "status") {
return { message: "Cells will be replaced with the option name.", lossy: true };
}
if (from === "multiSelect") {
return {
message:
"Cells will be replaced with a comma-separated list of option names.",
lossy: true,
};
}
if (from === "person") {
return { message: "Cells will be replaced with the person's name.", lossy: true };
}
if (from === "file") {
return {
message:
"Cells will be replaced with a comma-separated list of file names.",
lossy: true,
};
}
if (from === "page") {
return { message: "Cells will be replaced with the page title.", lossy: true };
}
}
if (to === "select" && from === "multiSelect") {
return {
message:
"Only the first selected item per row will be kept; the rest will be discarded.",
lossy: true,
};
}
if (to === "multiSelect" && from === "select") {
return {
message: "Existing values become single-item lists. No data is lost.",
lossy: false,
};
}
if (to === "page") {
return {
message: "Cells that aren't already a page reference will be cleared.",
lossy: true,
};
}
if (to === "number" && from !== "number") {
return {
message: "Cells that can't be parsed as a number will be cleared.",
lossy: true,
};
}
if (to === "date" && from !== "date") {
return {
message: "Cells that can't be parsed as a date will be cleared.",
lossy: true,
};
}
if (to === "checkbox" && from !== "checkbox") {
return {
message:
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
lossy: true,
};
}
if ((to === "url" || to === "email") && from !== to) {
return {
message:
to === "url"
? "Cells that aren't a valid URL will be cleared."
: "Cells that aren't a valid email address will be cleared.",
lossy: true,
};
}
return { message: "Cells will be reinterpreted under the new type.", lossy: false };
}
export function conversionWarning(
from: BasePropertyType,
to: BasePropertyType,
): string {
return describeConversion(from, to).message;
}
// Whether the type change can lose data, used to make "Apply" destructive.
export function isLossyConversion(
from: BasePropertyType,
to: BasePropertyType,
): boolean {
return describeConversion(from, to).lossy;
}
@@ -0,0 +1,388 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Popover,
TextInput,
Button,
Group,
Stack,
Divider,
UnstyledButton,
Text,
ScrollArea,
} from "@mantine/core";
import { IconPlus, IconChevronRight } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
BasePropertyType,
IBaseProperty,
TypeOptions,
} from "@/ee/base/types/base.types";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
getDescriptor,
defaultTypeOptionsFor,
} from "@/ee/base/property-types/property-type.registry";
import { FormulaEditor } from "../formula/formula-editor";
import classes from "@/ee/base/styles/grid.module.css";
type CreatePropertyPopoverProps = {
pageId: string;
properties?: IBaseProperty[];
onPropertyCreated?: () => void;
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
* Defaults to the grid's + column button. */
renderTarget?: (open: () => void) => React.ReactElement;
};
type Panel = "typePicker" | "configure" | "confirmDiscard";
const noop = () => {};
export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: CreatePropertyPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("typePicker");
const [selectedType, setSelectedType] = useState<BasePropertyType | null>(null);
const [name, setName] = useState("");
const [typeOptions, setTypeOptions] = useState<Record<string, unknown>>({});
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const createPropertyMutation = useCreatePropertyMutation();
const selectedTypeDef = useMemo(
() => propertyTypes.find((pt) => pt.type === selectedType),
[selectedType],
);
const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
const selectedTypeIcon = selectedTypeDef?.icon;
const hasContent = useMemo(() => {
return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
}, [name, typeOptions]);
const nameTaken = useMemo(() => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return false;
return (properties ?? []).some(
(p) => p.name.trim().toLowerCase() === trimmed,
);
}, [name, properties]);
// Fall back to the type label when Name is blank, suffixing a counter if taken.
const fallbackName = useMemo(() => {
const base = selectedTypeLabel || "Property";
const existing = new Set(
(properties ?? []).map((p) => p.name.trim().toLowerCase()),
);
if (!existing.has(base.toLowerCase())) return base;
for (let i = 1; i < 1000; i++) {
const candidate = `${base} ${i}`;
if (!existing.has(candidate.toLowerCase())) return candidate;
}
return `${base} ${Date.now()}`;
}, [selectedTypeLabel, properties]);
const resetState = useCallback(() => {
setPanel("typePicker");
setSelectedType(null);
setName("");
setTypeOptions({});
}, []);
const handleOpen = useCallback(() => {
resetState();
setOpened(true);
}, [resetState]);
const handleClose = useCallback(() => {
// Don't reset state here: resetting mid-close flashes the type picker.
// handleOpen resets on the next open instead.
setOpened(false);
}, []);
const attemptClose = useCallback(() => {
if (panel === "configure" && hasContent) {
setPanel("confirmDiscard");
} else {
handleClose();
}
}, [panel, hasContent, handleClose]);
const handleConfirmDiscard = useCallback(() => {
handleClose();
}, [handleClose]);
const handleCancelDiscard = useCallback(() => {
setPanel("configure");
}, []);
const handleTypeSelect = useCallback((type: BasePropertyType) => {
setSelectedType(type);
setTypeOptions(defaultTypeOptionsFor(type));
setPanel("configure");
}, []);
useEffect(() => {
if (panel === "configure") {
setTimeout(() => nameInputRef.current?.focus(), 0);
}
}, [panel]);
const handleCreate = useCallback(() => {
if (!selectedType || nameTaken) return;
const finalName = name.trim() || fallbackName;
createPropertyMutation.mutate(
{
pageId,
name: finalName,
type: selectedType,
typeOptions: Object.keys(typeOptions).length > 0
? typeOptions as TypeOptions
: undefined,
},
{
onSuccess: () => {
onPropertyCreated?.();
},
},
);
handleClose();
}, [selectedType, nameTaken, name, fallbackName, typeOptions, pageId, createPropertyMutation, handleClose, onPropertyCreated]);
const handleBackToTypePicker = useCallback(() => {
setPanel("typePicker");
setTypeOptions({});
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (panel === "confirmDiscard") {
handleCancelDiscard();
} else if (panel === "configure") {
handleBackToTypePicker();
} else {
handleClose();
}
}
},
[panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
);
const handleNameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
}
},
[handleCreate],
);
const handleOptionsUpdate = useCallback(
(newTypeOptions: Record<string, unknown>) => {
setTypeOptions(newTypeOptions);
},
[],
);
const syntheticProperty: IBaseProperty = useMemo(() => ({
id: "",
pageId,
name: name || "",
type: selectedType ?? "text",
position: "",
typeOptions: typeOptions as TypeOptions,
isPrimary: false,
workspaceId: "",
createdAt: "",
updatedAt: "",
}), [pageId, name, selectedType, typeOptions]);
const TypeIcon = selectedTypeIcon;
const showOptions = !!selectedType && (getDescriptor(selectedType)?.hasOptions ?? false);
return (
<>
<Popover
opened={opened}
onChange={(o) => {
if (!o) attemptClose();
}}
position="bottom-start"
shadow="md"
closeOnClickOutside
closeOnEscape={false}
withinPortal
>
<Popover.Target>
{renderTarget ? (
renderTarget(handleOpen)
) : (
<div
className={classes.addColumnButton}
onClick={handleOpen}
role="button"
tabIndex={0}
>
<IconPlus size={16} />
</div>
)}
</Popover.Target>
<Popover.Dropdown
ref={setDropdownNode}
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
style={{
zIndex: 300,
width: selectedType === "formula" ? 460 : undefined,
minWidth: selectedType === "formula" ? undefined : 320,
maxWidth: "calc(100vw - 32px)",
}}
>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<ScrollArea.Autosize
mah="min(60vh, 400px)"
scrollbarSize={6}
offsetScrollbars
>
<PropertyTypePicker
onSelect={handleTypeSelect}
showSearch
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "configure" && selectedType === "formula" && (
<Stack gap="xs" p="sm">
<TextInput
ref={nameInputRef}
size="xs"
label={t("Name")}
placeholder={fallbackName}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
error={nameTaken ? t("A property with this name already exists") : undefined}
/>
<FormulaEditor
properties={properties ?? []}
editingPropertyId={null}
name={name.trim() || undefined}
onCancel={handleBackToTypePicker}
disabled={nameTaken}
onSave={(source, ast, resultType, dependencies) => {
if (nameTaken) return;
createPropertyMutation.mutate(
{
pageId,
name: name.trim() || fallbackName,
type: "formula",
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
},
{ onSuccess: () => onPropertyCreated?.() },
);
handleClose();
}}
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<TextInput
ref={nameInputRef}
size="xs"
label={t("Name")}
placeholder={fallbackName}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={handleNameKeyDown}
error={nameTaken ? t("A property with this name already exists") : undefined}
mb="xs"
/>
<UnstyledButton
onClick={handleBackToTypePicker}
py={6}
px={0}
mb={showOptions ? "xs" : 0}
>
<Group gap={8} wrap="nowrap">
{TypeIcon && <TypeIcon size={14} />}
<Text size="sm" style={{ flex: 1 }}>
{selectedTypeLabel}
</Text>
<IconChevronRight size={14} />
</Group>
</UnstyledButton>
{showOptions && (
<>
<Divider mb="xs" />
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
<PropertyOptions
property={syntheticProperty}
onUpdate={handleOptionsUpdate}
onClose={noop}
onDirtyChange={noop}
hideButtons
dropdownPortalTarget={dropdownNode}
/>
</ScrollArea.Autosize>
</>
)}
<Divider my="xs" />
<Group gap="xs" justify="flex-end">
<Button variant="default" size="xs" onClick={attemptClose}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleCreate} disabled={nameTaken}>
{t("Create property")}
</Button>
</Group>
</Stack>
)}
{panel === "confirmDiscard" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Unsaved changes")}
</Text>
<Text size="xs" c="dimmed">
{t("You have unsaved changes. Do you want to discard them?")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={handleCancelDiscard}
>
{t("Keep editing")}
</Button>
<Button
color="red"
size="xs"
onClick={handleConfirmDiscard}
>
{t("Discard")}
</Button>
</Group>
</Stack>
)}
</Popover.Dropdown>
</Popover>
</>
);
}
@@ -0,0 +1,98 @@
import { Group, MultiSelect, Select, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { Choice } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import type { ComboboxItem } from "@mantine/core";
type DefaultValuePickerProps = {
choices: Choice[];
value: string | string[] | null;
multiple?: boolean;
onChange: (value: string | string[] | null) => void;
dropdownPortalTarget?: HTMLElement | null;
};
export function DefaultValuePicker({
choices,
value,
multiple,
onChange,
dropdownPortalTarget,
}: DefaultValuePickerProps) {
const { t } = useTranslation();
const data = choices.map((c) => ({ value: c.id, label: c.name }));
const comboboxProps = {
portalProps: { target: dropdownPortalTarget ?? undefined },
};
const renderOption = ({
option,
checked,
}: {
option: ComboboxItem;
checked?: boolean;
}) => {
const choice = choices.find((c) => c.id === option.value);
const colors = choice ? choiceColor(choice.color) : undefined;
return (
<Group gap={6} wrap="nowrap" justify="space-between" style={{ flex: 1 }}>
<Group gap={6} wrap="nowrap">
{colors && (
<span
style={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
)}
<Text size="xs">{option.label}</Text>
</Group>
{checked && (
<IconCheck size={14} color="var(--mantine-color-dimmed)" />
)}
</Group>
);
};
if (multiple) {
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
label={t("Default value")}
placeholder={selected.length ? undefined : t("None")}
data={data}
value={selected}
onChange={(vals) => onChange(vals.length ? vals : null)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
const single =
typeof value === "string" && choices.some((c) => c.id === value)
? value
: null;
return (
<Select
size="xs"
label={t("Default value")}
placeholder={t("None")}
data={data}
value={single}
onChange={(val) => onChange(val)}
clearable
comboboxProps={comboboxProps}
renderOption={renderOption}
/>
);
}
@@ -0,0 +1,564 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
UnstyledButton,
TextInput,
Button,
Stack,
Text,
Group,
ActionIcon,
Divider,
ScrollArea,
Loader,
} from "@mantine/core";
import {
IconTrash,
IconPencil,
IconChevronRight,
IconSettings,
IconMathFunction,
} from "@tabler/icons-react";
import {
IBaseProperty,
BasePropertyType,
} from "@/ee/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/ee/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import {
conversionWarning,
isLossyConversion,
NON_USER_TARGET_TYPES,
} from "./conversion-warning";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } 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";
type PropertyMenuContentProps = {
property: IBaseProperty;
opened: boolean;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void;
pageId: string;
};
type MenuPanel =
| "main"
| "rename"
| "options"
| "changeType"
| "confirmTypeChange"
| "confirmDelete"
| "confirmDiscard";
export function PropertyMenuContent({
property,
opened,
onClose,
onDirtyChange,
onEditFormula,
pageId,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>("main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
const [optionsAnchor, setOptionsAnchor] = useState<HTMLDivElement | null>(null);
const [pendingTargetType, setPendingTargetType] = useState<BasePropertyType | null>(null);
const pendingActionRef = useRef<"back" | "close" | null>(null);
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number];
const closeRequestRef = useRef(closeRequest);
const renameDirty = renameValue !== property.name;
const updatePropertyMutation = useUpdatePropertyMutation();
const deletePropertyMutation = useDeletePropertyMutation();
useEffect(() => {
if (opened) {
setPanel("main");
setRenameValue(property.name);
setOptionsDirty(false);
setPendingTargetType(null);
}
}, [opened, property.name]);
useEffect(() => {
if (panel === "rename") {
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [panel]);
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
setOptionsDirty(dirty);
}, []);
useEffect(() => {
const dirty =
(panel === "rename" && renameDirty) ||
(panel === "options" && optionsDirty);
onDirtyChange?.(dirty);
}, [panel, renameDirty, optionsDirty, onDirtyChange]);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== property.name) {
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
name: trimmed,
});
}
}, [renameValue, property, updatePropertyMutation]);
const handleRenameAndClose = useCallback(() => {
commitRename();
onClose();
}, [commitRename, onClose]);
const requestClose = useCallback(() => {
if (panel === "rename" && renameDirty) {
sourcePanelRef.current = "rename";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else if (panel === "options" && optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else {
onClose();
}
}, [panel, renameDirty, optionsDirty, onClose]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
handleRenameAndClose();
}
if (e.key === "Escape") {
e.preventDefault();
requestClose();
}
},
[handleRenameAndClose, requestClose],
);
const handleOptionsUpdate = useCallback(
(typeOptions: Record<string, unknown>) => {
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
typeOptions,
});
setOptionsDirty(false);
},
[property, updatePropertyMutation],
);
const handleTypeSelect = useCallback(
(type: BasePropertyType) => {
if (type === property.type) {
onClose();
return;
}
setPendingTargetType(type);
setPanel("confirmTypeChange");
},
[property.type, onClose],
);
const handleApplyTypeChange = useCallback(() => {
if (!pendingTargetType) return;
updatePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
type: pendingTargetType,
typeOptions: {},
});
onClose();
}, [
pendingTargetType,
property.id,
property.pageId,
updatePropertyMutation,
onClose,
]);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
propertyId: property.id,
pageId: property.pageId,
});
onClose();
}, [property, deletePropertyMutation, onClose]);
const handleOptionsBack = useCallback(() => {
if (optionsDirty) {
sourcePanelRef.current = "options";
pendingActionRef.current = "back";
setPanel("confirmDiscard");
} else {
setPanel("main");
}
}, [optionsDirty]);
useEffect(() => {
if (closeRequest !== closeRequestRef.current) {
closeRequestRef.current = closeRequest;
if (opened) {
requestClose();
}
}
}, [closeRequest, opened, requestClose]);
const handleConfirmDiscard = useCallback(() => {
setOptionsDirty(false);
setRenameValue(property.name);
const action = pendingActionRef.current;
pendingActionRef.current = null;
sourcePanelRef.current = null;
if (action === "back") {
setPanel("main");
} else {
onClose();
}
}, [property.name, onClose]);
const handleCancelDiscard = useCallback(() => {
const source = sourcePanelRef.current ?? "options";
pendingActionRef.current = null;
sourcePanelRef.current = null;
setPanel(source);
}, []);
return (
<>
{panel === "main" && (
<MainPanel
property={property}
onRename={() => setPanel("rename")}
onChangeType={() => setPanel("changeType")}
onOptions={() => setPanel("options")}
onDelete={() => setPanel("confirmDelete")}
onEditFormula={onEditFormula}
/>
)}
{panel === "rename" && (
<Stack gap="xs" p="sm">
<Text size="xs" fw={600} c="dimmed">
{t("Rename property")}
</Text>
<TextInput
ref={renameInputRef}
size="xs"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={handleRenameKeyDown}
/>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={requestClose}>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={handleRenameAndClose}
disabled={!renameValue.trim() || renameValue.trim() === property.name}
>
{t("Save")}
</Button>
</Group>
</Stack>
)}
{panel === "changeType" && (
<Stack gap={0} p={4}>
<Group gap="xs" px="sm" py={6}>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => setPanel("main")}
>
<IconChevronRight
size={14}
className={classes.chevronBack}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Change type")}
</Text>
</Group>
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
<PropertyTypePicker
onSelect={handleTypeSelect}
currentType={property.type}
excludeTypes={NON_USER_TARGET_TYPES}
showSearch
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "confirmTypeChange" && pendingTargetType && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Change type to {{label}}?", {
label: t(
propertyTypes.find((pt) => pt.type === pendingTargetType)
?.labelKey ?? pendingTargetType,
),
})}
</Text>
<Text size="xs" c="dimmed">
{t(conversionWarning(property.type, pendingTargetType))}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setPanel("main")}
>
{t("Cancel")}
</Button>
<Button
size="xs"
color={
isLossyConversion(property.type, pendingTargetType)
? "red"
: undefined
}
onClick={handleApplyTypeChange}
>
{t("Apply")}
</Button>
</Group>
</Stack>
)}
{(panel === "options" || panel === "confirmDiscard") && (
<Stack
ref={setOptionsAnchor}
gap="xs"
p="sm"
style={panel === "confirmDiscard" ? { display: "none" } : undefined}
>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={handleOptionsBack}
>
<IconChevronRight
size={14}
className={classes.chevronBack}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Property options")}
</Text>
</Group>
<ScrollArea.Autosize mah={400} scrollbarSize={6} offsetScrollbars>
<PropertyOptions
property={property}
onUpdate={handleOptionsUpdate}
onClose={onClose}
onDirtyChange={handleOptionsDirtyChange}
dropdownPortalTarget={optionsAnchor}
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "confirmDelete" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Delete property")}
</Text>
<Text size="xs" c="dimmed">
{t("Are you sure you want to delete")} <b>{property.name}</b>?{" "}
{t("All data in this column will be lost.")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setPanel("main")}
>
{t("Cancel")}
</Button>
<Button
color="red"
size="xs"
onClick={handleDelete}
>
{t("Delete")}
</Button>
</Group>
</Stack>
)}
{panel === "confirmDiscard" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Unsaved changes")}
</Text>
<Text size="xs" c="dimmed">
{t("You have unsaved changes. Do you want to discard them?")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={handleCancelDiscard}
>
{t("Keep editing")}
</Button>
<Button
color="red"
size="xs"
onClick={handleConfirmDiscard}
>
{t("Discard")}
</Button>
</Group>
</Stack>
)}
</>
);
}
PropertyMenuContent.displayName = "PropertyMenuContent";
function MenuItem({
icon,
label,
rightIcon,
color,
onClick,
}: {
icon: React.ReactNode;
label: string;
rightIcon?: React.ReactNode;
color?: string;
onClick: () => void;
}) {
return (
<UnstyledButton
className={cellClasses.menuItem}
onClick={onClick}
style={{ color: color ? `var(--mantine-color-${color}-6)` : undefined }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{icon}
<Text size="sm">{label}</Text>
</Group>
{rightIcon}
</UnstyledButton>
);
}
function MainPanel({
property,
onRename,
onChangeType,
onOptions,
onDelete,
onEditFormula,
}: {
property: IBaseProperty;
onRename: () => void;
onChangeType: () => void;
onOptions: () => void;
onDelete: () => void;
onEditFormula?: () => void;
}) {
const { t } = useTranslation();
const isSystem = isSystemPropertyType(property.type);
const isPending = property.pendingType != null;
const hasOptions =
!isSystem &&
!isPending &&
(property.type === "select" ||
property.type === "multiSelect" ||
property.type === "status" ||
property.type === "number" ||
property.type === "date" ||
property.type === "text" ||
property.type === "longText" ||
property.type === "checkbox" ||
property.type === "url" ||
property.type === "email");
const typeDef = propertyTypes.find((pt) => pt.type === property.type);
const TypeIcon = typeDef?.icon;
return (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconPencil size={14} />}
label={t("Rename")}
onClick={onRename}
/>
{property.type === "formula" && !isPending && onEditFormula && (
<MenuItem
icon={<IconMathFunction size={14} />}
label={t("Edit formula")}
onClick={onEditFormula}
/>
)}
{isPending && (
<Group gap={8} px="sm" py={8}>
<Loader size={12} />
<Text size="sm" c="dimmed">
{t("Converting…")}
</Text>
</Group>
)}
{!isSystem && !isPending && !property.isPrimary && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={onChangeType}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{TypeIcon ? <TypeIcon size={14} /> : null}
<Text size="sm">
{typeDef ? t(typeDef.labelKey) : property.type}
</Text>
</Group>
<IconChevronRight size={14} />
</UnstyledButton>
)}
{hasOptions && (
<MenuItem
icon={<IconSettings size={14} />}
label={t("Options")}
rightIcon={<IconChevronRight size={14} />}
onClick={onOptions}
/>
)}
{!property.isPrimary && !isPending && (
<>
<Divider my={4} />
<MenuItem
icon={<IconTrash size={14} />}
label={t("Delete property")}
color="red"
onClick={onDelete}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,596 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Stack,
NumberInput,
Select,
Switch,
Text,
Button,
Group,
Divider,
TextInput,
Textarea,
} from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
PersonTypeOptions,
Choice,
} from "@/ee/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
import {
CURRENCIES,
DEFAULT_CURRENCY_CODE,
} from "@/ee/base/constants/currencies";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
// Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
dropdownPortalTarget?: HTMLElement | null;
};
export function PropertyOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "person":
return (
<PersonOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
case "text":
case "longText":
case "url":
case "email":
return (
<TextDefaultOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "checkbox":
return (
<CheckboxOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
type OptionEditorProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
dropdownPortalTarget?: HTMLElement | null;
};
const EMPTY_OPTIONS: Record<string, unknown> = {};
function optionsEqual(
a: Record<string, unknown>,
b: Record<string, unknown>,
): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const key of keys) {
const av = a[key];
const bv = b[key];
if (Array.isArray(av) && Array.isArray(bv)) {
if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
return false;
}
} else if (av !== bv) {
return false;
}
}
return true;
}
// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
function useEditableTypeOptions(
initialRaw: Record<string, unknown> | undefined,
{
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
onUpdate: (opts: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
},
) {
const initial = initialRaw ?? EMPTY_OPTIONS;
const [draft, setDraft] = useState<Record<string, unknown>>(initial);
useEffect(() => {
if (!hideButtons) setDraft(initial);
}, [initial, hideButtons]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
useEffect(() => {
if (hideButtons) onUpdateRef.current(draft);
}, [hideButtons, draft]);
const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const update = useCallback(
(patch: Record<string, unknown>) =>
setDraft((prev) => ({ ...prev, ...patch })),
[],
);
const save = useCallback(() => {
onUpdate(draft);
onClose();
}, [draft, onUpdate, onClose]);
const cancel = useCallback(() => {
setDraft(initial);
onDirtyChange?.(false);
onClose();
}, [initial, onClose, onDirtyChange]);
return { draft, update, isDirty, save, cancel };
}
function OptionsFooter({
isDirty,
onCancel,
onSave,
}: {
isDirty: boolean;
onCancel: () => void;
onSave: () => void;
}) {
const { t } = useTranslation();
return (
<>
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={onSave} disabled={!isDirty}>
{t("Save")}
</Button>
</Group>
</>
);
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
multiDefault={property.type === "multiSelect"}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[], defaultValue: string | string[] | null) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
defaultValue,
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
hideButtons={hideButtons}
initialDefaultValue={options?.defaultValue ?? null}
dropdownPortalTarget={dropdownPortalTarget}
/>
);
}
function NumberOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as NumberTypeOptions;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "plain", label: t("Number") },
{ value: "separators", label: t("Number with separators") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
]}
value={options.format ?? "plain"}
onChange={(val) => update({ format: val ?? "plain" })}
/>
{options.format === "currency" && (
<Select
size="xs"
label={t("Currency")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={CURRENCIES.map((c) => ({
value: c.code,
label: `${c.name} (${c.code})`,
}))}
value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
onChange={(val) =>
update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
}
/>
)}
<NumberInput
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options.precision ?? 0}
onChange={(val) => update({ precision: val })}
/>
<NumberInput
size="xs"
label={t("Default value")}
placeholder={t("None")}
value={typeof options.defaultValue === "number" ? options.defaultValue : ""}
onChange={(val) =>
update({ defaultValue: typeof val === "number" ? val : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function DateOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as DateTypeOptions;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options.includeTime ?? false}
onChange={(e) => update({ includeTime: e.currentTarget.checked })}
/>
{options.includeTime && (
<Select
size="xs"
label={t("Time format")}
allowDeselect={false}
checkIconPosition="right"
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options.timeFormat ?? "12h"}
onChange={(val) => update({ timeFormat: val ?? "12h" })}
/>
)}
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
function PersonOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
dropdownPortalTarget,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const options = draft as PersonTypeOptions;
const allowMultiple = options.allowMultiple === true;
const handleAllowMultipleChange = (toMulti: boolean) => {
const dv = options.defaultValue;
const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
update({
allowMultiple: toMulti,
defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
});
};
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Allow multiple people")}
checked={allowMultiple}
onChange={(e) => handleAllowMultipleChange(e.currentTarget.checked)}
/>
<FilterPersonInput
pageId={property.pageId}
multiple={allowMultiple}
value={options.defaultValue ?? null}
onChange={(value) =>
update({ defaultValue: value as string | string[] | undefined })
}
placeholder={t("None")}
label={t("Default value")}
w="100%"
portalTarget={dropdownPortalTarget}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function TextDefaultOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
const defaultValue =
typeof draft.defaultValue === "string" ? draft.defaultValue : "";
const defaultValueError =
defaultValue && property.type === "url" && !URL.canParse(defaultValue)
? t("Please enter a valid url")
: defaultValue &&
property.type === "email" &&
!EMAIL_FORMAT.test(defaultValue)
? t("Please enter a valid email")
: null;
return (
<Stack gap="xs">
{property.type === "longText" ? (
<Textarea
size="xs"
label={t("Default value")}
placeholder={t("None")}
autosize
minRows={2}
maxRows={6}
value={defaultValue}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
) : (
<TextInput
size="xs"
label={t("Default value")}
placeholder={
property.type === "url"
? "https://example.com"
: property.type === "email"
? "name@example.com"
: t("None")
}
value={defaultValue}
error={defaultValueError}
onChange={(e) =>
update({
defaultValue: e.currentTarget.value.trim()
? e.currentTarget.value
: undefined,
})
}
/>
)}
{!hideButtons && (
<OptionsFooter
isDirty={isDirty && !defaultValueError}
onCancel={cancel}
onSave={save}
/>
)}
</Stack>
);
}
function CheckboxOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: OptionEditorProps) {
const { t } = useTranslation();
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
property.typeOptions as Record<string, unknown> | undefined,
{ onUpdate, onClose, onDirtyChange, hideButtons },
);
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Checked by default")}
checked={draft.defaultValue === true}
onChange={(e) =>
update({ defaultValue: e.currentTarget.checked ? true : undefined })
}
/>
{!hideButtons && (
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
)}
</Stack>
);
}
@@ -0,0 +1,81 @@
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 { 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;
excludeTypes?: Set<BasePropertyType>;
showSearch?: boolean;
};
export function PropertyTypePicker({
onSelect,
currentType,
excludeTypes,
showSearch,
}: PropertyTypePickerProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showSearch) {
setTimeout(() => searchRef.current?.focus(), 0);
}
}, [showSearch]);
const types = propertyTypes
.filter(({ type }) => !excludeTypes?.has(type))
.filter(({ labelKey }) =>
!search || t(labelKey).toLowerCase().includes(search.toLowerCase())
);
return (
<>
{showSearch && (
<TextInput
ref={searchRef}
size="xs"
placeholder={t("Find a property type")}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mx="sm"
mt="sm"
mb={4}
/>
)}
{types.map(({ type, icon: Icon, labelKey }) => (
<UnstyledButton
key={type}
className={classes.menuItem}
onClick={() => onSelect(type)}
style={{
fontWeight: type === currentType ? 600 : 400,
}}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Icon size={14} />
<Text size="sm">{t(labelKey)}</Text>
</Group>
{type === currentType && <IconCheck size={14} />}
</UnstyledButton>
))}
</>
);
}
export { propertyTypes };
@@ -0,0 +1,142 @@
import { forwardRef } from "react";
import { Checkbox } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import clsx from "clsx";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldText } from "./field-text";
import { FieldLongText } from "./field-long-text";
import { FieldNumber } from "./field-number";
import { FieldDate } from "./field-date";
import { FieldChoice } from "./field-choice";
import { FieldCellAdapter } from "./field-cell-adapter";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
export type FieldProps = {
property: IBaseProperty;
value: unknown;
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
};
type FieldShellProps = {
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
cursor?: "text" | "pointer" | "default";
/** Popover open — keeps the focus ring while focus is in the portal. */
active?: boolean;
locked?: boolean;
alignTop?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
// ref injected into this element; without it the picker renders at (0,0).
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
function FieldShell(
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
ref,
) {
return (
<div
ref={ref}
className={clsx(
classes.fieldShell,
cursor === "text" && classes.fieldShellText,
cursor === "pointer" && classes.fieldShellPointer,
active && classes.fieldShellActive,
locked && classes.fieldShellLocked,
alignTop && classes.fieldShellTop,
className,
)}
{...rest}
>
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
{children}
</div>
);
},
);
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
const checked = value === true;
return (
<FieldShell>
<Checkbox
size="sm"
checked={checked}
disabled={readOnly}
onChange={() => onChange(!checked)}
/>
</FieldShell>
);
}
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
const CellComponent = getDescriptor(property.type)?.cellComponent;
return (
<FieldShell locked>
<div className={classes.fieldCellDisplay}>
{CellComponent && (
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={false}
readOnly
onCommit={() => {}}
onValueChange={() => {}}
onCancel={() => {}}
/>
)}
</div>
</FieldShell>
);
}
type DetailFieldProps = {
property: IBaseProperty;
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
: (row.cells ?? {})[property.id];
const fieldProps: FieldProps = {
property,
value,
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
};
switch (property.type) {
case "text":
case "url":
case "email":
return <FieldText {...fieldProps} />;
case "longText":
return <FieldLongText {...fieldProps} />;
case "number":
return <FieldNumber {...fieldProps} />;
case "checkbox":
return <FieldCheckbox {...fieldProps} />;
case "date":
return <FieldDate {...fieldProps} />;
case "select":
case "status":
case "multiSelect":
return <FieldChoice {...fieldProps} />;
case "person":
case "file":
case "page":
return <FieldCellAdapter {...fieldProps} />;
default:
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
return <FieldReadonlyCell {...fieldProps} />;
}
}
@@ -0,0 +1,80 @@
import { useCallback, useRef, useState } from "react";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
/** Person, file and page editors are popover pickers owned by their cell
* components; the shell supplies modal styling and click-anywhere
* activation while the cell keeps its picker behavior. */
export function FieldCellAdapter({
property,
value,
rowId,
readOnly,
onChange,
}: FieldProps) {
const [editing, setEditing] = useState(false);
// Whether the picker was open when the current gesture's mousedown fired.
const editingAtMouseDownRef = useRef(false);
const CellComponent = getDescriptor(property.type)?.cellComponent;
// Files stay openable read-only (download-only popover), matching the grid.
const canActivate = !readOnly || property.type === "file";
// Activate on click, not mousedown: opening on mousedown mounts the cell's
// picker mid-dispatch, and its document-level outside-mousedown listener
// then catches the same still-bubbling event and instantly closes it. By
// click time the mousedown has fully finished. The ref keeps toggle-close
// working: when the gesture started with the picker open, the picker's own
// outside-close already handled it and we must not reopen.
const handleMouseDown = useCallback(() => {
editingAtMouseDownRef.current = editing;
}, [editing]);
const handleClick = useCallback(() => {
if (!canActivate || editingAtMouseDownRef.current || editing) return;
setEditing(true);
}, [canActivate, editing]);
const handleCommit = useCallback(
(next: unknown) => {
setEditing(false);
onChange(next);
},
[onChange],
);
const handleCancel = useCallback(() => setEditing(false), []);
if (!CellComponent) return <FieldShell />;
return (
<FieldShell
cursor={canActivate ? "pointer" : "default"}
active={editing}
onMouseDown={handleMouseDown}
onClick={handleClick}
role={canActivate ? "button" : undefined}
tabIndex={canActivate ? 0 : undefined}
aria-label={property.name}
onKeyDown={(e) => {
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setEditing(true);
}
}}
>
<div className={classes.fieldCellDisplay}>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={editing}
readOnly={readOnly}
onCommit={handleCommit}
onValueChange={onChange}
onCancel={handleCancel}
/>
</div>
</FieldShell>
);
}
@@ -0,0 +1,103 @@
import { useCallback, useState } from "react";
import { Popover } from "@mantine/core";
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
import cellClasses from "@/ee/base/styles/cells.module.css";
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const multiple = property.type === "multiSelect";
const choices =
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = multiple
? Array.isArray(value)
? (value as string[])
: []
: typeof value === "string"
? [value]
: [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
const handleToggle = useCallback(
(choice: Choice) => {
if (multiple) {
const next = selectedIds.includes(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onChange(next.length > 0 ? next : null);
} else {
onChange(choice.id === selectedIds[0] ? null : choice.id);
setOpened(false);
}
},
[multiple, selectedIds, onChange],
);
const chips = selectedChoices.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
));
if (readOnly) {
return (
<FieldShell>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="target"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape={false}
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<div className={classes.fieldChips}>{chips}</div>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p={4}>
{opened && (
<ChoicePicker
property={property}
selectedIds={selectedIds}
multiple={multiple}
grouped={property.type === "status"}
allowCreate={property.type !== "status"}
onToggle={handleToggle}
onEscape={() => setOpened(false)}
/>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { DateTypeOptions } from "@/ee/base/types/base.types";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
const [opened, setOpened] = useState(false);
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const display = formatDateDisplay(dateStr, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
);
}
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width="auto"
shadow="md"
withinPortal
trapFocus
closeOnClickOutside
closeOnEscape
>
<Popover.Target>
<FieldShell
cursor="pointer"
active={opened}
role="button"
tabIndex={0}
aria-label={property.name}
onClick={() => setOpened((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpened((o) => !o);
}
}}
>
<span className={classes.fieldValueText}>{display}</span>
</FieldShell>
</Popover.Target>
<Popover.Dropdown p="xs">
<DatePicker
value={toISODateString(dateStr)}
onChange={(selected) => {
onChange(selected ? new Date(selected).toISOString() : null);
setOpened(false);
}}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { Textarea } from "@mantine/core";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
const normalize = (s: string) => {
const trimmed = s.trim();
return trimmed.length ? trimmed : null;
};
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
};
if (readOnly) {
return (
<FieldShell alignTop>
<span className={classes.fieldValueTextMultiline}>{text}</span>
</FieldShell>
);
}
return (
<FieldShell cursor="text" alignTop>
<Textarea
autosize
minRows={3}
maxRows={16}
maxLength={25000}
variant="unstyled"
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "react";
import { NumberTypeOptions } from "@/ee/base/types/base.types";
import { formatNumber } 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;
const [draft, setDraft] = useState(toDraft(value));
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
useEffect(() => {
if (!focused) setDraft(toDraft(value));
}, [value, focused]);
const formatted = formatNumber(numValue, typeOptions);
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{formatted}</span>
</FieldShell>
);
}
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
return;
}
if (parse(draft) !== numValue) onChange(parse(draft));
};
return (
<FieldShell cursor="text">
<input
type="text"
inputMode="decimal"
className={classes.fieldInput}
value={focused ? draft : formatted}
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
}}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
</FieldShell>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { IconExternalLink, IconMail } from "@tabler/icons-react";
import { FieldProps, FieldShell } from "./detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
// Esc sets this; blur() then runs commit synchronously with the stale
// draft, so the revert must be decided here, not via setDraft.
const cancelRef = useRef(false);
// Track remote/navigation updates while not typing.
useEffect(() => {
if (!focused) setDraft(text);
}, [text, focused]);
const commit = () => {
setFocused(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
return;
}
if (draft !== text) onChange(draft);
};
if (readOnly) {
return (
<FieldShell>
<span className={classes.fieldValueText}>{text}</span>
</FieldShell>
);
}
const linkHref =
!focused && text
? property.type === "email"
? text.includes("@")
? `mailto:${text}`
: null
: property.type === "url" && /^https?:\/\//i.test(text)
? text
: null
: null;
return (
<FieldShell cursor="text">
<input
type="text"
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
}
}}
aria-label={property.name}
/>
{linkHref && (
<a
href={linkHref}
target={property.type === "url" ? "_blank" : undefined}
rel="noopener noreferrer"
className={classes.fieldTrailing}
onMouseDown={(e) => e.stopPropagation()}
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
>
{property.type === "email" ? (
<IconMail size={14} />
) : (
<IconExternalLink size={14} />
)}
</a>
)}
</FieldShell>
);
}
@@ -0,0 +1,100 @@
import { useCallback } from "react";
import clsx from "clsx";
import { Popover } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { DetailField } from "./fields/detail-field";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type PropertyRowProps = {
property: IBaseProperty;
row: IBaseRow;
pageId: string;
menuOpened: boolean;
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
};
export function PropertyRow({
property,
row,
pageId,
menuOpened,
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
}: PropertyRowProps) {
const canEdit = useBaseEditable();
const handleLabelClick = useCallback(() => {
onMenuOpenChange(!menuOpened);
}, [menuOpened, onMenuOpenChange]);
const handleMenuClose = useCallback(() => {
onMenuOpenChange(false);
}, [onMenuOpenChange]);
const Icon = getDescriptor(property.type)?.icon;
const label = (
<>
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
<span className={classes.propertyLabelText}>{property.name}</span>
</>
);
return (
<div className={classes.propertyRow}>
{canEdit ? (
<Popover
opened={menuOpened}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
closeOnEscape={false}
>
<Popover.Target>
<button
type="button"
className={clsx(classes.propertyLabel, classes.propertyLabelButton, {
[classes.propertyLabelActive]: menuOpened,
})}
onClick={handleLabelClick}
data-property-menu-target
>
{label}
<IconChevronDown size={13} className={classes.propertyLabelChevron} />
</button>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={onMenuDirtyChange}
pageId={pageId}
/>
</Popover.Dropdown>
</Popover>
) : (
<div className={classes.propertyLabel}>{label}</div>
)}
<DetailField
property={property}
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
/>
</div>
);
}
@@ -0,0 +1,433 @@
import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import {
IconChevronDown,
IconChevronUp,
IconDotsVertical,
IconLink,
IconLock,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IBase, IBaseRow } from "@/ee/base/types/base.types";
import {
useBaseRowQuery,
useDeleteRowMutation,
useUpdateRowMutation,
} from "@/ee/base/queries/base-row-query";
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { useClipboard } from "@/hooks/use-clipboard";
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
import { RowDetailTitle } from "./row-detail-title";
import { PropertyRow } from "./property-row";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailModalProps = {
base: IBase;
rows: IBaseRow[];
openRowId: string | null;
onClose: () => void;
onNavigate: (rowId: string) => void;
};
export function RowDetailModal({
base,
rows,
openRowId,
onClose,
onNavigate,
}: RowDetailModalProps) {
const { t } = useTranslation();
const canEdit = useBaseEditable();
const updateRowMutation = useUpdateRowMutation();
const deleteRowMutation = useDeleteRowMutation();
const clipboard = useClipboard({ timeout: 500 });
const rowIndex = useMemo(
() => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
[openRowId, rows],
);
const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
// Deep links (?row=) can target rows outside the loaded pages or filtered
// out of the active view — fetch by id instead of closing. Close only
// when the server confirms the row is gone.
const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
enabled: !!openRowId && !rowFromList,
});
const row = rowFromList ?? rowQuery.data;
const primaryProperty = useMemo(
() => base.properties.find((p) => p.isPrimary),
[base.properties],
);
const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
useEffect(() => {
if (rowMissing) onClose();
}, [rowMissing, onClose]);
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
// run its discard-confirm flow instead of being torn down mid-edit.
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const menuDirtyRef = useRef(false);
const [closeRequest, setCloseRequest] = useAtom(
propertyMenuCloseRequestAtomFamily(base.id),
) as unknown as [number, (val: number) => void];
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
menuDirtyRef.current = dirty;
}, []);
const requestMenuClose = useCallback(() => {
if (menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
} else {
setOpenMenuId(null);
}
}, [closeRequest, setCloseRequest]);
const handleMenuOpenChange = useCallback(
(propertyId: string, nextOpened: boolean) => {
if (!nextOpened) {
setOpenMenuId(null);
menuDirtyRef.current = false;
return;
}
if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
setCloseRequest(closeRequest + 1);
return;
}
setOpenMenuId(propertyId);
},
[openMenuId, closeRequest, setCloseRequest],
);
useEffect(() => {
if (!openMenuId) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-position]")) return;
if (target.closest("[data-property-menu-target]")) return;
requestMenuClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [openMenuId, requestMenuClose]);
const hasPrev = rowIndex > 0;
const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
const navigate = useCallback(
(delta: number) => {
if (rowIndex === -1) return;
const next = rows[rowIndex + delta];
if (next) onNavigate(next.id);
},
[rows, rowIndex, onNavigate],
);
const handleCopyLink = useCallback(() => {
clipboard.copy(window.location.href);
notifications.show({ message: t("Link copied") });
}, [clipboard, t]);
const handleDeleteRecord = useCallback(() => {
if (!row) return;
const rowId = row.id;
modals.openConfirmModal({
title: t("Delete record?"),
centered: true,
children: <Text size="sm">{t("This action cannot be undone.")}</Text>,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteRowMutation.mutate({ rowId, pageId: base.id });
onClose();
},
});
}, [row, base.id, deleteRowMutation, onClose, t]);
// Mantine's closeOnEscape runs a capture-phase window listener that fires
// before inner popovers and inputs see the key, so we manage Esc ourselves
// and yield to: nested dialogs (delete confirm), open popovers
// ([data-position]) and editable elements. Arrows step records under the
// same yield rules. Mantine puts role="dialog" and our content class on
// the same element, which distinguishes this modal from nested ones.
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isEscape = event.key === "Escape";
const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
const target = event.target as HTMLElement | null;
if (target) {
const dialog = target.closest('[role="dialog"]');
if (dialog && !dialog.classList.contains(classes.modalContent)) {
return;
}
if (
target.closest("[data-position]") ||
target.matches("input, textarea, select, [contenteditable='true']")
) {
return;
}
}
if (isEscape) {
if (openMenuId) {
requestMenuClose();
return;
}
onClose();
return;
}
if (openMenuId) return;
event.preventDefault();
navigate(event.key === "ArrowUp" ? -1 : 1);
},
[opened, openMenuId, requestMenuClose, onClose, navigate],
);
useWindowEvent("keydown", handleKeyDown, { capture: true });
return (
<Modal
opened={opened}
onClose={onClose}
size="lg"
centered
withCloseButton={false}
closeOnEscape={false}
closeOnClickOutside={!openMenuId}
padding={0}
radius="md"
title={null}
classNames={{ content: classes.modalContent }}
>
{row ? (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Tooltip label={t("Previous record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(-1)}
disabled={!hasPrev}
aria-label={t("Previous record")}
>
<IconChevronUp size={16} />
</button>
</Tooltip>
<Tooltip label={t("Next record")} openDelay={400}>
<button
type="button"
className={classes.iconButton}
onClick={() => navigate(1)}
disabled={!hasNext}
aria-label={t("Next record")}
>
<IconChevronDown size={16} />
</button>
</Tooltip>
</div>
<div className={classes.topBarGroup}>
<Menu position="bottom-end" shadow="md" withinPortal>
<Menu.Target>
<button
type="button"
className={classes.iconButton}
aria-label={t("Record actions")}
>
<IconDotsVertical size={16} />
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={14} />}
onClick={handleCopyLink}
>
{t("Copy link")}
</Menu.Item>
{canEdit && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={handleDeleteRecord}
>
{t("Delete record")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<button
type="button"
className={classes.iconButton}
onClick={onClose}
aria-label={t("Close")}
>
<IconX size={16} />
</button>
</div>
</div>
<RowDetailTitle
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [primaryProperty.id]: value },
});
}}
/>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => (
<PropertyRow
key={property.id}
property={property}
row={row}
pageId={base.id}
menuOpened={openMenuId === property.id}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
onMenuDirtyChange={handleMenuDirtyChange}
onUpdate={(propertyId, value) => {
updateRowMutation.mutate({
rowId: row.id,
pageId: base.id,
cells: { [propertyId]: value },
});
}}
/>
))}
</div>
{canEdit && (
<CreatePropertyPopover
pageId={base.id}
properties={base.properties}
renderTarget={(open) => (
<button
type="button"
className={classes.addPropertyRow}
onClick={open}
>
<span className={classes.addPropertyLabel}>
<IconPlus size={15} />
{t("Add property")}
</span>
</button>
)}
/>
)}
</div>
<footer className={classes.footer}>
<div className={classes.footerStatus}>
{!canEdit ? (
<span className={classes.lockedHint}>
<IconLock size={12} />
{t("Read-only")}
</span>
) : isSaving ? (
<>
<span className={classes.savingDot} />
<span>{t("Saving…")}</span>
</>
) : null}
</div>
<div className={classes.kbdHint}>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
) : (
<RowDetailSkeleton base={base} />
)}
</Modal>
);
}
/** Hydration state for deep-linked rows: the schema is already loaded, so
* render the real labels and shimmer only the unknown values. Matching the
* final layout avoids a size jump when the row arrives. */
function RowDetailSkeleton({ base }: { base: IBase }) {
return (
<>
<div className={classes.topBar}>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
<div className={classes.topBarGroup}>
<Skeleton height={28} width={28} radius={6} />
<Skeleton height={28} width={28} radius={6} />
</div>
</div>
<header className={classes.header}>
<Skeleton height={30} width="45%" radius={8} />
<div className={classes.metaRow}>
<Skeleton height={12} width={150} radius={4} />
</div>
</header>
<div className={classes.body}>
<div className={classes.propertyList}>
{base.properties
.filter((p) => !p.isPrimary)
.map((property) => {
const Icon = getDescriptor(property.type)?.icon;
return (
<div key={property.id} className={classes.propertyRow}>
<div className={classes.propertyLabel}>
{Icon && (
<Icon size={15} className={classes.propertyLabelIcon} />
)}
<span className={classes.propertyLabelText}>
{property.name}
</span>
</div>
<Skeleton
height={property.type === "longText" ? 82 : 34}
radius={7}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,64 @@
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";
import classes from "@/ee/base/styles/row-detail-modal.module.css";
type RowDetailTitleProps = {
row: IBaseRow;
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
};
export function RowDetailTitle({
row,
primaryProperty,
canEdit,
onCommit,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
setValue(initial);
}, [initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
return (
<header className={classes.header}>
{canEdit ? (
<input
type="text"
className={classes.titleInput}
placeholder={t("Untitled")}
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
) : (
<h1 className={classes.titleStatic}>{value || t("Untitled")}</h1>
)}
{updatedAgo && (
<div className={classes.metaRow}>
<span>{t("Updated {{when}}", { when: updatedAgo })}</span>
</div>
)}
</header>
);
}
@@ -0,0 +1,201 @@
import { useState } from "react";
import {
Popover,
InputBase,
Input,
SegmentedControl,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import type {
DateFilterValue,
FilterOperator,
} from "@/ee/base/types/base.types";
import {
DATE_ANCHOR_PRESETS,
DATE_RANGE_PRESETS,
ANCHOR_VALUES,
RANGE_VALUES,
} from "./relative-date-presets";
import cellClasses from "@/ee/base/styles/cells.module.css";
type FilterDateInputProps = {
op: FilterOperator;
value: unknown;
onChange: (value: unknown) => void;
};
type Mode = "exact" | "relative";
const ANCHOR_LABEL: Record<string, string> = Object.fromEntries(
DATE_ANCHOR_PRESETS.map((p) => [p.value, p.labelKey]),
);
const RANGE_LABEL: Record<string, string> = Object.fromEntries(
DATE_RANGE_PRESETS.map((p) => [p.value, p.labelKey]),
);
function asDateValue(value: unknown): DateFilterValue | null {
if (!value || typeof value !== "object") return null;
return value as DateFilterValue;
}
function toISODate(d: string | null): string | null {
if (!d) return null;
// Already a date-only ISO string (Mantine v8 emits these) — pass through to
// avoid a UTC-parse + local-getter round-trip that shifts the day west of UTC.
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
const date = new Date(d);
if (isNaN(date.getTime())) return null;
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function FilterDateInput({ op, value, onChange }: FilterDateInputProps) {
const { t } = useTranslation();
const current = asDateValue(value);
const [opened, setOpened] = useState(false);
const [localMode, setLocalMode] = useState<Mode>("exact");
const exactDate = current?.mode === "exact" ? toISODate(current.date) : null;
const anchor =
current?.mode === "relative" && ANCHOR_VALUES.has(current.preset)
? current.preset
: null;
const range =
current?.mode === "range" && RANGE_VALUES.has(current.preset)
? current.preset
: null;
const valueMode: Mode | null =
current?.mode === "relative"
? "relative"
: current?.mode === "exact"
? "exact"
: null;
const mode: Mode = valueMode ?? localMode;
let triggerLabel: string | null = null;
if (op === "isWithin") triggerLabel = range ? t(RANGE_LABEL[range]) : null;
else if (exactDate) triggerLabel = exactDate;
else if (anchor) triggerLabel = t(ANCHOR_LABEL[anchor]);
// Consume Escape locally so the outer filter popover (bubble handler) keeps
// the panel open and only this picker closes.
const handleEscape = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOpened(false);
}
};
const presetRow = (
selected: boolean,
label: string,
onClick: () => void,
key: string,
) => (
<div
key={key}
className={clsx(
cellClasses.selectOption,
selected && cellClasses.selectOptionActive,
)}
onClick={onClick}
>
<span className={cellClasses.personOptionName}>{label}</span>
</div>
);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width={op === "isWithin" ? 200 : "auto"}
withinPortal={false}
closeOnEscape={false}
closeOnClickOutside
>
<Popover.Target>
<InputBase
component="button"
type="button"
size="xs"
pointer
w={170}
rightSection={<IconChevronDown size={14} />}
rightSectionPointerEvents="none"
onClick={() => setOpened((o) => !o)}
onKeyDown={handleEscape}
>
{triggerLabel ?? <Input.Placeholder>{t("Select")}</Input.Placeholder>}
</InputBase>
</Popover.Target>
<Popover.Dropdown p={op === "isWithin" ? 0 : "xs"} onKeyDown={handleEscape}>
{op === "isWithin" ? (
<div className={cellClasses.selectDropdown}>
{DATE_RANGE_PRESETS.map((p) =>
presetRow(
range === p.value,
t(p.labelKey),
() => {
onChange({ mode: "range", preset: p.value });
setOpened(false);
},
p.value,
),
)}
</div>
) : (
<>
<SegmentedControl
fullWidth
size="xs"
mb="xs"
value={mode}
onChange={(m) => {
setLocalMode(m as Mode);
onChange(undefined);
}}
data={[
{ value: "exact", label: t("Date") },
{ value: "relative", label: t("Relative") },
]}
/>
{mode === "exact" ? (
<DatePicker
value={exactDate}
onChange={(d) => {
const iso = toISODate(d);
onChange(iso ? { mode: "exact", date: iso } : undefined);
setOpened(false);
}}
size="sm"
/>
) : (
<div className={cellClasses.selectDropdown}>
{DATE_ANCHOR_PRESETS.map((p) =>
presetRow(
anchor === p.value,
t(p.labelKey),
() => {
onChange({ mode: "relative", preset: p.value });
setOpened(false);
},
p.value,
),
)}
</div>
)}
</>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,246 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover, InputBase, Input } from "@mantine/core";
import { IconX, IconChevronDown } from "@tabler/icons-react";
import clsx from "clsx";
import {
usePersonSearch,
type PersonSuggestion,
} from "@/ee/base/hooks/use-person-search";
import {
useReferenceStore,
useHydrateUsers,
} from "@/ee/base/reference/reference-store";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/ee/base/styles/cells.module.css";
type FilterPersonInputProps = {
pageId: string;
multiple: boolean;
value: unknown;
onChange: (value: unknown) => void;
placeholder: string;
label?: string;
w?: number | string;
portalTarget?: HTMLElement | null;
};
function toIds(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => !!v);
if (typeof value === "string" && value) return [value];
return [];
}
export function FilterPersonInput({
pageId,
multiple,
value,
onChange,
placeholder,
label,
w,
portalTarget,
}: FilterPersonInputProps) {
const ids = toIds(value);
const selectedSet = new Set(ids);
const [opened, setOpened] = useState(false);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const store = useReferenceStore(pageId);
const hydrateUsers = useHydrateUsers(pageId);
const suggestions = usePersonSearch(search, opened);
useEffect(() => {
if (opened) requestAnimationFrame(() => searchRef.current?.focus());
else setSearch("");
}, [opened]);
const filtered: PersonSuggestion[] = multiple
? suggestions.filter((s) => !selectedSet.has(s.id))
: suggestions;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filtered.length, [search, opened]);
const emit = useCallback(
(nextIds: string[]) => {
if (multiple) onChange(nextIds.length > 0 ? nextIds : undefined);
else onChange(nextIds[0] ?? undefined);
},
[multiple, onChange],
);
const handleSelect = useCallback(
(id: string) => {
const picked = suggestions.find((s) => s.id === id);
if (picked)
hydrateUsers([
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
]);
if (multiple) {
emit(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
} else {
emit([id]);
setOpened(false);
}
setSearch("");
},
[suggestions, hydrateUsers, multiple, ids, emit],
);
const handleRemove = useCallback(
(id: string) => emit(ids.filter((x) => x !== id)),
[emit, ids],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setOpened(false);
return;
}
if (handleNavKey(e)) return;
if (e.key === "Enter") {
if (activeIndex < 0 || activeIndex >= filtered.length) return;
e.preventDefault();
handleSelect(filtered[activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && ids.length > 0) {
e.preventDefault();
handleRemove(ids[ids.length - 1]);
}
},
[handleNavKey, activeIndex, filtered, handleSelect, search, ids, handleRemove],
);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-start"
width={260}
withinPortal={!!portalTarget}
portalProps={{ target: portalTarget ?? undefined }}
closeOnEscape={false}
closeOnClickOutside
>
<Popover.Target>
<InputBase
component="button"
type="button"
size="xs"
pointer
multiline
w={w ?? 170}
label={label}
rightSection={<IconChevronDown size={14} />}
rightSectionPointerEvents="none"
onClick={() => setOpened((o) => !o)}
>
{ids.length === 0 ? (
<Input.Placeholder>{placeholder}</Input.Placeholder>
) : (
<span className={cellClasses.filterTriggerChips}>
{ids.map((id) => {
const user = store.users[id];
const name = user?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.filterTriggerChip}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={16}
radius="xl"
/>
<span className={cellClasses.filterTriggerChipName}>
{name}
</span>
</span>
);
})}
</span>
)}
</InputBase>
</Popover.Target>
<Popover.Dropdown p={0}>
<div className={cellClasses.personTagArea}>
{multiple &&
ids.map((id) => {
const user = store.users[id];
const name = user?.name ?? id.substring(0, 8);
return (
<span key={id} className={cellClasses.personTag}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder="Find a user..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{filtered.map((member, idx) => (
<div
key={member.id}
ref={setOptionRef(idx)}
className={clsx(
cellClasses.selectOption,
selectedSet.has(member.id) && cellClasses.selectOptionActive,
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
)}
onMouseEnter={() => setActiveIndex(idx)}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl ?? ""}
name={member.name ?? ""}
size={24}
radius="xl"
/>
<div className={cellClasses.personOptionText}>
<span className={cellClasses.personOptionName}>
{member.name ?? ""}
</span>
{member.email && (
<span className={cellClasses.personOptionEmail}>
{member.email}
</span>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className={cellClasses.personDropdownHint}>No users found</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,35 @@
import type {
DateFilterAnchor,
DateFilterRange,
} from "@/ee/base/types/base.types";
export const DATE_ANCHOR_PRESETS: { value: DateFilterAnchor; labelKey: string }[] =
[
{ value: "today", labelKey: "Today" },
{ value: "tomorrow", labelKey: "Tomorrow" },
{ value: "yesterday", labelKey: "Yesterday" },
{ value: "oneWeekAgo", labelKey: "One week ago" },
{ value: "oneWeekFromNow", labelKey: "One week from now" },
{ value: "oneMonthAgo", labelKey: "One month ago" },
{ value: "oneMonthFromNow", labelKey: "One month from now" },
];
export const DATE_RANGE_PRESETS: { value: DateFilterRange; labelKey: string }[] =
[
{ value: "pastWeek", labelKey: "Past week" },
{ value: "pastMonth", labelKey: "Past month" },
{ value: "pastYear", labelKey: "Past year" },
{ value: "thisWeek", labelKey: "This week" },
{ value: "thisMonth", labelKey: "This month" },
{ value: "thisYear", labelKey: "This year" },
{ value: "nextWeek", labelKey: "Next week" },
{ value: "nextMonth", labelKey: "Next month" },
{ value: "nextYear", labelKey: "Next year" },
];
export const ANCHOR_VALUES = new Set<string>(
DATE_ANCHOR_PRESETS.map((p) => p.value),
);
export const RANGE_VALUES = new Set<string>(
DATE_RANGE_PRESETS.map((p) => p.value),
);
@@ -0,0 +1,497 @@
import { useCallback, useEffect, useState } from "react";
import {
Popover,
Stack,
Group,
Select,
TextInput,
ActionIcon,
Text,
UnstyledButton,
Button,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
IBaseProperty,
SelectTypeOptions,
FilterCondition,
FilterOperator,
} from "@/ee/base/types/base.types";
import { useTranslation } from "react-i18next";
import {
getDescriptor,
DEFAULT_FILTER_OPERATORS,
} from "@/ee/base/property-types/property-type.registry";
import { FilterPersonInput } from "./filter-person-input";
import { FilterDateInput } from "./filter-date-input";
import viewClasses from "@/ee/base/styles/views.module.css";
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
{ value: "eq", labelKey: "Is" },
{ value: "neq", labelKey: "Is not" },
{ value: "contains", labelKey: "Contains" },
{ value: "ncontains", labelKey: "Doesn't contain" },
{ value: "any", labelKey: "Is any of" },
{ value: "none", labelKey: "Is none of" },
{ value: "before", labelKey: "Is before" },
{ value: "after", labelKey: "Is after" },
{ value: "onOrBefore", labelKey: "Is on or before" },
{ value: "onOrAfter", labelKey: "Is on or after" },
{ value: "isWithin", labelKey: "Is within" },
{ value: "gt", labelKey: "Greater than" },
{ value: "lt", labelKey: "Less than" },
{ value: "isEmpty", labelKey: "Is empty" },
{ value: "isNotEmpty", labelKey: "Is not empty" },
];
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// Two operators share a value control only if they share a value class.
// Switching across classes (e.g. eq→any, exact-date→isWithin) must reset the
// stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle";
}
if (inputKind === "date") {
return op === "isWithin" ? "dateRange" : "dateInstant";
}
return "scalar";
}
function inputKindForProperty(property: IBaseProperty | undefined): string {
return getDescriptor(property?.type ?? "")?.filterInput ?? "text";
}
function getOperatorsForType(type: string): FilterOperator[] {
return (getDescriptor(type)?.filterOperators ??
DEFAULT_FILTER_OPERATORS) as FilterOperator[];
}
function FilterValueInput({
condition,
property,
onChange,
t,
}: {
condition: FilterCondition;
property: IBaseProperty | undefined;
onChange: (value: unknown) => void;
t: (key: string) => string;
}) {
if (!property) {
return (
<TextInput
size="xs"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
const kind = getDescriptor(property.type)?.filterInput ?? "text";
if (kind === "person") {
return (
<FilterPersonInput
pageId={property.pageId}
multiple={condition.op === "any" || condition.op === "none"}
value={condition.value}
onChange={onChange}
placeholder={t("Select")}
/>
);
}
if (kind === "date") {
return (
<FilterDateInput
op={condition.op}
value={condition.value}
onChange={onChange}
/>
);
}
if (kind === "choices") {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
return (
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={choiceOptions}
value={(condition.value as string) ?? null}
onChange={(val) => onChange(val ?? "")}
w={120}
placeholder={t("Select")}
/>
);
}
if (kind === "number") {
return (
<TextInput
size="xs"
type="number"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
if (kind === "boolean") {
return (
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={[
{ value: "true", label: t("True") },
{ value: "false", label: t("False") },
]}
value={(condition.value as string) ?? null}
onChange={(val) => onChange(val ?? "")}
w={100}
/>
);
}
return (
<TextInput
size="xs"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
type ViewFilterConfigProps = {
opened: boolean;
onClose: () => void;
conditions: FilterCondition[];
properties: IBaseProperty[];
onChange: (conditions: FilterCondition[]) => void;
children: React.ReactNode;
};
export function ViewFilterConfigPopover({
opened,
onClose,
conditions,
properties,
onChange,
children,
}: ViewFilterConfigProps) {
const { t } = useTranslation();
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const [draft, setDraft] = useState<FilterCondition | null>(null);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
if (!firstProperty) return;
const validOperators = getOperatorsForType(firstProperty.type);
const defaultOperator = validOperators.includes("contains")
? ("contains" as FilterOperator)
: validOperators[0];
setDraft({ propertyId: firstProperty.id, op: defaultOperator });
}, [properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...conditions, draft]);
setDraft(null);
}, [draft, conditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
if (!propertyId || !draft) return;
const newProperty = properties.find((p) => p.id === propertyId);
if (!newProperty) {
setDraft({ ...draft, propertyId });
return;
}
const validOperators = getOperatorsForType(newProperty.type);
const currentOperatorValid = validOperators.includes(draft.op);
const sameKind =
inputKindForProperty(
properties.find((p) => p.id === draft.propertyId),
) === inputKindForProperty(newProperty);
setDraft({
...draft,
propertyId,
op: currentOperatorValid ? draft.op : validOperators[0],
value: currentOperatorValid && sameKind ? draft.value : undefined,
});
},
[draft, properties],
);
const handleDraftOperatorChange = useCallback(
(operator: string | null) => {
if (!operator || !draft) return;
const op = operator as FilterOperator;
const kind = inputKindForProperty(
properties.find((p) => p.id === draft.propertyId),
);
const keep = valueClass(draft.op, kind) === valueClass(op, kind);
setDraft({ ...draft, op, value: keep ? draft.value : undefined });
},
[draft, properties],
);
const handleDraftValueChange = useCallback(
(value: unknown) => {
if (!draft) return;
setDraft({ ...draft, value });
},
[draft],
);
const handleRemove = useCallback(
(index: number) => {
onChange(conditions.filter((_, i) => i !== index));
},
[conditions, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId);
onChange(
conditions.map((f, i) => {
if (i !== index) return f;
if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type);
const currentOperatorValid = validOperators.includes(f.op);
const sameKind =
inputKindForProperty(
properties.find((p) => p.id === f.propertyId),
) === inputKindForProperty(newProperty);
return {
...f,
propertyId,
op: currentOperatorValid ? f.op : validOperators[0],
value: currentOperatorValid && sameKind ? f.value : undefined,
};
}
return { ...f, propertyId };
}),
);
},
[conditions, properties, onChange],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as FilterOperator;
onChange(
conditions.map((f, i) => {
if (i !== index) return f;
const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId),
);
const keep = valueClass(f.op, kind) === valueClass(op, kind);
return { ...f, op, value: keep ? f.value : undefined };
}),
);
},
[conditions, properties, onChange],
);
const handleValueChange = useCallback(
(index: number, value: unknown) => {
onChange(
conditions.map((f, i) => (i === index ? { ...f, value } : f)),
);
},
[conditions, onChange],
);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={520}
trapFocus
closeOnEscape={false}
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown
onKeyDown={(e) => {
// Mantine's built-in closeOnEscape uses a capture-phase handler that
// would fire before a nested picker can consume Escape, closing the
// whole panel. Handle it on bubble instead so an open inner picker
// (which preventDefaults Escape) keeps the panel open.
if (e.key === "Escape" && !e.defaultPrevented) onClose();
}}
>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Filter by")}
</Text>
{conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{conditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find(
(p) => p.id === condition.propertyId,
);
const validOperators = property
? getOperatorsForType(property.type)
: OPERATORS.map((op) => op.value);
const operatorOptions = OPERATORS.filter((op) =>
validOperators.includes(op.value),
).map((op) => ({
value: op.value,
label: t(op.labelKey),
}));
return (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={propertyOptions}
searchable
openOnFocus={false}
nothingFoundMessage={t("No match")}
value={condition.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={operatorOptions}
searchable
openOnFocus={false}
nothingFoundMessage={t("No match")}
value={condition.op}
onChange={(val) => handleOperatorChange(index, val)}
w={130}
/>
{needsValue && (
<FilterValueInput
condition={condition}
property={property}
onChange={(val) => handleValueChange(index, val)}
t={t}
/>
)}
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
);
})}
{draft && (() => {
const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
const property = properties.find((p) => p.id === draft.propertyId);
const validOperators = property
? getOperatorsForType(property.type)
: OPERATORS.map((op) => op.value);
const operatorOptions = OPERATORS.filter((op) =>
validOperators.includes(op.value),
).map((op) => ({ value: op.value, label: t(op.labelKey) }));
return (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={propertyOptions}
searchable
openOnFocus={false}
nothingFoundMessage={t("No match")}
value={draft.propertyId}
onChange={handleDraftPropertyChange}
style={{ flex: 1 }}
/>
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={operatorOptions}
searchable
openOnFocus={false}
nothingFoundMessage={t("No match")}
value={draft.op}
onChange={handleDraftOperatorChange}
w={130}
/>
{needsValue && (
<FilterValueInput
condition={draft}
property={property}
onChange={handleDraftValueChange}
t={t}
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
{!draft && (
<UnstyledButton
onClick={handleStartDraft}
className={viewClasses.addActionButton}
>
<IconPlus size={14} />
{t("Add filter")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,158 @@
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 { useTranslation } from "react-i18next";
import cellClasses from "@/ee/base/styles/cells.module.css";
import viewClasses from "@/ee/base/styles/views.module.css";
type ViewPropertyVisibilityProps = {
opened: boolean;
onClose: () => void;
table: Table<IBaseRow>;
properties: IBaseProperty[];
onPersist: () => void;
children: React.ReactNode;
};
export function ViewPropertyVisibility({
opened,
onClose,
table,
properties,
onPersist,
children,
}: ViewPropertyVisibilityProps) {
const { t } = useTranslation();
const columns = useMemo(() => {
return table
.getAllLeafColumns()
.filter((col) => col.id !== "__row_number");
}, [table, properties]);
const allVisible = columns.every((col) => col.getIsVisible());
const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
const handleToggle = useCallback(
(columnId: string, visible: boolean) => {
const col = table.getColumn(columnId);
if (!col) return;
col.toggleVisibility(visible);
onPersist();
},
[table, onPersist],
);
const handleShowAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(true);
}
});
onPersist();
}, [columns, onPersist]);
const handleHideAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(false);
}
});
onPersist();
}, [columns, onPersist]);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={4}>
<Group justify="space-between" px={4} py={2}>
<Text size="xs" fw={600} c="dimmed">
{t("Properties")}
</Text>
<Group gap={8}>
<UnstyledButton
onClick={handleShowAll}
disabled={allVisible}
style={{ opacity: allVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Show all")}
</Text>
</UnstyledButton>
<UnstyledButton
onClick={handleHideAll}
disabled={noneVisible}
style={{ opacity: noneVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Hide all")}
</Text>
</UnstyledButton>
</Group>
</Group>
<Divider />
<Stack gap={0}>
{columns.map((col) => {
const property = col.columnDef.meta?.property as IBaseProperty | undefined;
if (!property) return null;
const canHide = col.getCanHide();
const isVisible = col.getIsVisible();
const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
const TypeIcon = typeConfig?.icon;
return (
<UnstyledButton
key={col.id}
className={cellClasses.menuItem}
onClick={() => {
if (canHide) {
handleToggle(col.id, !isVisible);
}
}}
style={{ opacity: canHide ? 1 : 0.5 }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" className={viewClasses.fieldNameText}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
disabled={!canHide}
onChange={() => {}}
// Clicking the track synthesizes a second click on the hidden input which bubbles
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
// synthetic input click so handleToggle fires exactly once.
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,43 @@
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/ee/base/types/base.types";
import { BaseTable } from "@/ee/base/components/base-table";
type ViewRendererProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
isFiltered: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode;
};
export function ViewRenderer(props: ViewRendererProps) {
const viewType = props.effectiveView?.type ?? "table";
if (viewType === "table") {
return <BaseTable {...props} />;
}
// Kanban not yet implemented; fall back to table to avoid blank page.
return <BaseTable {...props} />;
}
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useState } from "react";
import {
Popover,
Stack,
Group,
Select,
ActionIcon,
Text,
UnstyledButton,
Button,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
IBaseProperty,
ViewSortConfig,
} from "@/ee/base/types/base.types";
import { useTranslation } from "react-i18next";
import viewClasses from "@/ee/base/styles/views.module.css";
type ViewSortConfigProps = {
opened: boolean;
onClose: () => void;
sorts: ViewSortConfig[];
properties: IBaseProperty[];
onChange: (sorts: ViewSortConfig[]) => void;
children: React.ReactNode;
};
export function ViewSortConfigPopover({
opened,
onClose,
sorts,
properties,
onChange,
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
// Page props sort by raw UUID; hide until title-based sort is supported.
const sortableProperties = properties.filter((p) => p.type !== "page");
const propertyOptions = sortableProperties.map((p) => ({
value: p.id,
label: p.name,
}));
const directionOptions = [
{ value: "asc", label: t("Ascending") },
{ value: "desc", label: t("Descending") },
];
const handleStartDraft = useCallback(() => {
const usedIds = new Set(sorts.map((s) => s.propertyId));
const available = sortableProperties.find((p) => !usedIds.has(p.id));
if (!available) return;
setDraft({ propertyId: available.id, direction: "asc" });
}, [sorts, sortableProperties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...sorts, draft]);
setDraft(null);
}, [draft, sorts, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleRemove = useCallback(
(index: number) => {
onChange(sorts.filter((_, i) => i !== index));
},
[sorts, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
onChange(
sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
);
},
[sorts, onChange],
);
const handleDirectionChange = useCallback(
(index: number, direction: string | null) => {
if (!direction) return;
onChange(
sorts.map((s, i) =>
i === index
? { ...s, direction: direction as "asc" | "desc" }
: s,
),
);
},
[sorts, onChange],
);
const canAddMore =
sortableProperties.length > sorts.length + (draft ? 1 : 0);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sort by")}
</Text>
{sorts.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No sorts applied")}
</Text>
)}
{sorts.map((sort, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={propertyOptions}
value={sort.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={directionOptions}
value={sort.direction}
onChange={(val) => handleDirectionChange(index, val)}
w={110}
/>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
{draft && (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={propertyOptions}
value={draft.propertyId}
onChange={(val) =>
val && setDraft({ ...draft, propertyId: val })
}
style={{ flex: 1 }}
/>
<Select
size="xs"
comboboxProps={{ withinPortal: false }}
data={directionOptions}
value={draft.direction}
onChange={(val) =>
val &&
setDraft({
...draft,
direction: val as "asc" | "desc",
})
}
w={110}
/>
</Group>
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
)}
{!draft && canAddMore && (
<UnstyledButton
onClick={handleStartDraft}
className={viewClasses.addActionButton}
>
<IconPlus size={14} />
{t("Add sort")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,404 @@
import {
useState,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import {
Group,
UnstyledButton,
Text,
ActionIcon,
Tooltip,
TextInput,
Popover,
Stack,
Divider,
} from "@mantine/core";
import {
IconPlus,
IconPencil,
IconTrash,
IconTable,
IconLink,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { IBaseView } from "@/ee/base/types/base.types";
import {
useUpdateViewMutation,
useDeleteViewMutation,
} from "@/ee/base/queries/base-view-query";
import { useTranslation } from "react-i18next";
import cellClasses from "@/ee/base/styles/cells.module.css";
import { useBaseEditable } from "@/ee/base/context/base-editable";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
const VIEW_DRAG_TYPE = "base-view";
type ViewTabsProps = {
views: IBaseView[];
activeViewId: string | undefined;
pageId: string;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
/** Standalone base-page link for a view, used by "Copy link to view". */
getViewShareUrl?: (viewId: string) => string | null;
};
export function ViewTabs({
views,
activeViewId,
pageId,
onViewChange,
onAddView,
getViewShareUrl,
}: ViewTabsProps) {
const { t } = useTranslation();
const editable = useBaseEditable();
const [editingViewId, setEditingViewId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const updateViewMutation = useUpdateViewMutation();
const deleteViewMutation = useDeleteViewMutation();
const orderedViews = useMemo(
() =>
[...views].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[views],
);
const handleReorder = useCallback(
(sourceId: string, targetId: string, edge: Edge) => {
if (sourceId === targetId) return;
const remaining = orderedViews.filter((v) => v.id !== sourceId);
const targetIndex = remaining.findIndex((v) => v.id === targetId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (edge === "left") {
lowerPos =
targetIndex > 0 ? remaining[targetIndex - 1]?.position : null;
upperPos = remaining[targetIndex]?.position ?? null;
} else {
lowerPos = remaining[targetIndex]?.position ?? null;
upperPos =
targetIndex < remaining.length - 1
? remaining[targetIndex + 1]?.position
: null;
}
try {
const position =
lowerPos && upperPos && lowerPos === upperPos
? generateJitteredKeyBetween(lowerPos, null)
: generateJitteredKeyBetween(lowerPos, upperPos);
updateViewMutation.mutate({ viewId: sourceId, pageId, position });
} catch {
// Position computation failed; skip the reorder.
}
},
[orderedViews, pageId, updateViewMutation],
);
const handleRenameStart = useCallback(
(view: IBaseView) => {
setEditingViewId(view.id);
setEditingName(view.name);
},
[],
);
const handleRenameCommit = useCallback(() => {
if (!editingViewId) return;
const trimmed = editingName.trim();
const view = views.find((v) => v.id === editingViewId);
if (trimmed && view && trimmed !== view.name) {
updateViewMutation.mutate({
viewId: editingViewId,
pageId,
name: trimmed,
});
}
setEditingViewId(null);
}, [editingViewId, editingName, views, pageId, updateViewMutation]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameCommit();
}
if (e.key === "Escape") {
e.preventDefault();
setEditingViewId(null);
}
},
[handleRenameCommit],
);
const handleDelete = useCallback(
(viewId: string) => {
if (orderedViews.length <= 1) return;
deleteViewMutation.mutate({ viewId, pageId });
if (viewId === activeViewId) {
const remaining = orderedViews.filter((v) => v.id !== viewId);
onViewChange(remaining[0].id);
}
},
[orderedViews, pageId, activeViewId, deleteViewMutation, onViewChange],
);
return (
<Group gap={4}>
{orderedViews.map((view) => (
<ViewTab
key={view.id}
view={view}
isActive={view.id === activeViewId}
isEditing={view.id === editingViewId}
editingName={editingName}
canDelete={orderedViews.length > 1}
multipleViews={orderedViews.length > 1}
reorderEnabled={editable && orderedViews.length > 1}
onReorder={handleReorder}
onClick={() => onViewChange(view.id)}
onRenameStart={() => handleRenameStart(view)}
onRenameChange={setEditingName}
onRenameCommit={handleRenameCommit}
onRenameKeyDown={handleRenameKeyDown}
onDelete={() => handleDelete(view.id)}
getViewShareUrl={getViewShareUrl}
/>
))}
{onAddView && (
<Tooltip label={t("Add view")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onAddView}
>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
function ViewTab({
view,
isActive,
isEditing,
editingName,
canDelete,
multipleViews,
reorderEnabled,
onReorder,
onClick,
onRenameStart,
onRenameChange,
onRenameCommit,
onRenameKeyDown,
onDelete,
getViewShareUrl,
}: {
view: IBaseView;
isActive: boolean;
isEditing: boolean;
editingName: string;
canDelete: boolean;
multipleViews: boolean;
reorderEnabled: boolean;
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
onClick: () => void;
onRenameStart: () => void;
onRenameChange: (name: string) => void;
onRenameCommit: () => void;
onRenameKeyDown: (e: React.KeyboardEvent) => void;
onDelete: () => void;
getViewShareUrl?: (viewId: string) => string | null;
}) {
const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false);
const editable = useBaseEditable();
const tabRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
const el = tabRef.current;
if (!el || !reorderEnabled || isEditing) return;
return combine(
draggable({
element: el,
getInitialData: () => ({ type: VIEW_DRAG_TYPE, viewId: view.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: el,
canDrop: ({ source }) =>
source.data.type === VIEW_DRAG_TYPE &&
source.data.viewId !== view.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ viewId: view.id },
{ input, element, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(source.data.viewId as string, view.id, edge);
},
}),
);
}, [view.id, reorderEnabled, isEditing]);
const handleTabClick = useCallback(() => {
if (isActive) {
setMenuOpened((o) => !o);
} else {
onClick();
}
}, [isActive, onClick]);
const handleCopyLink = useCallback(() => {
setMenuOpened(false);
const url = getViewShareUrl?.(view.id);
if (!url) return;
void navigator.clipboard.writeText(url);
notifications.show({ message: t("Link copied to clipboard") });
}, [getViewShareUrl, view.id, t]);
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
);
}
return (
<div
ref={tabRef}
style={{
position: "relative",
display: "inline-flex",
opacity: isDragging ? 0.4 : 1,
}}
>
<Popover
opened={menuOpened}
onChange={setMenuOpened}
position="bottom-start"
shadow="md"
width={180}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>
<UnstyledButton
onClick={handleTabClick}
style={{
padding: "2px 10px",
borderRadius: "var(--mantine-radius-xl)",
fontWeight: isActive ? 600 : 400,
backgroundColor:
isActive && multipleViews
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
: undefined,
}}
>
<Group gap={6} wrap="nowrap">
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
{view.name}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={4}>
<Stack gap={0}>
{editable && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onRenameStart();
}}
>
<Group gap={8} wrap="nowrap">
<IconPencil size={14} />
<Text size="sm">{t("Rename")}</Text>
</Group>
</UnstyledButton>
)}
{getViewShareUrl && (
<UnstyledButton
className={cellClasses.menuItem}
onClick={handleCopyLink}
>
<Group gap={8} wrap="nowrap">
<IconLink size={14} />
<Text size="sm">{t("Copy link to view")}</Text>
</Group>
</UnstyledButton>
)}
{editable && canDelete && (
<>
<Divider my={4} />
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onDelete();
}}
style={{ color: "var(--mantine-color-red-6)" }}
>
<Group gap={8} wrap="nowrap">
<IconTrash size={14} />
<Text size="sm">{t("Delete view")}</Text>
</Group>
</UnstyledButton>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -0,0 +1,39 @@
export type Currency = { code: string; name: string };
// Most-used first; order drives the dropdown.
export const CURRENCIES: Currency[] = [
{ code: "USD", name: "US Dollar" },
{ code: "EUR", name: "Euro" },
{ code: "GBP", name: "Pound" },
{ code: "CAD", name: "Canadian dollar" },
{ code: "AUD", name: "Australian dollar" },
{ code: "SGD", name: "Singapore dollar" },
{ code: "JPY", name: "Yen" },
{ code: "CNY", name: "Chinese Yuan" },
];
export const DEFAULT_CURRENCY_CODE = "USD";
const CURRENCY_CODES = new Set(CURRENCIES.map((c) => c.code));
// Renders value with locale symbol and grouping. Falls back to USD for unknown codes,
// plain string if Intl throws. precision overrides the currency's natural decimal places.
export function formatCurrency(
value: number,
code: string | undefined,
precision: number | undefined,
): string {
const currency =
code && CURRENCY_CODES.has(code) ? code : DEFAULT_CURRENCY_CODE;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
...(precision != null
? { minimumFractionDigits: precision, maximumFractionDigits: precision }
: {}),
}).format(value);
} catch {
return String(value);
}
}
@@ -0,0 +1,22 @@
import { createContext, useContext, type ReactNode } from "react";
const BaseEditableContext = createContext<boolean>(true);
export function BaseEditableProvider({
editable,
children,
}: {
editable: boolean;
children: ReactNode;
}) {
return (
<BaseEditableContext.Provider value={editable}>
{children}
</BaseEditableContext.Provider>
);
}
/** Whether the current base subtree is editable. Defaults to true outside a provider. */
export function useBaseEditable(): boolean {
return useContext(BaseEditableContext);
}
@@ -0,0 +1,12 @@
import { createContext, useContext } from "react";
// Row order is only needed at interaction time (shift-select range math), so
// rows subscribe to a stable getter instead of the array itself — appending a
// page must not re-render every mounted row.
const GridRowOrderContext = createContext<() => string[]>(() => []);
export const GridRowOrderProvider = GridRowOrderContext.Provider;
export function useGridRowOrder(): () => string[] {
return useContext(GridRowOrderContext);
}
@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
// Rows only need the handler at click time; a stable context value keeps the
// expand affordance out of every GridRow/GridCell memo equality check.
const RowExpandContext = createContext<((rowId: string) => void) | null>(null);
export const RowExpandProvider = RowExpandContext.Provider;
export function useRowExpand(): ((rowId: string) => void) | null {
return useContext(RowExpandContext);
}
@@ -0,0 +1,363 @@
import { useEffect } from "react";
import { useAtomValue, getDefaultStore } from "jotai";
import { useQueryClient, InfiniteData } from "@tanstack/react-query";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
} from "@/ee/base/types/base.types";
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
import { IPagination } from "@/lib/types";
type BaseRowCreated = {
operation: "base:row:created";
pageId: string;
row: IBaseRow;
requestId?: string | null;
};
type BaseRowUpdated = {
operation: "base:row:updated";
pageId: string;
rowId: string;
updatedCells: Record<string, unknown>;
requestId?: string | null;
};
type BaseRowDeleted = {
operation: "base:row:deleted";
pageId: string;
rowId: string;
requestId?: string | null;
};
type BaseRowsDeleted = {
operation: "base:rows:deleted";
pageId: string;
rowIds: string[];
requestId?: string | null;
};
type BaseRowReordered = {
operation: "base:row:reordered";
pageId: string;
rowId: string;
position: string;
requestId?: string | null;
};
type BasePropertyEvent = {
operation:
| "base:property:created"
| "base:property:updated"
| "base:property:deleted"
| "base:property:reordered";
pageId: string;
property?: IBaseProperty;
propertyId?: string;
requestId?: string | null;
};
type BaseViewEvent = {
operation:
| "base:view:created"
| "base:view:updated"
| "base:view:deleted";
pageId: string;
view?: IBaseView;
viewId?: string;
};
type BaseRowsUpdated = {
operation: "base:rows:updated";
pageId: string;
rowIds: string[];
propertyIds: string[];
requestId?: string | null;
};
type BaseFormulaRecomputeStarted = {
operation: "base:formula:recompute:started";
pageId: string;
propertyIds: string[];
jobId: string;
};
type BaseFormulaRecomputeCompleted = {
operation: "base:formula:recompute:completed";
pageId: string;
propertyIds: string[];
jobId: string;
processed: number;
errored: number;
};
type BaseSchemaBumped = {
operation: "base:schema:bumped";
pageId: string;
schemaVersion: number;
};
type BaseSubscribed = {
operation: "base:subscribed";
pageId: string;
schemaVersion: number;
};
type BaseInboundEvent =
| BaseRowCreated
| BaseRowUpdated
| BaseRowDeleted
| BaseRowsDeleted
| BaseRowReordered
| BaseRowsUpdated
| BaseFormulaRecomputeStarted
| BaseFormulaRecomputeCompleted
| BaseSchemaBumped
| BaseSubscribed
| BasePropertyEvent
| BaseViewEvent
| { operation: string; pageId: string };
// Module-level set of requestIds we've just sent. When the socket echoes back
// a mutation with a matching requestId we drop it, as the local mutation
// already updated the cache. Bounded to prevent unbounded growth on long tabs.
const outboundRequestIds = new Set<string>();
const OUTBOUND_MAX = 256;
export function markRequestIdOutbound(requestId: string): void {
outboundRequestIds.add(requestId);
if (outboundRequestIds.size > OUTBOUND_MAX) {
const oldest = outboundRequestIds.values().next().value;
if (oldest) outboundRequestIds.delete(oldest);
}
}
// Realtime bridge for a single base. Joins the base-{pageId} room on mount,
// leaves on unmount, and reconciles React Query caches on inbound events.
export function useBaseSocket(pageId: string | undefined): void {
const socket = useAtomValue(socketAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!socket || !pageId) return;
socket.emit("message", { operation: "base:subscribe", pageId });
const handler = (raw: unknown) => {
if (!raw || typeof raw !== "object") return;
const event = raw as BaseInboundEvent;
if (event.pageId !== pageId) return;
const requestId = (event as any).requestId as string | undefined;
if (requestId && outboundRequestIds.has(requestId)) {
outboundRequestIds.delete(requestId);
return;
}
switch (event.operation) {
case "base:row:created": {
const e = event as BaseRowCreated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) =>
index === lastPageIndex
? { ...page, items: [...page.items, e.row] }
: page,
),
};
},
);
break;
}
case "base:row:updated": {
const e = event as BaseRowUpdated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? {
...row,
cells: { ...row.cells, ...e.updatedCells },
}
: row,
),
})),
},
);
break;
}
case "base:row:deleted": {
const e = event as BaseRowDeleted;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== e.rowId),
})),
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.has(e.rowId)) {
const next = new Set(current);
next.delete(e.rowId);
store.set(selectedIdsAtom, next);
}
break;
}
case "base:rows:deleted": {
const e = event as BaseRowsDeleted;
const removeSet = new Set(e.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
);
const store = getDefaultStore();
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
const current = store.get(selectedIdsAtom);
if (current.size > 0) {
let changed = false;
const next = new Set(current);
for (const id of e.rowIds) {
if (next.delete(id)) changed = true;
}
if (changed) store.set(selectedIdsAtom, next);
}
break;
}
case "base:row:reordered": {
const e = event as BaseRowReordered;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", pageId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === e.rowId
? { ...row, position: e.position }
: row,
),
})),
},
);
break;
}
case "base:rows:updated": {
const e = event as BaseRowsUpdated;
// Only refetch if the batch touches rows currently in cache; formula
// backfills emit one event per 500 rows so this avoids redundant fetches.
const updatedIds = new Set(e.rowIds);
const caches = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", pageId] });
let touchesCache = false;
outer: for (const [, data] of caches) {
if (!data) continue;
for (const page of data.pages) {
for (const row of page.items) {
if (updatedIds.has(row.id)) {
touchesCache = true;
break outer;
}
}
}
}
if (touchesCache) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
}
break;
}
case "base:schema:bumped": {
// Worker committed a type conversion or cell GC; re-fetch under the new schema.
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
case "base:subscribed": {
const e = event as BaseSubscribed;
const cached = queryClient.getQueryData<IBase>(["bases", pageId]);
if (cached && cached.baseSchemaVersion !== e.schemaVersion) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
}
break;
}
case "base:formula:recompute:started": {
const e = event as BaseFormulaRecomputeStarted;
const store = getDefaultStore();
store.set(formulaRecomputeAtom, {
...store.get(formulaRecomputeAtom),
[e.jobId]: e.propertyIds,
});
break;
}
case "base:formula:recompute:completed": {
const e = event as BaseFormulaRecomputeCompleted;
const store = getDefaultStore();
const current = store.get(formulaRecomputeAtom);
if (e.jobId in current) {
const next = { ...current };
delete next[e.jobId];
store.set(formulaRecomputeAtom, next);
}
break;
}
case "base:property:created":
case "base:property:updated":
case "base:property:deleted":
case "base:property:reordered":
case "base:view:created":
case "base:view:updated":
case "base:view:deleted": {
// Schema/metadata events only affect properties/views, not cell data.
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
break;
}
default:
break;
}
};
socket.on("message", handler);
return () => {
socket.off("message", handler);
socket.emit("message", { operation: "base:unsubscribe", pageId });
};
}, [socket, pageId, queryClient]);
}
@@ -0,0 +1,376 @@
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
import { useMediaQuery } from "@mantine/hooks";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
createColumnHelper,
ColumnDef,
SortingState,
ColumnSizingState,
VisibilityState,
ColumnOrderState,
ColumnPinningState,
Table,
} from "@tanstack/react-table";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
ViewConfig,
ViewConfigPatch,
} from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { systemAccessorFor } from "@/ee/base/property-types/property-type.registry";
const DEFAULT_COLUMN_WIDTH = 180;
const MIN_COLUMN_WIDTH = 80;
const MAX_COLUMN_WIDTH = 600;
const ROW_NUMBER_COLUMN_WIDTH = 64;
const columnHelper = createColumnHelper<IBaseRow>();
function buildColumns(properties: IBaseProperty[]): ColumnDef<IBaseRow, unknown>[] {
const rowNumberColumn = columnHelper.display({
id: "__row_number",
header: "#",
size: ROW_NUMBER_COLUMN_WIDTH,
minSize: ROW_NUMBER_COLUMN_WIDTH,
maxSize: ROW_NUMBER_COLUMN_WIDTH,
enableResizing: false,
enableSorting: false,
enableHiding: false,
});
const propertyColumns = properties.map((property) => {
const sysAccessor = systemAccessorFor(property.type);
if (sysAccessor) {
return columnHelper.accessor(sysAccessor, {
id: property.id,
header: property.name,
size: DEFAULT_COLUMN_WIDTH,
minSize: MIN_COLUMN_WIDTH,
maxSize: MAX_COLUMN_WIDTH,
enableResizing: true,
enableSorting: false,
enableHiding: !property.isPrimary,
meta: { property },
});
}
return columnHelper.accessor((row) => row.cells[property.id], {
id: property.id,
header: property.name,
size: DEFAULT_COLUMN_WIDTH,
minSize: MIN_COLUMN_WIDTH,
maxSize: MAX_COLUMN_WIDTH,
enableResizing: true,
enableSorting: true,
enableHiding: !property.isPrimary,
meta: { property },
});
});
return [rowNumberColumn, ...propertyColumns];
}
function buildSortingState(config: ViewConfig | undefined): SortingState {
if (!config?.sorts?.length) return [];
return config.sorts.map((sort) => ({
id: sort.propertyId,
desc: sort.direction === "desc",
}));
}
function buildColumnSizing(
config: ViewConfig | undefined,
): ColumnSizingState {
const sizing: ColumnSizingState = {
__row_number: ROW_NUMBER_COLUMN_WIDTH,
};
if (config?.propertyWidths) {
Object.entries(config.propertyWidths).forEach(([id, width]) => {
sizing[id] = width;
});
}
return sizing;
}
function buildColumnVisibility(
config: ViewConfig | undefined,
properties: IBaseProperty[],
): VisibilityState {
const visibility: VisibilityState = { __row_number: true };
if (config?.hiddenPropertyIds) {
const hiddenSet = new Set(config.hiddenPropertyIds);
properties.forEach((p) => {
visibility[p.id] = !hiddenSet.has(p.id);
});
return visibility;
}
if (config?.visiblePropertyIds?.length) {
const visibleSet = new Set(config.visiblePropertyIds);
properties.forEach((p) => {
visibility[p.id] = visibleSet.has(p.id);
});
return visibility;
}
properties.forEach((p) => {
visibility[p.id] = true;
});
return visibility;
}
function buildColumnOrder(
config: ViewConfig | undefined,
properties: IBaseProperty[],
): ColumnOrderState {
if (config?.propertyOrder?.length) {
const orderSet = new Set(config.propertyOrder);
const missing = properties
.filter((p) => !orderSet.has(p.id))
.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
.map((p) => p.id);
return ["__row_number", ...config.propertyOrder, ...missing];
}
const sorted = [...properties].sort((a, b) => {
if (a.isPrimary) return -1;
if (b.isPrimary) return 1;
return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
});
return ["__row_number", ...sorted.map((p) => p.id)];
}
function buildColumnPinning(
properties: IBaseProperty[],
pinPrimary: boolean,
): ColumnPinningState {
const primary = pinPrimary ? properties.find((p) => p.isPrimary) : undefined;
return {
left: primary ? ["__row_number", primary.id] : ["__row_number"],
right: [],
};
}
export function buildLayoutConfigPatch(table: Table<IBaseRow>): ViewConfigPatch {
const state = table.getState();
const propertyWidths: Record<string, number> = {};
Object.entries(state.columnSizing).forEach(([id, width]) => {
if (id !== "__row_number") {
// Resize state can hold the raw drag value below minSize; rendering
// clamps via getSize(), so persist the clamped value too.
propertyWidths[id] = Math.min(
MAX_COLUMN_WIDTH,
Math.max(MIN_COLUMN_WIDTH, width),
);
}
});
const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number");
const hiddenPropertyIds = Object.entries(state.columnVisibility)
.filter(([id, visible]) => id !== "__row_number" && !visible)
.map(([id]) => id);
return {
propertyWidths,
propertyOrder,
hiddenPropertyIds,
visiblePropertyIds: null,
};
}
export type UseBaseTableResult = {
table: Table<IBaseRow>;
persistViewConfig: () => void;
};
export function useBaseTable(
base: IBase | undefined,
rows: IBaseRow[],
activeView: IBaseView | undefined,
): UseBaseTableResult {
const updateViewMutation = useUpdateViewMutation();
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// While a local edit is pending the reconcile effect preserves local state
// to avoid stomping in-flight toggles. When idle it adopts server state so
// remote updates from other clients (e.g. hiding a column) show up here.
const [hasPendingEdit, setHasPendingEdit] = useState(false);
const properties = useMemo(() => base?.properties ?? [], [base?.properties]);
const viewConfig = activeView?.config;
const columns = useMemo(
() => buildColumns(properties),
[properties],
);
const initialSorting = useMemo(
() => buildSortingState(viewConfig),
[viewConfig],
);
const derivedColumnSizing = useMemo(
() => buildColumnSizing(viewConfig),
[viewConfig],
);
const derivedColumnOrder = useMemo(
() => buildColumnOrder(viewConfig, properties),
[viewConfig, properties],
);
const derivedColumnVisibility = useMemo(
() => buildColumnVisibility(viewConfig, properties),
[viewConfig, properties],
);
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(derivedColumnSizing);
// Re-seed from the server only on view switch. Within the same view local
// state is the source of truth. Without this guard, any ws-driven
// invalidateQueries would land a new derivedColumnVisibility reference and
// overwrite a pending toggle before persistViewConfig flushes it.
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
setColumnSizing(derivedColumnSizing);
return;
}
// Same view: if a local edit is pending, reconcile only the id set so
// new/deleted columns appear without stomping the user's toggle.
// If no edit is pending, adopt server state so remote updates show up.
const validIds = new Set<string>(["__row_number"]);
for (const p of properties) validIds.add(p.id);
if (hasPendingEdit) {
setColumnOrder((prev) => {
const prevSet = new Set(prev);
const kept = prev.filter((id) => validIds.has(id));
const appended = derivedColumnOrder.filter(
(id) => !prevSet.has(id) && validIds.has(id),
);
if (appended.length === 0 && kept.length === prev.length) return prev;
return [...kept, ...appended];
});
setColumnVisibility((prev) => {
let changed = false;
const next: VisibilityState = {};
for (const [id, visible] of Object.entries(prev)) {
if (validIds.has(id)) {
next[id] = visible;
} else {
changed = true;
}
}
for (const id of derivedColumnOrder) {
if (!(id in next)) {
next[id] = derivedColumnVisibility[id] ?? true;
changed = true;
}
}
return changed ? next : prev;
});
setColumnSizing((prev) => {
let changed = false;
const next: ColumnSizingState = {};
for (const [id, width] of Object.entries(prev)) {
if (validIds.has(id)) {
next[id] = width;
} else {
changed = true;
}
}
return changed ? next : prev;
});
} else {
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
setColumnSizing(derivedColumnSizing);
}
}, [
activeView?.id,
derivedColumnOrder,
derivedColumnVisibility,
derivedColumnSizing,
properties,
hasPendingEdit,
]);
const isMobile = useMediaQuery("(max-width: 48em)", false, {
getInitialValueInEffect: false,
});
const columnPinning = useMemo(
() => buildColumnPinning(properties, !isMobile),
[properties, isMobile],
);
const table = useReactTable({
data: rows,
columns,
state: {
columnPinning,
columnOrder,
columnVisibility,
columnSizing,
},
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
initialState: {
sorting: initialSorting,
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
columnResizeMode: "onChange",
enableColumnResizing: true,
enableSorting: true,
enableHiding: true,
getRowId: (row) => row.id,
});
const persistViewConfig = useCallback(() => {
if (!activeView || !base) return;
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
}
setHasPendingEdit(true);
persistTimerRef.current = setTimeout(() => {
persistTimerRef.current = null;
const config = buildLayoutConfigPatch(table);
updateViewMutation.mutate(
{ viewId: activeView.id, pageId: base.id, config },
{
onSettled: () => {
// Only clear if no new debounce was scheduled while in flight.
if (persistTimerRef.current === null) {
setHasPendingEdit(false);
}
},
},
);
}, 300);
}, [activeView, base, table, updateViewMutation]);
return { table, persistViewConfig };
}
@@ -0,0 +1,26 @@
import { useEffect, useRef, useCallback } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow } from "@/ee/base/types/base.types";
export function useColumnResize(
table: Table<IBaseRow>,
onResizeEnd: () => void,
) {
const wasResizingRef = useRef(false);
const checkResizeEnd = useCallback(() => {
const isResizing = table.getState().columnSizingInfo.isResizingColumn;
if (wasResizingRef.current && !isResizing) {
onResizeEnd();
}
wasResizingRef.current = !!isResizing;
}, [table, onResizeEnd]);
useEffect(() => {
checkResizeEnd();
});
return {
isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
};
}

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