mirror of
https://github.com/docmost/docmost.git
synced 2026-06-13 19:19:53 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
# Base `page` Property Type — Design Spec
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Draft
|
||||
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
|
||||
|
||||
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
|
||||
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
|
||||
- **Creating pages from within the picker.**
|
||||
- **Cross-workspace page linking.**
|
||||
- **Rich previews / hover cards** showing page excerpts — pill-only.
|
||||
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
|
||||
|
||||
## UX overview
|
||||
|
||||
### Picker (edit mode)
|
||||
|
||||
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
|
||||
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
|
||||
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
|
||||
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
|
||||
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
|
||||
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
|
||||
- Esc / click-outside → `onCancel`.
|
||||
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
|
||||
- Keyboard: reuse `useListKeyboardNav`.
|
||||
|
||||
### View mode
|
||||
|
||||
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
|
||||
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
|
||||
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
|
||||
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
|
||||
|
||||
### Sort / filter UI
|
||||
|
||||
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
|
||||
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
|
||||
|
||||
## Data model
|
||||
|
||||
### Cell value
|
||||
|
||||
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
|
||||
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
|
||||
|
||||
### Property type options
|
||||
|
||||
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
|
||||
- **Future:** `{ allowMultiple?: boolean }`.
|
||||
|
||||
### Schema additions
|
||||
|
||||
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
|
||||
|
||||
```ts
|
||||
export const BasePropertyType = {
|
||||
// ...existing entries...
|
||||
PAGE: 'page',
|
||||
} as const;
|
||||
|
||||
// typeOptionsSchemaMap
|
||||
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||
|
||||
// cellValueSchemaMap
|
||||
[BasePropertyType.PAGE]: z.uuid(),
|
||||
```
|
||||
|
||||
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
|
||||
|
||||
```ts
|
||||
export type BasePropertyType = ... | 'page';
|
||||
export type PageTypeOptions = Record<string, never>;
|
||||
```
|
||||
|
||||
### Property kind & engine
|
||||
|
||||
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
|
||||
|
||||
```ts
|
||||
export const PropertyKind = {
|
||||
// ...existing...
|
||||
PAGE: 'page',
|
||||
} as const;
|
||||
|
||||
// propertyKind()
|
||||
case BasePropertyType.PAGE:
|
||||
return PropertyKind.PAGE;
|
||||
```
|
||||
|
||||
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
|
||||
|
||||
- `isEmpty` / `isNotEmpty` → `textCell` is null or empty
|
||||
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
|
||||
- `any` → `textCell IN (...)`
|
||||
- `none` → `textCell NOT IN (...)` or null
|
||||
|
||||
Wired into the `switch (kind)` in `buildCondition`:
|
||||
```ts
|
||||
case PropertyKind.PAGE:
|
||||
return pageCondition(eb, cond);
|
||||
```
|
||||
|
||||
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
|
||||
|
||||
### Type conversion
|
||||
|
||||
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
|
||||
|
||||
```ts
|
||||
export type CellConversionContext = {
|
||||
fromTypeOptions?: unknown;
|
||||
userNames?: Map<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>; // NEW
|
||||
};
|
||||
```
|
||||
|
||||
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
|
||||
|
||||
**`attemptCellConversion` branches:**
|
||||
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
|
||||
- `page → *` (anything else): return `{converted: true, value: null}`.
|
||||
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
|
||||
|
||||
## Server: page resolver endpoint
|
||||
|
||||
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
|
||||
|
||||
### `POST /bases/pages/resolve`
|
||||
|
||||
**Request:**
|
||||
```ts
|
||||
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```ts
|
||||
{
|
||||
items: Array<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string };
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
1. Deduplicate input IDs.
|
||||
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
|
||||
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
|
||||
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
|
||||
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
|
||||
|
||||
### Code layout
|
||||
|
||||
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
|
||||
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
|
||||
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
|
||||
|
||||
## Client: cell component & resolver
|
||||
|
||||
### Batch resolver hook
|
||||
|
||||
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
|
||||
|
||||
```ts
|
||||
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
|
||||
```
|
||||
|
||||
- Deduplicate + sort IDs to form a stable React Query key.
|
||||
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
|
||||
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
|
||||
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
|
||||
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
|
||||
|
||||
### Cell component
|
||||
|
||||
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
|
||||
|
||||
```ts
|
||||
type CellPageProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
|
||||
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
|
||||
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
|
||||
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
|
||||
|
||||
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
|
||||
|
||||
```ts
|
||||
const cellComponents = {
|
||||
// ...existing...
|
||||
page: CellPage,
|
||||
};
|
||||
```
|
||||
|
||||
### Property type picker
|
||||
|
||||
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
|
||||
|
||||
```ts
|
||||
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
|
||||
```
|
||||
|
||||
### Filter editor
|
||||
|
||||
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
|
||||
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
|
||||
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
|
||||
|
||||
### Sort editor
|
||||
|
||||
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
|
||||
|
||||
## Testing
|
||||
|
||||
### Server — unit
|
||||
|
||||
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
|
||||
- **Conversion:**
|
||||
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
|
||||
- Same call with empty `pageTitles` → `""`.
|
||||
- `page → number/date/select/…` → `{converted: true, value: null}`.
|
||||
- `text → page` with any string input → `{converted: true, value: null}`.
|
||||
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
|
||||
|
||||
### Server — integration
|
||||
|
||||
- **Resolver endpoint `POST /bases/pages/resolve`:**
|
||||
- valid IDs in an accessible space → present in `items`
|
||||
- deleted pages (trash) → absent
|
||||
- pages in a space the user isn't a member of → absent
|
||||
- pages in another workspace → absent
|
||||
- empty array → 400
|
||||
- array length > 100 → 400
|
||||
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
|
||||
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
|
||||
|
||||
### Client — unit (Vitest + React Testing Library)
|
||||
|
||||
- `cell-page.test.tsx`:
|
||||
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
|
||||
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
|
||||
- double-click opens picker
|
||||
- Enter on highlighted result commits `pageId`
|
||||
- Esc cancels
|
||||
- Remove tag button commits `null`
|
||||
- `base-page-resolver-query.test.ts`:
|
||||
- dedupes IDs
|
||||
- stable query key across re-renders with same set
|
||||
- missing IDs render as `null` in the returned map
|
||||
|
||||
### Manual QA checklist
|
||||
|
||||
- Link a page in the same space.
|
||||
- Link a page in another space → pill shows, picker shows muted space-name hint.
|
||||
- Remove link → cell empties.
|
||||
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
|
||||
- Viewer loses space access → same "Page not found" fallback.
|
||||
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
|
||||
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
|
||||
- Conversion `page → text` populates cells with page titles.
|
||||
- Conversion `text → page` wipes cells.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
|
||||
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
|
||||
- Existing bases continue to work unchanged.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
|
||||
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
|
||||
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
|
||||
|
||||
## Future extension (multiple pages per cell)
|
||||
|
||||
When `allowMultiple` lands:
|
||||
|
||||
1. Widen cell-value schema: `z.uuid()` → `z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
|
||||
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
|
||||
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
|
||||
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
|
||||
5. No data writes required for existing cells.
|
||||
|
||||
This spec leaves room for that change without locking the storage shape.
|
||||
@@ -0,0 +1,479 @@
|
||||
# Base View Draft (Local-First Filter & Sort) — Design Spec
|
||||
|
||||
**Date:** 2026-04-20
|
||||
**Status:** Draft
|
||||
**Feature area:** `apps/client/src/features/base` (client-only)
|
||||
|
||||
## Goal
|
||||
|
||||
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
|
||||
|
||||
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
|
||||
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
|
||||
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
|
||||
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
|
||||
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
|
||||
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
|
||||
|
||||
## UX overview
|
||||
|
||||
### Draft banner
|
||||
|
||||
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
|
||||
|
||||
Render condition: `isDirty === true` (see "Dirty check").
|
||||
|
||||
Layout (match the reference screenshot):
|
||||
|
||||
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
|
||||
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
|
||||
- Right region (a `<Group gap="sm">`):
|
||||
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
|
||||
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
|
||||
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
|
||||
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
|
||||
|
||||
### Filter/sort editors in draft mode
|
||||
|
||||
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
|
||||
|
||||
### Reset behavior
|
||||
|
||||
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
|
||||
|
||||
### Save for everyone
|
||||
|
||||
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
|
||||
|
||||
### Permission gating
|
||||
|
||||
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
|
||||
|
||||
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
|
||||
|
||||
## Data model
|
||||
|
||||
### localStorage key
|
||||
|
||||
```
|
||||
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
|
||||
```
|
||||
|
||||
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
|
||||
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
|
||||
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
|
||||
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
|
||||
|
||||
### Value shape
|
||||
|
||||
```ts
|
||||
// apps/client/src/features/base/types/base.types.ts (additive)
|
||||
export type BaseViewDraft = {
|
||||
filter?: FilterGroup;
|
||||
sorts?: ViewSortConfig[];
|
||||
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
|
||||
};
|
||||
```
|
||||
|
||||
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
|
||||
|
||||
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
|
||||
|
||||
## Client architecture
|
||||
|
||||
### Storage atom family
|
||||
|
||||
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
|
||||
|
||||
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
|
||||
|
||||
```ts
|
||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||
import { BaseViewDraft } from "@/features/base/types/base.types";
|
||||
|
||||
export type ViewDraftKey = {
|
||||
userId: string;
|
||||
baseId: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
const keyFor = (k: ViewDraftKey) =>
|
||||
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
|
||||
|
||||
export const viewDraftAtomFamily = atomFamily(
|
||||
(k: ViewDraftKey) =>
|
||||
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
|
||||
(a, b) =>
|
||||
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
|
||||
);
|
||||
```
|
||||
|
||||
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
|
||||
|
||||
### Hook: `useViewDraft`
|
||||
|
||||
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
|
||||
|
||||
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
|
||||
|
||||
```ts
|
||||
export type ViewDraftState = {
|
||||
draft: BaseViewDraft | null;
|
||||
effectiveFilter: FilterGroup | undefined;
|
||||
effectiveSorts: ViewSortConfig[] | undefined;
|
||||
isDirty: boolean;
|
||||
setFilter: (filter: FilterGroup | undefined) => void;
|
||||
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
reset: () => void;
|
||||
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
|
||||
};
|
||||
|
||||
export function useViewDraft(args: {
|
||||
userId: string | undefined;
|
||||
baseId: string | undefined;
|
||||
viewId: string | undefined;
|
||||
baselineFilter: FilterGroup | undefined;
|
||||
baselineSorts: ViewSortConfig[] | undefined;
|
||||
}): ViewDraftState;
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
|
||||
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
|
||||
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
|
||||
4. `reset()` is `setDraft(RESET)`.
|
||||
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
|
||||
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
|
||||
|
||||
**Return composition:**
|
||||
|
||||
- `effectiveFilter = draft?.filter ?? baselineFilter`
|
||||
- `effectiveSorts = draft?.sorts ?? baselineSorts`
|
||||
|
||||
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
|
||||
|
||||
### Integration into `useBaseTable` and `base-table.tsx`
|
||||
|
||||
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
|
||||
|
||||
Two cut options were considered:
|
||||
|
||||
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
|
||||
|
||||
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
|
||||
|
||||
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
|
||||
|
||||
```ts
|
||||
// NEW: wire the draft hook
|
||||
const { data: user } = useCurrentUser();
|
||||
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
|
||||
useViewDraft({
|
||||
userId: user?.user.id,
|
||||
baseId,
|
||||
viewId: activeView?.id,
|
||||
baselineFilter: activeView?.config?.filter,
|
||||
baselineSorts: activeView?.config?.sorts,
|
||||
});
|
||||
|
||||
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
|
||||
const effectiveView = useMemo(
|
||||
() =>
|
||||
activeView
|
||||
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
|
||||
: undefined,
|
||||
[activeView, effectiveFilter, effectiveSorts],
|
||||
);
|
||||
|
||||
// Row query reads effective filter/sorts.
|
||||
const { data: rowsData, ... } = useBaseRowsQuery(
|
||||
base ? baseId : undefined,
|
||||
effectiveFilter,
|
||||
effectiveSorts,
|
||||
);
|
||||
|
||||
// Table is seeded from effectiveView for rendering, but the auto-persist
|
||||
// write-path uses the real `activeView.config` as the baseline so draft
|
||||
// filter/sort values can never leak into a column-layout save.
|
||||
// See "Filter & sort write-path changes" below for the exact mechanism.
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
|
||||
baselineConfig: activeView?.config,
|
||||
});
|
||||
```
|
||||
|
||||
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
|
||||
|
||||
### Filter & sort write-path changes
|
||||
|
||||
Today, filter/sort editors feed `BaseToolbar`'s handlers:
|
||||
|
||||
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })` → `updateViewMutation.mutate(...)`.
|
||||
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
|
||||
|
||||
Both write directly to the server. That's the exact site to branch.
|
||||
|
||||
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
|
||||
|
||||
```ts
|
||||
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
|
||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||
```
|
||||
|
||||
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
|
||||
|
||||
```ts
|
||||
const handleSortsChange = useCallback(
|
||||
(newSorts: ViewSortConfig[]) => {
|
||||
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
|
||||
},
|
||||
[onDraftSortsChange],
|
||||
);
|
||||
```
|
||||
|
||||
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
|
||||
|
||||
**`base-table.tsx`** wires those callbacks to the draft hook:
|
||||
|
||||
```ts
|
||||
const handleDraftSortsChange = useCallback(
|
||||
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
|
||||
[setSorts],
|
||||
);
|
||||
const handleDraftFiltersChange = useCallback(
|
||||
(filter: FilterGroup | undefined) => setFilter(filter),
|
||||
[setFilter],
|
||||
);
|
||||
```
|
||||
|
||||
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
|
||||
|
||||
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
|
||||
|
||||
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
|
||||
|
||||
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
|
||||
|
||||
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
|
||||
|
||||
```ts
|
||||
const config = buildViewConfigFromTable(table, activeView.config, {
|
||||
sorts: activeView.config?.sorts,
|
||||
filter: activeView.config?.filter,
|
||||
});
|
||||
```
|
||||
|
||||
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
|
||||
|
||||
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
|
||||
|
||||
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
|
||||
|
||||
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
|
||||
|
||||
## Banner component
|
||||
|
||||
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
|
||||
|
||||
```ts
|
||||
type BaseViewDraftBannerProps = {
|
||||
isDirty: boolean;
|
||||
canSave: boolean;
|
||||
onReset: () => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!isDirty) return null;
|
||||
return (
|
||||
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconInfoCircle size={16} />
|
||||
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
|
||||
{canSave && (
|
||||
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
|
||||
|
||||
```ts
|
||||
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!activeView || !base) return;
|
||||
const config = buildPromotedConfig(activeView.config);
|
||||
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||
reset();
|
||||
notifications.show({ message: t("View updated for everyone") });
|
||||
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
|
||||
|
||||
return (
|
||||
<div style={{...}}>
|
||||
<BaseViewDraftBanner
|
||||
isDirty={isDirty}
|
||||
canSave={canSave}
|
||||
onReset={reset}
|
||||
onSave={handleSaveDraft}
|
||||
saving={updateViewMutation.isPending}
|
||||
/>
|
||||
<BaseToolbar ... />
|
||||
<GridContainer ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
|
||||
|
||||
## Cross-tab sync
|
||||
|
||||
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
|
||||
|
||||
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
|
||||
|
||||
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
|
||||
|
||||
## Save flow (pseudocode)
|
||||
|
||||
```ts
|
||||
async function onSaveForEveryone() {
|
||||
if (!activeView || !base) return;
|
||||
// 1. Compose the promoted config from the server baseline + draft values.
|
||||
// baseline is activeView.config (NOT effectiveView.config) because the
|
||||
// baseline might include layout fields (propertyWidths, propertyOrder,
|
||||
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
|
||||
const config: ViewConfig = {
|
||||
...activeView.config,
|
||||
filter: draft.filter ?? activeView.config.filter,
|
||||
sorts: draft.sorts ?? activeView.config.sorts,
|
||||
};
|
||||
// 2. Fire the existing mutation. `updateViewMutation` already:
|
||||
// - optimistically updates the ["bases", baseId] query cache
|
||||
// - rolls back on error
|
||||
// - writes the server response back on success
|
||||
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||
// 3. Clear the draft. Because the baseline has now caught up to what the
|
||||
// draft said, isDirty flips to false and the banner unmounts.
|
||||
reset();
|
||||
notifications.show({ message: t("View updated for everyone") });
|
||||
}
|
||||
```
|
||||
|
||||
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
|
||||
|
||||
## Dirty check
|
||||
|
||||
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
|
||||
|
||||
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
|
||||
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
|
||||
|
||||
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
|
||||
|
||||
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
|
||||
2. Hand-written structural compare — overkill for two types with known finite shapes.
|
||||
|
||||
Go with option 1. Helpers live in `use-view-draft.ts`:
|
||||
|
||||
```ts
|
||||
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
```
|
||||
|
||||
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
|
||||
|
||||
## Testing
|
||||
|
||||
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
|
||||
|
||||
### Unit tests (proposed, Vitest — gated on harness being added)
|
||||
|
||||
`use-view-draft.test.ts`:
|
||||
|
||||
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
|
||||
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
|
||||
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
|
||||
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
|
||||
- **`reset` clears both state and storage.**
|
||||
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
|
||||
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X` — `isDirty` flips to `false` without draft being cleared.
|
||||
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
|
||||
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
|
||||
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
|
||||
|
||||
### Manual QA checklist
|
||||
|
||||
**Single user, single tab.**
|
||||
- Apply a filter. Banner appears. Row list updates locally.
|
||||
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
|
||||
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
|
||||
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
|
||||
|
||||
**Single user, multiple tabs.**
|
||||
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
|
||||
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
|
||||
|
||||
**Multi-user baseline race.**
|
||||
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
|
||||
|
||||
**Permission gating.**
|
||||
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
|
||||
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
|
||||
|
||||
**Reset with popover open.**
|
||||
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
|
||||
|
||||
**Save clears draft + updates server.**
|
||||
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
|
||||
|
||||
**Browser storage cleared.**
|
||||
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **No DB migration.** No server change.
|
||||
- **No feature flag.** Behavior change ships as-is.
|
||||
- **No data migration.** Existing users have no drafts; the system starts empty.
|
||||
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
|
||||
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
|
||||
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
|
||||
|
||||
## Risks & open questions
|
||||
|
||||
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 5–10MB per-origin cap, but practically negligible. No cleanup logic needed.
|
||||
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
|
||||
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
|
||||
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
|
||||
1. "Save for everyone" (current behavior)
|
||||
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
|
||||
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
|
||||
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
|
||||
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
|
||||
|
||||
## Future extension
|
||||
|
||||
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
|
||||
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
|
||||
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
|
||||
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user