Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 12bd213236 fix 2026-04-13 23:53:16 +01:00
185 changed files with 89 additions and 26497 deletions
File diff suppressed because it is too large Load Diff
@@ -1,309 +0,0 @@
# Base `page` Property Type — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
## Goal
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
## Non-goals (v1)
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
- **Creating pages from within the picker.**
- **Cross-workspace page linking.**
- **Rich previews / hover cards** showing page excerpts — pill-only.
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
## UX overview
### Picker (edit mode)
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
- Esc / click-outside → `onCancel`.
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
- Keyboard: reuse `useListKeyboardNav`.
### View mode
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
### Sort / filter UI
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
## Data model
### Cell value
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
### Property type options
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
- **Future:** `{ allowMultiple?: boolean }`.
### Schema additions
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
```ts
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
```
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
```ts
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
```
### Property kind & engine
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
```ts
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
```
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
- `isEmpty` / `isNotEmpty``textCell` is null or empty
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
- `any``textCell IN (...)`
- `none``textCell NOT IN (...)` or null
Wired into the `switch (kind)` in `buildCondition`:
```ts
case PropertyKind.PAGE:
return pageCondition(eb, cond);
```
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
### Type conversion
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
```ts
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
```
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
**`attemptCellConversion` branches:**
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
- `page → *` (anything else): return `{converted: true, value: null}`.
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
## Server: page resolver endpoint
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
### `POST /bases/pages/resolve`
**Request:**
```ts
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
```
**Response:**
```ts
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
```
### Behavior
1. Deduplicate input IDs.
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
### Code layout
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
## Client: cell component & resolver
### Batch resolver hook
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
```ts
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
```
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
### Cell component
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
```ts
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
```
**Behavior:**
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
```ts
const cellComponents = {
// ...existing...
page: CellPage,
};
```
### Property type picker
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
```ts
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
```
### Filter editor
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
### Sort editor
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
## Testing
### Server — unit
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
- **Conversion:**
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
- Same call with empty `pageTitles``""`.
- `page → number/date/select/…``{converted: true, value: null}`.
- `text → page` with any string input → `{converted: true, value: null}`.
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
### Server — integration
- **Resolver endpoint `POST /bases/pages/resolve`:**
- valid IDs in an accessible space → present in `items`
- deleted pages (trash) → absent
- pages in a space the user isn't a member of → absent
- pages in another workspace → absent
- empty array → 400
- array length > 100 → 400
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
### Client — unit (Vitest + React Testing Library)
- `cell-page.test.tsx`:
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
- double-click opens picker
- Enter on highlighted result commits `pageId`
- Esc cancels
- Remove tag button commits `null`
- `base-page-resolver-query.test.ts`:
- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as `null` in the returned map
### Manual QA checklist
- Link a page in the same space.
- Link a page in another space → pill shows, picker shows muted space-name hint.
- Remove link → cell empties.
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
- Viewer loses space access → same "Page not found" fallback.
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
- Conversion `page → text` populates cells with page titles.
- Conversion `text → page` wipes cells.
## Rollout
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
- Existing bases continue to work unchanged.
## Risks & open questions
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
## Future extension (multiple pages per cell)
When `allowMultiple` lands:
1. Widen cell-value schema: `z.uuid()``z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
5. No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.
@@ -1,479 +0,0 @@
# Base View Draft (Local-First Filter & Sort) — Design Spec
**Date:** 2026-04-20
**Status:** Draft
**Feature area:** `apps/client/src/features/base` (client-only)
## Goal
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
## Non-goals (v1)
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
## UX overview
### Draft banner
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
Render condition: `isDirty === true` (see "Dirty check").
Layout (match the reference screenshot):
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
- Right region (a `<Group gap="sm">`):
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
### Filter/sort editors in draft mode
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
### Reset behavior
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
### Save for everyone
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
### Permission gating
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
## Data model
### localStorage key
```
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
```
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
### Value shape
```ts
// apps/client/src/features/base/types/base.types.ts (additive)
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
};
```
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
## Client architecture
### Storage atom family
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
```ts
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
const keyFor = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
```
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
### Hook: `useViewDraft`
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
```ts
export type ViewDraftState = {
draft: BaseViewDraft | null;
effectiveFilter: FilterGroup | undefined;
effectiveSorts: ViewSortConfig[] | undefined;
isDirty: boolean;
setFilter: (filter: FilterGroup | undefined) => void;
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
reset: () => void;
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
};
export function useViewDraft(args: {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
}): ViewDraftState;
```
**Behavior:**
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
4. `reset()` is `setDraft(RESET)`.
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
**Return composition:**
- `effectiveFilter = draft?.filter ?? baselineFilter`
- `effectiveSorts = draft?.sorts ?? baselineSorts`
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
### Integration into `useBaseTable` and `base-table.tsx`
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
Two cut options were considered:
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
```ts
// NEW: wire the draft hook
const { data: user } = useCurrentUser();
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
useViewDraft({
userId: user?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
const effectiveView = useMemo(
() =>
activeView
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Row query reads effective filter/sorts.
const { data: rowsData, ... } = useBaseRowsQuery(
base ? baseId : undefined,
effectiveFilter,
effectiveSorts,
);
// Table is seeded from effectiveView for rendering, but the auto-persist
// write-path uses the real `activeView.config` as the baseline so draft
// filter/sort values can never leak into a column-layout save.
// See "Filter & sort write-path changes" below for the exact mechanism.
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
```
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
### Filter & sort write-path changes
Today, filter/sort editors feed `BaseToolbar`'s handlers:
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })``updateViewMutation.mutate(...)`.
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
Both write directly to the server. That's the exact site to branch.
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
```ts
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
```
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
},
[onDraftSortsChange],
);
```
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
**`base-table.tsx`** wires those callbacks to the draft hook:
```ts
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
[setSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => setFilter(filter),
[setFilter],
);
```
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
```ts
const config = buildViewConfigFromTable(table, activeView.config, {
sorts: activeView.config?.sorts,
filter: activeView.config?.filter,
});
```
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
## Banner component
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
```ts
type BaseViewDraftBannerProps = {
isDirty: boolean;
canSave: boolean;
onReset: () => void;
onSave: () => void;
saving: boolean;
};
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
const { t } = useTranslation();
if (!isDirty) return null;
return (
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<IconInfoCircle size={16} />
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
{canSave && (
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
)}
</Group>
</Group>
</Paper>
);
}
```
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
```ts
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
const updateViewMutation = useUpdateViewMutation();
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
const config = buildPromotedConfig(activeView.config);
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
reset();
notifications.show({ message: t("View updated for everyone") });
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
return (
<div style={{...}}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={reset}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar ... />
<GridContainer ... />
</div>
);
```
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
## Cross-tab sync
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
## Save flow (pseudocode)
```ts
async function onSaveForEveryone() {
if (!activeView || !base) return;
// 1. Compose the promoted config from the server baseline + draft values.
// baseline is activeView.config (NOT effectiveView.config) because the
// baseline might include layout fields (propertyWidths, propertyOrder,
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
const config: ViewConfig = {
...activeView.config,
filter: draft.filter ?? activeView.config.filter,
sorts: draft.sorts ?? activeView.config.sorts,
};
// 2. Fire the existing mutation. `updateViewMutation` already:
// - optimistically updates the ["bases", baseId] query cache
// - rolls back on error
// - writes the server response back on success
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
// 3. Clear the draft. Because the baseline has now caught up to what the
// draft said, isDirty flips to false and the banner unmounts.
reset();
notifications.show({ message: t("View updated for everyone") });
}
```
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
## Dirty check
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
2. Hand-written structural compare — overkill for two types with known finite shapes.
Go with option 1. Helpers live in `use-view-draft.ts`:
```ts
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
```
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
## Testing
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
### Unit tests (proposed, Vitest — gated on harness being added)
`use-view-draft.test.ts`:
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
- **`reset` clears both state and storage.**
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X``isDirty` flips to `false` without draft being cleared.
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
### Manual QA checklist
**Single user, single tab.**
- Apply a filter. Banner appears. Row list updates locally.
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
**Single user, multiple tabs.**
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
**Multi-user baseline race.**
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped``["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
**Permission gating.**
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
**Reset with popover open.**
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
**Save clears draft + updates server.**
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
**Browser storage cleared.**
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
## Rollout
- **No DB migration.** No server change.
- **No feature flag.** Behavior change ships as-is.
- **No data migration.** Existing users have no drafts; the system starts empty.
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
## Risks & open questions
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 510MB per-origin cap, but practically negligible. No cleanup logic needed.
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
1. "Save for everyone" (current behavior)
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
## Future extension
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
-3
View File
@@ -43,9 +43,6 @@ POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
+2 -8
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.80.0",
"version": "0.71.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -11,10 +11,6 @@
},
"dependencies": {
"@casl/react": "^5.0.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -27,9 +23,7 @@
"@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.99.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"axios": "1.15.0",
"blueimp-load-image": "^5.16.0",
@@ -222,8 +222,6 @@
"Edit comment": "Kommentar bearbeiten",
"Delete comment": "Kommentar löschen",
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
"Delete chat": "Chat löschen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"Comment created successfully": "Kommentar erfolgreich erstellt",
"Error creating comment": "Fehler beim Erstellen des Kommentars",
"Comment updated successfully": "Kommentar erfolgreich aktualisiert",
@@ -741,93 +739,6 @@
"Removed page restriction": "Seitenbeschränkung entfernt",
"Added page permission": "Seitenberechtigung hinzugefügt",
"Removed page permission": "Seitenberechtigung entfernt",
"day": "Tag",
"days": "Tage",
"week": "Woche",
"weeks": "Wochen",
"month": "Monat",
"months": "Monate",
"year": "Jahr",
"years": "Jahre",
"Period": "Zeitraum",
"Fixed date": "Festes Datum",
"Indefinitely": "Unbegrenzt",
"Days": "Tage",
"Weeks": "Wochen",
"Months": "Monate",
"Years": "Jahre",
"Pick a date": "Datum auswählen",
"Maximum is {{max}} {{unit}} for this unit": "Das Maximum für diese Einheit beträgt {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Läuft nie ab. Prüfer können die Seite jederzeit erneut verifizieren.",
"Verified": "Verifiziert",
"Review needed": "Prüfung erforderlich",
"Verification expired": "Verifizierung abgelaufen",
"Draft": "Entwurf",
"In Approval": "In Genehmigung",
"In approval": "In Genehmigung",
"Approved": "Genehmigt",
"Obsolete": "Veraltet",
"Expiring": "Läuft bald ab",
"Set up verification": "Verifizierung einrichten",
"Verify page": "Seite verifizieren",
"Page verification": "Seitenverifizierung",
"Add verification": "Verifizierung hinzufügen",
"Edit verification": "Verifizierung bearbeiten",
"Search by title": "Nach Titel suchen",
"Choose how this page should stay accurate.": "Wählen Sie aus, wie diese Seite aktuell gehalten werden soll.",
"Recurring verification": "Wiederkehrende Verifizierung",
"Verifiers re-confirm this page on a schedule.": "Prüfer bestätigen diese Seite nach einem Zeitplan erneut.",
"Re-verify on a schedule (e.g every 30 days )": "Nach einem Zeitplan erneut verifizieren (z. B. alle 30 Tage)",
"Page stays editable at all times": "Die Seite bleibt jederzeit bearbeitbar",
"Best for runbooks, FAQs, living documentation": "Am besten für Runbooks, FAQs und lebende Dokumentation geeignet",
"Approval workflow": "Genehmigungsworkflow",
"Formal document lifecycle with named approvers.": "Formaler Dokumentenlebenszyklus mit benannten Genehmigern.",
"Draft → In approval → Approved → Obsolete": "Entwurf → In Genehmigung → Genehmigt → Veraltet",
"Locked once approved, with full history": "Nach der Genehmigung gesperrt, mit vollständiger Historie",
"Designed for ISO 9001, ISO 13485, and FDA": "Entwickelt für ISO 9001, ISO 13485 und FDA",
"Best for SOPs and controlled documents": "Am besten für SOPs und kontrollierte Dokumente geeignet",
"Back": "Zurück",
"Quality management": "Qualitätsmanagement",
"Recurring": "Wiederkehrend",
"Pages move through draft, approval, and approved stages.": "Seiten durchlaufen die Phasen Entwurf, Genehmigung und Genehmigt.",
"Verifiers": "Prüfer",
"Add verifier": "Prüfer hinzufügen",
"I've reviewed this page for accuracy": "Ich habe diese Seite auf Richtigkeit geprüft",
"Set up": "Einrichten",
"Remove verification": "Verifizierung entfernen",
"Are you sure you want to remove verification from this page?": "Möchten Sie die Verifizierung wirklich von dieser Seite entfernen?",
"Assigned verifiers must periodically re-verify this page.": "Zugewiesene Prüfer müssen diese Seite regelmäßig erneut verifizieren.",
"Last verified by {{name}} {{time}} (expired)": "Zuletzt von {{name}} {{time}} verifiziert (abgelaufen)",
"The fixed expiration date has passed.": "Das feste Ablaufdatum ist überschritten.",
"Verified by {{name}} {{time}}": "Verifiziert von {{name}} {{time}}",
"Expires {{date}}": "Läuft ab am {{date}}",
"Expired {{date}}": "Abgelaufen am {{date}}",
"Mark as obsolete": "Als veraltet markieren",
"Mark obsolete": "Als veraltet markieren",
"Returned by {{name}} {{time}}": "Zurückgegeben von {{name}} {{time}}",
"No approval has been requested yet.": "Es wurde noch keine Genehmigung angefordert.",
"Submitted by {{name}} {{time}}": "Eingereicht von {{name}} {{time}}",
"Someone": "Jemand",
"Approved by {{name}} {{time}}": "Genehmigt von {{name}} {{time}}",
"This document has been marked as obsolete.": "Dieses Dokument wurde als veraltet markiert.",
"Rejection comment": "Ablehnungskommentar",
"Reason for returning this document...": "Grund für die Rückgabe dieses Dokuments...",
"Confirm rejection": "Ablehnung bestätigen",
"Submit for approval": "Zur Genehmigung einreichen",
"Reject": "Ablehnen",
"Approve": "Genehmigen",
"Re-submit for approval": "Erneut zur Genehmigung einreichen",
"Verified until": "Verifiziert bis",
"QMS": "QMS",
"Verified pages": "Verifizierte Seiten",
"Search pages...": "Seiten suchen...",
"Filter by space": "Nach Bereich filtern",
"Filter by type": "Nach Typ filtern",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> hat eine Seite verifiziert",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> hat eine Seite zu Ihrer Genehmigung eingereicht",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> hat eine Seite zur Überarbeitung zurückgegeben",
"Page verification expires soon": "Die Seitenverifizierung läuft bald ab",
"Page verification has expired": "Die Seitenverifizierung ist abgelaufen",
"Verifying your email": "Ihre E-Mail wird bestätigt",
"Please wait...": "Bitte warten...",
"Verification failed. The link may have expired.": "Überprüfung fehlgeschlagen. Der Link ist möglicherweise abgelaufen.",
@@ -222,8 +222,6 @@
"Edit comment": "Edit comment",
"Delete comment": "Delete comment",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Delete chat": "Delete chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
"Comment created successfully": "Comment created successfully",
"Error creating comment": "Error creating comment",
"Comment updated successfully": "Comment updated successfully",
@@ -222,8 +222,6 @@
"Edit comment": "Editar comentario",
"Delete comment": "Eliminar comentario",
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
"Delete chat": "Eliminar chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}'? Esta acción no se puede deshacer.",
"Comment created successfully": "Comentario creado con éxito",
"Error creating comment": "Error al crear comentario",
"Comment updated successfully": "Comentario actualizado con éxito",
@@ -741,93 +739,6 @@
"Removed page restriction": "Restricción de página eliminada",
"Added page permission": "Permiso de página añadido",
"Removed page permission": "Permiso de página eliminado",
"day": "día",
"days": "días",
"week": "semana",
"weeks": "semanas",
"month": "mes",
"months": "meses",
"year": "año",
"years": "años",
"Period": "Período",
"Fixed date": "Fecha fija",
"Indefinitely": "Indefinidamente",
"Days": "Días",
"Weeks": "Semanas",
"Months": "Meses",
"Years": "Años",
"Pick a date": "Selecciona una fecha",
"Maximum is {{max}} {{unit}} for this unit": "El máximo es {{max}} {{unit}} para esta unidad",
"Never expires. Verifiers can re-verify at any time.": "Nunca caduca. Los verificadores pueden volver a verificar en cualquier momento.",
"Verified": "Verificado",
"Review needed": "Revisión necesaria",
"Verification expired": "La verificación ha caducado",
"Draft": "Borrador",
"In Approval": "En aprobación",
"In approval": "En aprobación",
"Approved": "Aprobado",
"Obsolete": "Obsoleto",
"Expiring": "Próximo a caducar",
"Set up verification": "Configurar verificación",
"Verify page": "Verificar página",
"Page verification": "Verificación de página",
"Add verification": "Añadir verificación",
"Edit verification": "Editar verificación",
"Search by title": "Buscar por título",
"Choose how this page should stay accurate.": "Elige cómo debe mantenerse precisa esta página.",
"Recurring verification": "Verificación periódica",
"Verifiers re-confirm this page on a schedule.": "Los verificadores vuelven a confirmar esta página según una programación.",
"Re-verify on a schedule (e.g every 30 days )": "Volver a verificar según una programación (p. ej., cada 30 días)",
"Page stays editable at all times": "La página permanece editable en todo momento",
"Best for runbooks, FAQs, living documentation": "Ideal para runbooks, preguntas frecuentes y documentación viva",
"Approval workflow": "Flujo de aprobación",
"Formal document lifecycle with named approvers.": "Ciclo de vida formal del documento con aprobadores designados.",
"Draft → In approval → Approved → Obsolete": "Borrador → En aprobación → Aprobado → Obsoleto",
"Locked once approved, with full history": "Bloqueado una vez aprobado, con historial completo",
"Designed for ISO 9001, ISO 13485, and FDA": "Diseñado para ISO 9001, ISO 13485 y FDA",
"Best for SOPs and controlled documents": "Ideal para SOP y documentos controlados",
"Back": "Atrás",
"Quality management": "Gestión de calidad",
"Recurring": "Periódica",
"Pages move through draft, approval, and approved stages.": "Las páginas pasan por las etapas de borrador, aprobación y aprobado.",
"Verifiers": "Verificadores",
"Add verifier": "Añadir verificador",
"I've reviewed this page for accuracy": "He revisado la exactitud de esta página",
"Set up": "Configurar",
"Remove verification": "Eliminar verificación",
"Are you sure you want to remove verification from this page?": "¿Seguro que quieres eliminar la verificación de esta página?",
"Assigned verifiers must periodically re-verify this page.": "Los verificadores asignados deben volver a verificar esta página periódicamente.",
"Last verified by {{name}} {{time}} (expired)": "Última verificación por {{name}} {{time}} (caducada)",
"The fixed expiration date has passed.": "La fecha fija de vencimiento ya pasó.",
"Verified by {{name}} {{time}}": "Verificado por {{name}} {{time}}",
"Expires {{date}}": "Caduca el {{date}}",
"Expired {{date}}": "Caducó el {{date}}",
"Mark as obsolete": "Marcar como obsoleto",
"Mark obsolete": "Marcar como obsoleto",
"Returned by {{name}} {{time}}": "Devuelto por {{name}} {{time}}",
"No approval has been requested yet.": "Aún no se ha solicitado aprobación.",
"Submitted by {{name}} {{time}}": "Enviado por {{name}} {{time}}",
"Someone": "Alguien",
"Approved by {{name}} {{time}}": "Aprobado por {{name}} {{time}}",
"This document has been marked as obsolete.": "Este documento ha sido marcado como obsoleto.",
"Rejection comment": "Comentario de rechazo",
"Reason for returning this document...": "Motivo de la devolución de este documento...",
"Confirm rejection": "Confirmar rechazo",
"Submit for approval": "Enviar para aprobación",
"Reject": "Rechazar",
"Approve": "Aprobar",
"Re-submit for approval": "Volver a enviar para aprobación",
"Verified until": "Verificado hasta",
"QMS": "SGC",
"Verified pages": "Páginas verificadas",
"Search pages...": "Buscar páginas...",
"Filter by space": "Filtrar por espacio",
"Filter by type": "Filtrar por tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verificó una página",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> envió una página para tu aprobación",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolvió una página para revisión",
"Page verification expires soon": "La verificación de la página caduca pronto",
"Page verification has expired": "La verificación de la página ha caducado",
"Verifying your email": "Verificando tu correo electrónico",
"Please wait...": "Por favor, espera...",
"Verification failed. The link may have expired.": "La verificación ha fallado. Es posible que el enlace haya expirado.",
@@ -222,8 +222,6 @@
"Edit comment": "Modifier le commentaire",
"Delete comment": "Supprimer le commentaire",
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
"Delete chat": "Supprimer la conversation",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer '{{title}}' ? Cette action est irréversible.",
"Comment created successfully": "Commentaire créé avec succès",
"Error creating comment": "Erreur lors de la création du commentaire",
"Comment updated successfully": "Commentaire mis à jour avec succès",
@@ -741,93 +739,6 @@
"Removed page restriction": "Restriction de la page supprimée",
"Added page permission": "Autorisation de la page ajoutée",
"Removed page permission": "Autorisation de la page supprimée",
"day": "jour",
"days": "jours",
"week": "semaine",
"weeks": "semaines",
"month": "mois",
"months": "mois",
"year": "an",
"years": "ans",
"Period": "Période",
"Fixed date": "Date fixe",
"Indefinitely": "Indéfiniment",
"Days": "Jours",
"Weeks": "Semaines",
"Months": "Mois",
"Years": "Ans",
"Pick a date": "Choisir une date",
"Maximum is {{max}} {{unit}} for this unit": "Le maximum est de {{max}} {{unit}} pour cette unité",
"Never expires. Verifiers can re-verify at any time.": "Nexpire jamais. Les vérificateurs peuvent revérifier à tout moment.",
"Verified": "Vérifié",
"Review needed": "Révision nécessaire",
"Verification expired": "Vérification expirée",
"Draft": "Brouillon",
"In Approval": "En approbation",
"In approval": "En approbation",
"Approved": "Approuvé",
"Obsolete": "Obsolète",
"Expiring": "Expire bientôt",
"Set up verification": "Configurer la vérification",
"Verify page": "Vérifier la page",
"Page verification": "Vérification de la page",
"Add verification": "Ajouter une vérification",
"Edit verification": "Modifier la vérification",
"Search by title": "Rechercher par titre",
"Choose how this page should stay accurate.": "Choisissez comment cette page doit rester exacte.",
"Recurring verification": "Vérification récurrente",
"Verifiers re-confirm this page on a schedule.": "Les vérificateurs reconfirment cette page selon une fréquence définie.",
"Re-verify on a schedule (e.g every 30 days )": "Revérifier selon une fréquence définie (p. ex. tous les 30 jours)",
"Page stays editable at all times": "La page reste modifiable en permanence",
"Best for runbooks, FAQs, living documentation": "Idéal pour les runbooks, FAQ et la documentation évolutive",
"Approval workflow": "Flux dapprobation",
"Formal document lifecycle with named approvers.": "Cycle de vie formel du document avec des approbateurs désignés.",
"Draft → In approval → Approved → Obsolete": "Brouillon → En approbation → Approuvé → Obsolète",
"Locked once approved, with full history": "Verrouillé une fois approuvé, avec historique complet",
"Designed for ISO 9001, ISO 13485, and FDA": "Conçu pour lISO 9001, lISO 13485 et la FDA",
"Best for SOPs and controlled documents": "Idéal pour les SOP et les documents contrôlés",
"Back": "Retour",
"Quality management": "Gestion de la qualité",
"Recurring": "Récurrent",
"Pages move through draft, approval, and approved stages.": "Les pages passent par les étapes brouillon, approbation et approuvé.",
"Verifiers": "Vérificateurs",
"Add verifier": "Ajouter un vérificateur",
"I've reviewed this page for accuracy": "Jai vérifié lexactitude de cette page",
"Set up": "Configurer",
"Remove verification": "Supprimer la vérification",
"Are you sure you want to remove verification from this page?": "Voulez-vous vraiment supprimer la vérification de cette page ?",
"Assigned verifiers must periodically re-verify this page.": "Les vérificateurs assignés doivent revérifier périodiquement cette page.",
"Last verified by {{name}} {{time}} (expired)": "Dernière vérification par {{name}} {{time}} (expirée)",
"The fixed expiration date has passed.": "La date dexpiration fixe est passée.",
"Verified by {{name}} {{time}}": "Vérifié par {{name}} {{time}}",
"Expires {{date}}": "Expire le {{date}}",
"Expired {{date}}": "Expiré le {{date}}",
"Mark as obsolete": "Marquer comme obsolète",
"Mark obsolete": "Marquer comme obsolète",
"Returned by {{name}} {{time}}": "Renvoyé par {{name}} {{time}}",
"No approval has been requested yet.": "Aucune approbation na encore été demandée.",
"Submitted by {{name}} {{time}}": "Soumis par {{name}} {{time}}",
"Someone": "Quelquun",
"Approved by {{name}} {{time}}": "Approuvé par {{name}} {{time}}",
"This document has been marked as obsolete.": "Ce document a été marqué comme obsolète.",
"Rejection comment": "Commentaire de rejet",
"Reason for returning this document...": "Raison du renvoi de ce document...",
"Confirm rejection": "Confirmer le rejet",
"Submit for approval": "Soumettre pour approbation",
"Reject": "Rejeter",
"Approve": "Approuver",
"Re-submit for approval": "Soumettre à nouveau pour approbation",
"Verified until": "Vérifié jusquau",
"QMS": "SMQ",
"Verified pages": "Pages vérifiées",
"Search pages...": "Rechercher des pages...",
"Filter by space": "Filtrer par espace",
"Filter by type": "Filtrer par type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> a vérifié une page",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> a soumis une page à votre approbation",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> a renvoyé une page pour révision",
"Page verification expires soon": "La vérification de la page expire bientôt",
"Page verification has expired": "La vérification de la page a expiré",
"Verifying your email": "Vérification de votre e-mail",
"Please wait...": "Veuillez patienter...",
"Verification failed. The link may have expired.": "Échec de la vérification. Le lien a peut-être expiré.",
@@ -222,8 +222,6 @@
"Edit comment": "Modifica commento",
"Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
"Delete chat": "Elimina chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
"Comment created successfully": "Commento creato con successo",
"Error creating comment": "Si è verificato un errore durante la creazione del commento",
"Comment updated successfully": "Commento aggiornato con successo",
@@ -741,93 +739,6 @@
"Removed page restriction": "Restrizione della pagina rimossa",
"Added page permission": "Permesso sulla pagina aggiunto",
"Removed page permission": "Permesso sulla pagina rimosso",
"day": "giorno",
"days": "giorni",
"week": "settimana",
"weeks": "settimane",
"month": "mese",
"months": "mesi",
"year": "anno",
"years": "anni",
"Period": "Periodo",
"Fixed date": "Data fissa",
"Indefinitely": "A tempo indeterminato",
"Days": "Giorni",
"Weeks": "Settimane",
"Months": "Mesi",
"Years": "Anni",
"Pick a date": "Scegli una data",
"Maximum is {{max}} {{unit}} for this unit": "Il massimo consentito è {{max}} {{unit}} per questa unità",
"Never expires. Verifiers can re-verify at any time.": "Non scade mai. I verificatori possono verificare nuovamente in qualsiasi momento.",
"Verified": "Verificato",
"Review needed": "Revisione necessaria",
"Verification expired": "Verifica scaduta",
"Draft": "Bozza",
"In Approval": "In approvazione",
"In approval": "In approvazione",
"Approved": "Approvato",
"Obsolete": "Obsoleto",
"Expiring": "In scadenza",
"Set up verification": "Configura la verifica",
"Verify page": "Verifica la pagina",
"Page verification": "Verifica della pagina",
"Add verification": "Aggiungi verifica",
"Edit verification": "Modifica verifica",
"Search by title": "Cerca per titolo",
"Choose how this page should stay accurate.": "Scegli come mantenere accurata questa pagina.",
"Recurring verification": "Verifica ricorrente",
"Verifiers re-confirm this page on a schedule.": "I verificatori riconfermano questa pagina secondo una pianificazione.",
"Re-verify on a schedule (e.g every 30 days )": "Verifica nuovamente secondo una pianificazione (ad es. ogni 30 giorni)",
"Page stays editable at all times": "La pagina resta sempre modificabile",
"Best for runbooks, FAQs, living documentation": "Ideale per runbook, FAQ e documentazione dinamica",
"Approval workflow": "Flusso di approvazione",
"Formal document lifecycle with named approvers.": "Ciclo di vita formale del documento con approvatori nominati.",
"Draft → In approval → Approved → Obsolete": "Bozza → In approvazione → Approvato → Obsoleto",
"Locked once approved, with full history": "Bloccato una volta approvato, con cronologia completa",
"Designed for ISO 9001, ISO 13485, and FDA": "Progettato per ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideale per SOP e documenti controllati",
"Back": "Indietro",
"Quality management": "Gestione della qualità",
"Recurring": "Ricorrente",
"Pages move through draft, approval, and approved stages.": "Le pagine passano attraverso le fasi di bozza, approvazione e approvato.",
"Verifiers": "Verificatori",
"Add verifier": "Aggiungi verificatore",
"I've reviewed this page for accuracy": "Ho controllato l'accuratezza di questa pagina",
"Set up": "Configura",
"Remove verification": "Rimuovi verifica",
"Are you sure you want to remove verification from this page?": "Sei sicuro di voler rimuovere la verifica da questa pagina?",
"Assigned verifiers must periodically re-verify this page.": "I verificatori assegnati devono verificare nuovamente questa pagina periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Ultima verifica effettuata da {{name}} {{time}} (scaduta)",
"The fixed expiration date has passed.": "La data di scadenza fissa è trascorsa.",
"Verified by {{name}} {{time}}": "Verificato da {{name}} {{time}}",
"Expires {{date}}": "Scade il {{date}}",
"Expired {{date}}": "Scaduto il {{date}}",
"Mark as obsolete": "Contrassegna come obsoleto",
"Mark obsolete": "Contrassegna come obsoleto",
"Returned by {{name}} {{time}}": "Restituito da {{name}} {{time}}",
"No approval has been requested yet.": "Non è stata ancora richiesta alcuna approvazione.",
"Submitted by {{name}} {{time}}": "Inviato da {{name}} {{time}}",
"Someone": "Qualcuno",
"Approved by {{name}} {{time}}": "Approvato da {{name}} {{time}}",
"This document has been marked as obsolete.": "Questo documento è stato contrassegnato come obsoleto.",
"Rejection comment": "Commento di rifiuto",
"Reason for returning this document...": "Motivo della restituzione di questo documento...",
"Confirm rejection": "Conferma rifiuto",
"Submit for approval": "Invia per approvazione",
"Reject": "Rifiuta",
"Approve": "Approva",
"Re-submit for approval": "Invia nuovamente per approvazione",
"Verified until": "Verificato fino al",
"QMS": "QMS",
"Verified pages": "Pagine verificate",
"Search pages...": "Cerca pagine...",
"Filter by space": "Filtra per spazio",
"Filter by type": "Filtra per tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> ha verificato una pagina",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> ha inviato una pagina per la tua approvazione",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> ha restituito una pagina per la revisione",
"Page verification expires soon": "La verifica della pagina scadrà presto",
"Page verification has expired": "La verifica della pagina è scaduta",
"Verifying your email": "Verifica della tua email in corso",
"Please wait...": "Attendere...",
"Verification failed. The link may have expired.": "Verifica non riuscita. Il link potrebbe essere scaduto.",
@@ -222,8 +222,6 @@
"Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Delete chat": "チャットを削除",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "「{{title}}」を削除してもよろしいですか?この操作は元に戻せません。",
"Comment created successfully": "コメントを作成しました",
"Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメントを更新しました",
@@ -741,93 +739,6 @@
"Removed page restriction": "ページの制限を解除しました",
"Added page permission": "ページの権限を追加しました",
"Removed page permission": "ページの権限を削除しました",
"day": "日",
"days": "日",
"week": "週",
"weeks": "週",
"month": "か月",
"months": "か月",
"year": "年",
"years": "年",
"Period": "期間",
"Fixed date": "指定日",
"Indefinitely": "無期限",
"Days": "日",
"Weeks": "週",
"Months": "か月",
"Years": "年",
"Pick a date": "日付を選択",
"Maximum is {{max}} {{unit}} for this unit": "この単位の最大値は{{max}}{{unit}}です",
"Never expires. Verifiers can re-verify at any time.": "有効期限はありません。検証者はいつでも再検証できます。",
"Verified": "検証済み",
"Review needed": "確認が必要",
"Verification expired": "検証期限切れ",
"Draft": "下書き",
"In Approval": "承認中",
"In approval": "承認中",
"Approved": "承認済み",
"Obsolete": "廃止",
"Expiring": "期限間近",
"Set up verification": "検証を設定",
"Verify page": "ページを検証",
"Page verification": "ページ検証",
"Add verification": "検証を追加",
"Edit verification": "検証を編集",
"Search by title": "タイトルで検索",
"Choose how this page should stay accurate.": "このページの正確性をどのように維持するか選択してください。",
"Recurring verification": "定期検証",
"Verifiers re-confirm this page on a schedule.": "検証者がこのページを定期的に再確認します。",
"Re-verify on a schedule (e.g every 30 days )": "スケジュールに従って再検証(例:30日ごと)",
"Page stays editable at all times": "ページは常に編集可能です",
"Best for runbooks, FAQs, living documentation": "運用手順書、FAQ、継続的に更新されるドキュメントに最適",
"Approval workflow": "承認ワークフロー",
"Formal document lifecycle with named approvers.": "指定された承認者による正式な文書ライフサイクルです。",
"Draft → In approval → Approved → Obsolete": "下書き → 承認中 → 承認済み → 廃止",
"Locked once approved, with full history": "承認後はロックされ、完全な履歴が残ります",
"Designed for ISO 9001, ISO 13485, and FDA": "ISO 9001、ISO 13485、FDA向けに設計",
"Best for SOPs and controlled documents": "SOPや管理文書に最適",
"Back": "戻る",
"Quality management": "品質管理",
"Recurring": "定期",
"Pages move through draft, approval, and approved stages.": "ページは下書き、承認中、承認済みの各段階を進みます。",
"Verifiers": "検証者",
"Add verifier": "検証者を追加",
"I've reviewed this page for accuracy": "このページの正確性を確認しました",
"Set up": "設定",
"Remove verification": "検証を削除",
"Are you sure you want to remove verification from this page?": "このページから検証を削除してもよろしいですか?",
"Assigned verifiers must periodically re-verify this page.": "割り当てられた検証者はこのページを定期的に再検証する必要があります。",
"Last verified by {{name}} {{time}} (expired)": "最終検証者:{{name}} {{time}}(期限切れ)",
"The fixed expiration date has passed.": "指定された有効期限を過ぎています。",
"Verified by {{name}} {{time}}": "{{name}}が{{time}}に検証",
"Expires {{date}}": "有効期限:{{date}}",
"Expired {{date}}": "{{date}}に期限切れ",
"Mark as obsolete": "廃止としてマーク",
"Mark obsolete": "廃止にする",
"Returned by {{name}} {{time}}": "{{name}}が{{time}}に差し戻し",
"No approval has been requested yet.": "まだ承認は依頼されていません。",
"Submitted by {{name}} {{time}}": "{{name}}が{{time}}に提出",
"Someone": "誰か",
"Approved by {{name}} {{time}}": "{{name}}が{{time}}に承認",
"This document has been marked as obsolete.": "この文書は廃止としてマークされています。",
"Rejection comment": "差し戻しコメント",
"Reason for returning this document...": "この文書を差し戻す理由...",
"Confirm rejection": "差し戻しを確定",
"Submit for approval": "承認を申請",
"Reject": "差し戻す",
"Approve": "承認",
"Re-submit for approval": "再度承認を申請",
"Verified until": "検証有効期限",
"QMS": "QMS",
"Verified pages": "検証済みページ",
"Search pages...": "ページを検索...",
"Filter by space": "スペースで絞り込み",
"Filter by type": "タイプで絞り込み",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold>がページを検証しました",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold>があなたの承認のためにページを提出しました",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>がページを修正のため差し戻しました",
"Page verification expires soon": "ページ検証の期限が間もなく切れます",
"Page verification has expired": "ページ検証の期限が切れています",
"Verifying your email": "メールアドレスを確認しています",
"Please wait...": "お待ちください…",
"Verification failed. The link may have expired.": "認証に失敗しました。リンクの有効期限が切れている可能性があります。",
@@ -222,8 +222,6 @@
"Edit comment": "댓글 수정",
"Delete comment": "댓글 삭제",
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료",
"Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료",
@@ -741,93 +739,6 @@
"Removed page restriction": "페이지 제한이 제거됨",
"Added page permission": "페이지 권한이 추가됨",
"Removed page permission": "페이지 권한이 제거됨",
"day": "일",
"days": "일",
"week": "주",
"weeks": "주",
"month": "개월",
"months": "개월",
"year": "년",
"years": "년",
"Period": "기간",
"Fixed date": "고정 날짜",
"Indefinitely": "무기한",
"Days": "일",
"Weeks": "주",
"Months": "개월",
"Years": "년",
"Pick a date": "날짜 선택",
"Maximum is {{max}} {{unit}} for this unit": "이 단위의 최대값은 {{max}} {{unit}}입니다",
"Never expires. Verifiers can re-verify at any time.": "만료되지 않습니다. 검증자는 언제든지 다시 검증할 수 있습니다.",
"Verified": "검증됨",
"Review needed": "검토 필요",
"Verification expired": "검증 만료됨",
"Draft": "초안",
"In Approval": "승인 진행 중",
"In approval": "승인 진행 중",
"Approved": "승인됨",
"Obsolete": "폐기됨",
"Expiring": "만료 예정",
"Set up verification": "검증 설정",
"Verify page": "페이지 검증",
"Page verification": "페이지 검증",
"Add verification": "검증 추가",
"Edit verification": "검증 편집",
"Search by title": "제목으로 검색",
"Choose how this page should stay accurate.": "이 페이지의 정확성을 유지할 방법을 선택하세요.",
"Recurring verification": "반복 검증",
"Verifiers re-confirm this page on a schedule.": "검증자가 일정에 따라 이 페이지를 다시 확인합니다.",
"Re-verify on a schedule (e.g every 30 days )": "일정에 따라 다시 검증(예: 30일마다)",
"Page stays editable at all times": "페이지는 항상 편집 가능합니다",
"Best for runbooks, FAQs, living documentation": "런북, FAQ, 살아 있는 문서에 적합",
"Approval workflow": "승인 워크플로",
"Formal document lifecycle with named approvers.": "지정된 승인자가 있는 공식 문서 수명 주기입니다.",
"Draft → In approval → Approved → Obsolete": "초안 → 승인 진행 중 → 승인됨 → 폐기됨",
"Locked once approved, with full history": "승인되면 잠기며 전체 이력이 유지됩니다",
"Designed for ISO 9001, ISO 13485, and FDA": "ISO 9001, ISO 13485 및 FDA용으로 설계됨",
"Best for SOPs and controlled documents": "SOP 및 관리 문서에 적합",
"Back": "뒤로",
"Quality management": "품질 관리",
"Recurring": "반복",
"Pages move through draft, approval, and approved stages.": "페이지는 초안, 승인, 승인됨 단계를 거칩니다.",
"Verifiers": "검증자",
"Add verifier": "검증자 추가",
"I've reviewed this page for accuracy": "이 페이지의 정확성을 검토했습니다",
"Set up": "설정",
"Remove verification": "검증 제거",
"Are you sure you want to remove verification from this page?": "이 페이지에서 검증을 제거하시겠습니까?",
"Assigned verifiers must periodically re-verify this page.": "지정된 검증자는 이 페이지를 주기적으로 다시 검증해야 합니다.",
"Last verified by {{name}} {{time}} (expired)": "마지막 검증자: {{name}} {{time}} (만료됨)",
"The fixed expiration date has passed.": "고정된 만료일이 지났습니다.",
"Verified by {{name}} {{time}}": "검증자: {{name}} {{time}}",
"Expires {{date}}": "{{date}}에 만료",
"Expired {{date}}": "{{date}}에 만료됨",
"Mark as obsolete": "폐기로 표시",
"Mark obsolete": "폐기 표시",
"Returned by {{name}} {{time}}": "반려자: {{name}} {{time}}",
"No approval has been requested yet.": "아직 승인이 요청되지 않았습니다.",
"Submitted by {{name}} {{time}}": "제출자: {{name}} {{time}}",
"Someone": "누군가",
"Approved by {{name}} {{time}}": "승인자: {{name}} {{time}}",
"This document has been marked as obsolete.": "이 문서는 폐기로 표시되었습니다.",
"Rejection comment": "반려 사유",
"Reason for returning this document...": "이 문서를 반려하는 이유...",
"Confirm rejection": "반려 확인",
"Submit for approval": "승인 요청",
"Reject": "반려",
"Approve": "승인",
"Re-submit for approval": "승인 재요청",
"Verified until": "다음까지 검증됨",
"QMS": "QMS",
"Verified pages": "검증된 페이지",
"Search pages...": "페이지 검색...",
"Filter by space": "스페이스별 필터",
"Filter by type": "유형별 필터",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold>님이 페이지를 검증했습니다",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold>님이 승인을 위해 페이지를 제출했습니다",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>님이 수정을 위해 페이지를 반려했습니다",
"Page verification expires soon": "페이지 검토가 곧 만료됩니다",
"Page verification has expired": "페이지 검토가 만료되었습니다",
"Verifying your email": "이메일 확인 중",
"Please wait...": "잠시만 기다려 주세요...",
"Verification failed. The link may have expired.": "인증에 실패했습니다. 링크가 만료되었을 수 있습니다.",
@@ -222,8 +222,6 @@
"Edit comment": "Bewerk reactie",
"Delete comment": "Verwijder reactie",
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
"Delete chat": "Chat verwijderen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Weet je zeker dat je '{{title}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Comment created successfully": "Reactie succesvol aangemaakt",
"Error creating comment": "Fout bij het aanmaken van reactie",
"Comment updated successfully": "Opmerking succesvol bijgewerkt",
@@ -741,93 +739,6 @@
"Removed page restriction": "Pagina-restrictie verwijderd",
"Added page permission": "Paginatoestemming toegevoegd",
"Removed page permission": "Paginatoestemming verwijderd",
"day": "dag",
"days": "dagen",
"week": "week",
"weeks": "weken",
"month": "maand",
"months": "maanden",
"year": "jaar",
"years": "jaren",
"Period": "Periode",
"Fixed date": "Vaste datum",
"Indefinitely": "Voor onbepaalde tijd",
"Days": "Dagen",
"Weeks": "Weken",
"Months": "Maanden",
"Years": "Jaren",
"Pick a date": "Kies een datum",
"Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} voor deze eenheid",
"Never expires. Verifiers can re-verify at any time.": "Verloopt nooit. Verificateurs kunnen op elk moment opnieuw verifiëren.",
"Verified": "Geverifieerd",
"Review needed": "Beoordeling nodig",
"Verification expired": "Verificatie verlopen",
"Draft": "Concept",
"In Approval": "In goedkeuring",
"In approval": "In goedkeuring",
"Approved": "Goedgekeurd",
"Obsolete": "Verouderd",
"Expiring": "Verloopt binnenkort",
"Set up verification": "Verificatie instellen",
"Verify page": "Pagina verifiëren",
"Page verification": "Paginaverificatie",
"Add verification": "Verificatie toevoegen",
"Edit verification": "Verificatie bewerken",
"Search by title": "Zoeken op titel",
"Choose how this page should stay accurate.": "Kies hoe deze pagina accuraat moet blijven.",
"Recurring verification": "Terugkerende verificatie",
"Verifiers re-confirm this page on a schedule.": "Verificateurs bevestigen deze pagina opnieuw volgens een schema.",
"Re-verify on a schedule (e.g every 30 days )": "Opnieuw verifiëren volgens een schema (bijv. elke 30 dagen)",
"Page stays editable at all times": "Pagina blijft altijd bewerkbaar",
"Best for runbooks, FAQs, living documentation": "Het beste voor runbooks, veelgestelde vragen en levende documentatie",
"Approval workflow": "Goedkeuringsworkflow",
"Formal document lifecycle with named approvers.": "Formele documentlevenscyclus met benoemde goedkeurders.",
"Draft → In approval → Approved → Obsolete": "Concept → In goedkeuring → Goedgekeurd → Verouderd",
"Locked once approved, with full history": "Vergrendeld zodra goedgekeurd, met volledige geschiedenis",
"Designed for ISO 9001, ISO 13485, and FDA": "Ontworpen voor ISO 9001, ISO 13485 en FDA",
"Best for SOPs and controlled documents": "Het beste voor SOP's en beheerde documenten",
"Back": "Terug",
"Quality management": "Kwaliteitsmanagement",
"Recurring": "Terugkerend",
"Pages move through draft, approval, and approved stages.": "Pagina's doorlopen de fasen concept, goedkeuring en goedgekeurd.",
"Verifiers": "Verificateurs",
"Add verifier": "Verificateur toevoegen",
"I've reviewed this page for accuracy": "Ik heb deze pagina op nauwkeurigheid beoordeeld",
"Set up": "Instellen",
"Remove verification": "Verificatie verwijderen",
"Are you sure you want to remove verification from this page?": "Weet je zeker dat je verificatie van deze pagina wilt verwijderen?",
"Assigned verifiers must periodically re-verify this page.": "Toegewezen verificateurs moeten deze pagina periodiek opnieuw verifiëren.",
"Last verified by {{name}} {{time}} (expired)": "Laatst geverifieerd door {{name}} {{time}} (verlopen)",
"The fixed expiration date has passed.": "De vaste vervaldatum is verstreken.",
"Verified by {{name}} {{time}}": "Geverifieerd door {{name}} {{time}}",
"Expires {{date}}": "Verloopt op {{date}}",
"Expired {{date}}": "Verlopen op {{date}}",
"Mark as obsolete": "Markeren als verouderd",
"Mark obsolete": "Markeer als verouderd",
"Returned by {{name}} {{time}}": "Teruggestuurd door {{name}} {{time}}",
"No approval has been requested yet.": "Er is nog geen goedkeuring aangevraagd.",
"Submitted by {{name}} {{time}}": "Ingediend door {{name}} {{time}}",
"Someone": "Iemand",
"Approved by {{name}} {{time}}": "Goedgekeurd door {{name}} {{time}}",
"This document has been marked as obsolete.": "Dit document is als verouderd gemarkeerd.",
"Rejection comment": "Afwijzingsopmerking",
"Reason for returning this document...": "Reden om dit document terug te sturen...",
"Confirm rejection": "Afwijzing bevestigen",
"Submit for approval": "Indienen voor goedkeuring",
"Reject": "Afwijzen",
"Approve": "Goedkeuren",
"Re-submit for approval": "Opnieuw indienen voor goedkeuring",
"Verified until": "Geverifieerd tot",
"QMS": "QMS",
"Verified pages": "Geverifieerde pagina's",
"Search pages...": "Pagina's zoeken...",
"Filter by space": "Filteren op ruimte",
"Filter by type": "Filteren op type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> heeft een pagina geverifieerd",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> heeft een pagina voor jouw goedkeuring ingediend",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> heeft een pagina teruggestuurd voor revisie",
"Page verification expires soon": "Paginaverificatie verloopt binnenkort",
"Page verification has expired": "Paginaverificatie is verlopen",
"Verifying your email": "Je e-mailadres wordt geverifieerd",
"Please wait...": "Even geduld...",
"Verification failed. The link may have expired.": "Verificatie mislukt. De link is mogelijk verlopen.",
@@ -222,8 +222,6 @@
"Edit comment": "Editar comentário",
"Delete comment": "Excluir comentário",
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
"Delete chat": "Excluir chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
"Comment created successfully": "Comentário criado com sucesso",
"Error creating comment": "Erro ao criar comentário",
"Comment updated successfully": "Comentário atualizado com sucesso",
@@ -741,93 +739,6 @@
"Removed page restriction": "Restrição de página removida",
"Added page permission": "Permissão de página adicionada",
"Removed page permission": "Permissão de página removida",
"day": "dia",
"days": "dias",
"week": "semana",
"weeks": "semanas",
"month": "mês",
"months": "meses",
"year": "ano",
"years": "anos",
"Period": "Período",
"Fixed date": "Data fixa",
"Indefinitely": "Indefinidamente",
"Days": "Dias",
"Weeks": "Semanas",
"Months": "Meses",
"Years": "Anos",
"Pick a date": "Escolha uma data",
"Maximum is {{max}} {{unit}} for this unit": "O máximo é {{max}} {{unit}} para esta unidade",
"Never expires. Verifiers can re-verify at any time.": "Nunca expira. Os verificadores podem verificar novamente a qualquer momento.",
"Verified": "Verificado",
"Review needed": "Revisão necessária",
"Verification expired": "A verificação expirou",
"Draft": "Rascunho",
"In Approval": "Em aprovação",
"In approval": "Em aprovação",
"Approved": "Aprovado",
"Obsolete": "Obsoleto",
"Expiring": "Expirando",
"Set up verification": "Configurar verificação",
"Verify page": "Verificar página",
"Page verification": "Verificação da página",
"Add verification": "Adicionar verificação",
"Edit verification": "Editar verificação",
"Search by title": "Pesquisar por título",
"Choose how this page should stay accurate.": "Escolha como esta página deve permanecer precisa.",
"Recurring verification": "Verificação recorrente",
"Verifiers re-confirm this page on a schedule.": "Os verificadores confirmam novamente esta página em uma programação definida.",
"Re-verify on a schedule (e.g every 30 days )": "Verificar novamente em uma programação definida (ex.: a cada 30 dias)",
"Page stays editable at all times": "A página permanece editável o tempo todo",
"Best for runbooks, FAQs, living documentation": "Ideal para runbooks, FAQs e documentação viva",
"Approval workflow": "Fluxo de aprovação",
"Formal document lifecycle with named approvers.": "Ciclo de vida formal do documento com aprovadores nomeados.",
"Draft → In approval → Approved → Obsolete": "Rascunho → Em aprovação → Aprovado → Obsoleto",
"Locked once approved, with full history": "Bloqueado após a aprovação, com histórico completo",
"Designed for ISO 9001, ISO 13485, and FDA": "Desenvolvido para ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideal para POPs e documentos controlados",
"Back": "Voltar",
"Quality management": "Gestão da qualidade",
"Recurring": "Recorrente",
"Pages move through draft, approval, and approved stages.": "As páginas passam pelos estágios de rascunho, aprovação e aprovado.",
"Verifiers": "Verificadores",
"Add verifier": "Adicionar verificador",
"I've reviewed this page for accuracy": "Revisei esta página quanto à precisão",
"Set up": "Configurar",
"Remove verification": "Remover verificação",
"Are you sure you want to remove verification from this page?": "Tem certeza de que deseja remover a verificação desta página?",
"Assigned verifiers must periodically re-verify this page.": "Os verificadores atribuídos devem verificar novamente esta página periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Verificado pela última vez por {{name}} {{time}} (expirado)",
"The fixed expiration date has passed.": "A data fixa de expiração já passou.",
"Verified by {{name}} {{time}}": "Verificado por {{name}} {{time}}",
"Expires {{date}}": "Expira em {{date}}",
"Expired {{date}}": "Expirou em {{date}}",
"Mark as obsolete": "Marcar como obsoleto",
"Mark obsolete": "Marcar como obsoleto",
"Returned by {{name}} {{time}}": "Devolvido por {{name}} {{time}}",
"No approval has been requested yet.": "Nenhuma aprovação foi solicitada ainda.",
"Submitted by {{name}} {{time}}": "Enviado por {{name}} {{time}}",
"Someone": "Alguém",
"Approved by {{name}} {{time}}": "Aprovado por {{name}} {{time}}",
"This document has been marked as obsolete.": "Este documento foi marcado como obsoleto.",
"Rejection comment": "Comentário de rejeição",
"Reason for returning this document...": "Motivo para devolver este documento...",
"Confirm rejection": "Confirmar rejeição",
"Submit for approval": "Enviar para aprovação",
"Reject": "Rejeitar",
"Approve": "Aprovar",
"Re-submit for approval": "Reenviar para aprovação",
"Verified until": "Verificado até",
"QMS": "SGQ",
"Verified pages": "Páginas verificadas",
"Search pages...": "Pesquisar páginas...",
"Filter by space": "Filtrar por espaço",
"Filter by type": "Filtrar por tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verificou uma página",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> enviou uma página para sua aprovação",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolveu uma página para revisão",
"Page verification expires soon": "A verificação da página expirará em breve",
"Page verification has expired": "A verificação da página expirou",
"Verifying your email": "Verificando seu e-mail",
"Please wait...": "Por favor, aguarde...",
"Verification failed. The link may have expired.": "Falha na verificação. O link pode ter expirado.",
@@ -222,8 +222,6 @@
"Edit comment": "Редактировать комментарий",
"Delete comment": "Удалить комментарий",
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
"Delete chat": "Удалить чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите удалить '{{title}}'? Это действие нельзя отменить.",
"Comment created successfully": "Комментарий успешно создан",
"Error creating comment": "Ошибка при создании комментария",
"Comment updated successfully": "Комментарий успешно обновлён",
@@ -741,93 +739,6 @@
"Removed page restriction": "Ограничение доступа к странице удалено",
"Added page permission": "Добавлено разрешение доступа к странице",
"Removed page permission": "Удалено разрешение доступа к странице",
"day": "день",
"days": "дни",
"week": "неделя",
"weeks": "недели",
"month": "месяц",
"months": "месяцы",
"year": "год",
"years": "годы",
"Period": "Период",
"Fixed date": "Фиксированная дата",
"Indefinitely": "Бессрочно",
"Days": "Дни",
"Weeks": "Недели",
"Months": "Месяцы",
"Years": "Годы",
"Pick a date": "Выберите дату",
"Maximum is {{max}} {{unit}} for this unit": "Максимум для этой единицы измерения — {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Срок действия не истекает. Проверяющие могут повторно подтверждать в любое время.",
"Verified": "Проверено",
"Review needed": "Требуется проверка",
"Verification expired": "Срок проверки истёк",
"Draft": "Черновик",
"In Approval": "На утверждении",
"In approval": "На утверждении",
"Approved": "Утверждено",
"Obsolete": "Устарело",
"Expiring": "Истекает",
"Set up verification": "Настроить проверку",
"Verify page": "Проверить страницу",
"Page verification": "Проверка страницы",
"Add verification": "Добавить проверку",
"Edit verification": "Изменить проверку",
"Search by title": "Поиск по заголовку",
"Choose how this page should stay accurate.": "Выберите, как поддерживать актуальность этой страницы.",
"Recurring verification": "Регулярная проверка",
"Verifiers re-confirm this page on a schedule.": "Проверяющие повторно подтверждают эту страницу по расписанию.",
"Re-verify on a schedule (e.g every 30 days )": "Повторно проверять по расписанию (например, каждые 30 дней)",
"Page stays editable at all times": "Страница остаётся редактируемой в любое время",
"Best for runbooks, FAQs, living documentation": "Лучше всего подходит для инструкций, FAQ и живой документации",
"Approval workflow": "Процесс утверждения",
"Formal document lifecycle with named approvers.": "Формальный жизненный цикл документа с назначенными утверждающими.",
"Draft → In approval → Approved → Obsolete": "Черновик → На утверждении → Утверждено → Устарело",
"Locked once approved, with full history": "После утверждения блокируется, с полной историей",
"Designed for ISO 9001, ISO 13485, and FDA": "Разработано для ISO 9001, ISO 13485 и FDA",
"Best for SOPs and controlled documents": "Лучше всего подходит для СОП и контролируемых документов",
"Back": "Назад",
"Quality management": "Управление качеством",
"Recurring": "Регулярно",
"Pages move through draft, approval, and approved stages.": "Страницы проходят стадии черновика, утверждения и утверждённого состояния.",
"Verifiers": "Проверяющие",
"Add verifier": "Добавить проверяющего",
"I've reviewed this page for accuracy": "Я проверил(а) эту страницу на точность",
"Set up": "Настроить",
"Remove verification": "Удалить проверку",
"Are you sure you want to remove verification from this page?": "Вы уверены, что хотите удалить проверку с этой страницы?",
"Assigned verifiers must periodically re-verify this page.": "Назначенные проверяющие должны периодически повторно проверять эту страницу.",
"Last verified by {{name}} {{time}} (expired)": "Последняя проверка: {{name}}, {{time}} (срок истёк)",
"The fixed expiration date has passed.": "Фиксированная дата истечения срока уже прошла.",
"Verified by {{name}} {{time}}": "Проверено: {{name}}, {{time}}",
"Expires {{date}}": "Истекает {{date}}",
"Expired {{date}}": "Срок истёк {{date}}",
"Mark as obsolete": "Отметить как устаревшее",
"Mark obsolete": "Отметить как устаревшее",
"Returned by {{name}} {{time}}": "Возвращено: {{name}}, {{time}}",
"No approval has been requested yet.": "Запрос на утверждение ещё не отправлен.",
"Submitted by {{name}} {{time}}": "Отправлено: {{name}}, {{time}}",
"Someone": "Кто-то",
"Approved by {{name}} {{time}}": "Утверждено: {{name}}, {{time}}",
"This document has been marked as obsolete.": "Этот документ был отмечен как устаревший.",
"Rejection comment": "Комментарий к отклонению",
"Reason for returning this document...": "Причина возврата этого документа...",
"Confirm rejection": "Подтвердить отклонение",
"Submit for approval": "Отправить на утверждение",
"Reject": "Отклонить",
"Approve": "Утвердить",
"Re-submit for approval": "Повторно отправить на утверждение",
"Verified until": "Проверено до",
"QMS": "QMS",
"Verified pages": "Проверенные страницы",
"Search pages...": "Поиск страниц...",
"Filter by space": "Фильтр по пространству",
"Filter by type": "Фильтр по типу",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> проверил(а) страницу",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> отправил(а) страницу вам на утверждение",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> вернул(а) страницу на доработку",
"Page verification expires soon": "Срок проверки страницы скоро истекает",
"Page verification has expired": "Срок проверки страницы истёк",
"Verifying your email": "Подтверждение вашего адреса электронной почты",
"Please wait...": "Пожалуйста, подождите...",
"Verification failed. The link may have expired.": "Ошибка проверки. Ссылка могла устареть.",
@@ -222,8 +222,6 @@
"Edit comment": "Редагувати коментар",
"Delete comment": "Видалити коментар",
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
"Delete chat": "Видалити чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете видалити '{{title}}'? Цю дію неможливо скасувати.",
"Comment created successfully": "Коментар успішно створено",
"Error creating comment": "Помилка при створенні коментаря",
"Comment updated successfully": "Коментар успішно оновлено",
@@ -741,93 +739,6 @@
"Removed page restriction": "Обмеження сторінки видалено",
"Added page permission": "Додано дозвіл на сторінку",
"Removed page permission": "Дозвіл на сторінку видалено",
"day": "день",
"days": "дні",
"week": "тиждень",
"weeks": "тижні",
"month": "місяць",
"months": "місяці",
"year": "рік",
"years": "роки",
"Period": "Період",
"Fixed date": "Фіксована дата",
"Indefinitely": "Безстроково",
"Days": "Дні",
"Weeks": "Тижні",
"Months": "Місяці",
"Years": "Роки",
"Pick a date": "Виберіть дату",
"Maximum is {{max}} {{unit}} for this unit": "Максимум для цієї одиниці — {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Термін дії не спливає. Верифікатори можуть повторно перевірити будь-коли.",
"Verified": "Перевірено",
"Review needed": "Потрібен перегляд",
"Verification expired": "Термін перевірки сплив",
"Draft": "Чернетка",
"In Approval": "На погодженні",
"In approval": "На погодженні",
"Approved": "Погоджено",
"Obsolete": "Застаріло",
"Expiring": "Термін дії спливає",
"Set up verification": "Налаштувати перевірку",
"Verify page": "Перевірити сторінку",
"Page verification": "Перевірка сторінки",
"Add verification": "Додати перевірку",
"Edit verification": "Редагувати перевірку",
"Search by title": "Пошук за назвою",
"Choose how this page should stay accurate.": "Виберіть, як підтримувати актуальність цієї сторінки.",
"Recurring verification": "Регулярна перевірка",
"Verifiers re-confirm this page on a schedule.": "Верифікатори повторно підтверджують цю сторінку за розкладом.",
"Re-verify on a schedule (e.g every 30 days )": "Повторно перевіряти за розкладом (наприклад, кожні 30 днів)",
"Page stays editable at all times": "Сторінка залишається доступною для редагування в будь-який час",
"Best for runbooks, FAQs, living documentation": "Найкраще підходить для runbook-ів, FAQ і живої документації",
"Approval workflow": "Процес погодження",
"Formal document lifecycle with named approvers.": "Формальний життєвий цикл документа з призначеними погоджувачами.",
"Draft → In approval → Approved → Obsolete": "Чернетка → На погодженні → Погоджено → Застаріло",
"Locked once approved, with full history": "Після погодження блокується, із повною історією",
"Designed for ISO 9001, ISO 13485, and FDA": "Призначено для ISO 9001, ISO 13485 та FDA",
"Best for SOPs and controlled documents": "Найкраще підходить для SOP і контрольованих документів",
"Back": "Назад",
"Quality management": "Управління якістю",
"Recurring": "Регулярна",
"Pages move through draft, approval, and approved stages.": "Сторінки проходять стадії чернетки, погодження та погодженого документа.",
"Verifiers": "Верифікатори",
"Add verifier": "Додати верифікатора",
"I've reviewed this page for accuracy": "Я перевірив(ла) цю сторінку на точність",
"Set up": "Налаштувати",
"Remove verification": "Видалити перевірку",
"Are you sure you want to remove verification from this page?": "Ви впевнені, що хочете видалити перевірку з цієї сторінки?",
"Assigned verifiers must periodically re-verify this page.": "Призначені верифікатори мають періодично повторно перевіряти цю сторінку.",
"Last verified by {{name}} {{time}} (expired)": "Востаннє перевірив(-ла) {{name}} {{time}} (термін дії сплив)",
"The fixed expiration date has passed.": "Фіксована дата завершення вже минула.",
"Verified by {{name}} {{time}}": "Перевірено: {{name}} {{time}}",
"Expires {{date}}": "Термін дії спливає {{date}}",
"Expired {{date}}": "Термін дії сплив {{date}}",
"Mark as obsolete": "Позначити як застаріле",
"Mark obsolete": "Позначити як застаріле",
"Returned by {{name}} {{time}}": "Повернуто: {{name}} {{time}}",
"No approval has been requested yet.": "Запит на погодження ще не було надіслано.",
"Submitted by {{name}} {{time}}": "Надіслано: {{name}} {{time}}",
"Someone": "Хтось",
"Approved by {{name}} {{time}}": "Погоджено: {{name}} {{time}}",
"This document has been marked as obsolete.": "Цей документ позначено як застарілий.",
"Rejection comment": "Коментар щодо відхилення",
"Reason for returning this document...": "Причина повернення цього документа...",
"Confirm rejection": "Підтвердити відхилення",
"Submit for approval": "Надіслати на погодження",
"Reject": "Відхилити",
"Approve": "Погодити",
"Re-submit for approval": "Повторно надіслати на погодження",
"Verified until": "Перевірено до",
"QMS": "QMS",
"Verified pages": "Перевірені сторінки",
"Search pages...": "Шукати сторінки...",
"Filter by space": "Фільтрувати за простором",
"Filter by type": "Фільтрувати за типом",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> перевірив(-ла) сторінку",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> надіслав(-ла) сторінку вам на погодження",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> повернув(-ла) сторінку на доопрацювання",
"Page verification expires soon": "Термін перевірки сторінки скоро спливає",
"Page verification has expired": "Термін перевірки сторінки сплив",
"Verifying your email": "Перевірка вашої електронної пошти",
"Please wait...": "Будь ласка, зачекайте...",
"Verification failed. The link may have expired.": "Підтвердження не вдалося. Посилання могло втратити чинність.",
@@ -222,8 +222,6 @@
"Edit comment": "编辑评论",
"Delete comment": "删除评论",
"Are you sure you want to delete this comment?": "你确定要删除这条评论吗?",
"Delete chat": "删除聊天",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "您确定要删除「{{title}}」吗?此操作无法撤销。",
"Comment created successfully": "成功创建评论",
"Error creating comment": "创建评论时出错",
"Comment updated successfully": "评论更新成功",
@@ -741,93 +739,6 @@
"Removed page restriction": "已移除页面限制",
"Added page permission": "已添加页面权限",
"Removed page permission": "已移除页面权限",
"day": "天",
"days": "天",
"week": "周",
"weeks": "周",
"month": "个月",
"months": "个月",
"year": "年",
"years": "年",
"Period": "周期",
"Fixed date": "固定日期",
"Indefinitely": "无限期",
"Days": "天",
"Weeks": "周",
"Months": "个月",
"Years": "年",
"Pick a date": "选择日期",
"Maximum is {{max}} {{unit}} for this unit": "此单位的最大值为 {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "永不过期。验证者可随时重新验证。",
"Verified": "已验证",
"Review needed": "需要审核",
"Verification expired": "验证已过期",
"Draft": "草稿",
"In Approval": "审批中",
"In approval": "审批中",
"Approved": "已批准",
"Obsolete": "已作废",
"Expiring": "即将过期",
"Set up verification": "设置验证",
"Verify page": "验证页面",
"Page verification": "页面验证",
"Add verification": "添加验证",
"Edit verification": "编辑验证",
"Search by title": "按标题搜索",
"Choose how this page should stay accurate.": "选择此页面保持准确的方式。",
"Recurring verification": "定期验证",
"Verifiers re-confirm this page on a schedule.": "验证者按计划重新确认此页面。",
"Re-verify on a schedule (e.g every 30 days )": "按计划重新验证(例如每 30 天一次)",
"Page stays editable at all times": "页面始终可编辑",
"Best for runbooks, FAQs, living documentation": "最适合运行手册、常见问题和动态文档",
"Approval workflow": "审批工作流",
"Formal document lifecycle with named approvers.": "具有指定审批人的正式文档生命周期。",
"Draft → In approval → Approved → Obsolete": "草稿 → 审批中 → 已批准 → 已作废",
"Locked once approved, with full history": "批准后锁定,并保留完整历史记录",
"Designed for ISO 9001, ISO 13485, and FDA": "专为 ISO 9001、ISO 13485 和 FDA 设计",
"Best for SOPs and controlled documents": "最适合 SOP 和受控文档",
"Back": "返回",
"Quality management": "质量管理",
"Recurring": "定期",
"Pages move through draft, approval, and approved stages.": "页面会经历草稿、审批中和已批准阶段。",
"Verifiers": "验证者",
"Add verifier": "添加验证者",
"I've reviewed this page for accuracy": "我已审核此页面的准确性",
"Set up": "设置",
"Remove verification": "移除验证",
"Are you sure you want to remove verification from this page?": "确定要移除此页面的验证吗?",
"Assigned verifiers must periodically re-verify this page.": "指定的验证者必须定期重新验证此页面。",
"Last verified by {{name}} {{time}} (expired)": "最后由 {{name}} 于 {{time}} 验证(已过期)",
"The fixed expiration date has passed.": "固定到期日已过。",
"Verified by {{name}} {{time}}": "由 {{name}} 于 {{time}} 验证",
"Expires {{date}}": "于 {{date}} 到期",
"Expired {{date}}": "已于 {{date}} 过期",
"Mark as obsolete": "标记为作废",
"Mark obsolete": "标记作废",
"Returned by {{name}} {{time}}": "由 {{name}} 于 {{time}} 退回",
"No approval has been requested yet.": "尚未请求审批。",
"Submitted by {{name}} {{time}}": "由 {{name}} 于 {{time}} 提交",
"Someone": "某人",
"Approved by {{name}} {{time}}": "由 {{name}} 于 {{time}} 批准",
"This document has been marked as obsolete.": "此文档已被标记为作废。",
"Rejection comment": "退回意见",
"Reason for returning this document...": "退回此文档的原因...",
"Confirm rejection": "确认退回",
"Submit for approval": "提交审批",
"Reject": "退回",
"Approve": "批准",
"Re-submit for approval": "重新提交审批",
"Verified until": "验证有效期至",
"QMS": "QMS",
"Verified pages": "已验证页面",
"Search pages...": "搜索页面...",
"Filter by space": "按空间筛选",
"Filter by type": "按类型筛选",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> 验证了一个页面",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> 提交了一个页面供您审批",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> 退回了一个页面以供修改",
"Page verification expires soon": "页面验证即将过期",
"Page verification has expired": "页面验证已过期",
"Verifying your email": "正在验证您的邮箱",
"Please wait...": "请稍候……",
"Verification failed. The link may have expired.": "验证失败。该链接可能已过期。",
-5
View File
@@ -26,7 +26,6 @@ import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
@@ -38,7 +37,6 @@ 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 "@/pages/base/base-page.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
@@ -83,7 +81,6 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@@ -105,8 +102,6 @@ export default function App() {
element={<Page />}
/>
<Route path={"/base/:baseId"} element={<BasePage />} />
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />
<Route
@@ -9,7 +9,7 @@ import classes from "../styles/chat-sidebar.module.css";
type Props = {
chat: AiChat;
isActive: boolean;
onDelete: (chatId: string, title: string | null) => void;
onDelete: (chatId: string) => void;
onRename: (chatId: string, title: string) => void;
};
@@ -153,7 +153,7 @@ export default function AiChatSidebarItem({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id, chat.title);
onDelete(chat.id);
}}
>
{t("Delete")}
@@ -1,14 +1,6 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Center,
Text,
TextInput,
Loader,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -81,31 +73,16 @@ export default function AiChatSidebar() {
);
const handleDelete = useCallback(
(id: string, title: string | null) => {
modals.openConfirmModal({
title: t("Delete chat"),
centered: true,
children: (
<Text size="sm">
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
title: title || t("Untitled"),
})}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
(id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
},
[deleteMutation, chatId, navigate, t],
[deleteMutation, chatId, navigate],
);
const handleRename = useCallback(
@@ -1,64 +0,0 @@
import "@/features/editor/styles/index.css";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
import { Container } from "@mantine/core";
type PdfRenderData = {
pageId: string;
title: string;
content: any;
};
export default function PdfRenderPage() {
const { pageId } = useParams<{ pageId: string }>();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [data, setData] = useState<PdfRenderData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!pageId || !token) {
setError("Missing page ID or token");
return;
}
fetch('/api/pdf-export/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pageId, token }),
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((result) => setData(result.data))
.catch((err) => setError(err.message));
}, [pageId, token]);
useEffect(() => {
if (data?.title) {
document.title = data.title;
}
}, [data?.title]);
if (error) {
return <div>{error}</div>;
}
if (!data) {
return null;
}
return (
<Container size={900} p={0}>
<ReadonlyPageEditor
key={data.pageId}
title={data.title}
content={data.content}
pageId={data.pageId}
/>
</Container>
);
}
@@ -1,15 +0,0 @@
import { atom } from "jotai";
import { EditingCell } from "@/features/base/types/base.types";
export const activeViewIdAtom = atom<string | null>(null);
export const editingCellAtom = atom<EditingCell>(null);
export const activePropertyMenuAtom = atom<string | null>(null);
export const propertyMenuDirtyAtom = atom<boolean>(false);
export const propertyMenuCloseRequestAtom = atom<number>(0);
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
export const lastToggledRowIndexAtom = atom<number | null>(null);
@@ -1,22 +0,0 @@
import { atomFamily, atomWithStorage } from "jotai/utils";
import { BaseViewDraft } from "@/features/base/types/base.types";
export type ViewDraftKey = {
userId: string;
baseId: string;
viewId: string;
};
export const viewDraftStorageKey = (k: ViewDraftKey) =>
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
// `atomWithStorage` handles JSON serialization, cross-tab sync via the
// `storage` event, and lazy first-read out of the box. `atomFamily`'s
// comparator ensures the same triple resolves to the same atom instance
// across renders, so identity-equality cache hits in Jotai still work.
export const viewDraftAtomFamily = atomFamily(
(k: ViewDraftKey) =>
atomWithStorage<BaseViewDraft | null>(viewDraftStorageKey(k), null),
(a, b) =>
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
);
@@ -1,84 +0,0 @@
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const COLUMN_COUNT = 6;
const ROW_COUNT = 10;
// Deterministic per-cell widths so the skeleton doesn't flicker between
// renders. Values are rough normal distribution around 55-85 % of cell.
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];
export function BaseTableSkeleton() {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<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: COLUMN_COUNT }).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] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{Array.from({ length: ROW_COUNT }).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: COLUMN_COUNT }).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>
);
}
@@ -1,338 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import { Text, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { arrayMove } from "@dnd-kit/sortable";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
} from "@/features/base/types/base.types";
import {
useBaseRowsQuery,
flattenRows,
} from "@/features/base/queries/base-row-query";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
import {
useCreateViewMutation,
useUpdateViewMutation,
} from "@/features/base/queries/base-view-query";
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useViewDraft } from "@/features/base/hooks/use-view-draft";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import classes from "@/features/base/styles/grid.module.css";
type BaseTableProps = {
baseId: string;
};
export function BaseTable({ baseId }: BaseTableProps) {
const { t } = useTranslation();
// Subscribe to the base's realtime room so other clients' edits,
// schema changes, and async-job completions reconcile into our cache.
useBaseSocket(baseId);
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
const views = 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();
const {
draft: _draft,
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
baseId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Render view: baseline merged with any local draft. Passed to
// `useBaseTable` (for table state seeding) and to the toolbar (for badge
// counts). The real `activeView` is still used as the auto-persist
// baseline so drafts can't leak into column-layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Effective values drive the row query and the client-side position
// sort guard below. The old `activeView.config` reads are no longer the
// source of truth once drafts are involved.
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
// `useSpaceQuery` is guarded by `enabled: !!spaceId` internally, so
// passing `""` when `base` hasn't loaded yet is safe. See
// use-history-restore.tsx for the same pattern.
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canSave = spaceAbility.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Base,
);
// Hold the rows query until `base` has loaded. Otherwise the query
// fires once with `activeFilter` / `activeSorts` still undefined
// (a "bland" list request), then fires a second time as soon as the
// active view's config resolves — doubling network traffic on every
// base open for any view that has sort or filter.
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBaseRowsQuery(base ? baseId : undefined, activeFilter, activeSorts);
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]);
const { clear: clearSelection } = useRowSelection();
useEffect(() => {
clearSelection();
}, [baseId, activeView?.id, clearSelection]);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
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 { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
baseId,
cells: { [propertyId]: value },
});
},
[baseId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ baseId });
}, [baseId, createRowMutation]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
baseId,
name: t("New view"),
type: "table",
});
}, [baseId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(activeId: string, overId: string) => {
const currentOrder = table.getState().columnOrder;
const oldIndex = currentOrder.indexOf(activeId);
const newIndex = currentOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
table.setColumnOrder(newOrder);
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
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;
// `buildPromotedConfig` preserves all non-draft baseline fields
// (widths/order/visibility) and only overwrites filter/sorts when the
// draft has divergent values.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
baseId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// `useUpdateViewMutation` already shows a red toast on error and
// rolls back the optimistic cache; keep the draft so the user can
// retry without re-typing.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.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);
}
reorderRowMutation.mutate({
rowId,
baseId,
position: newPosition,
});
} catch {
// Position computation failed — skip silently
}
},
[rows, baseId, reorderRowMutation],
);
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;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
/>
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
baseId={baseId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
/>
</div>
);
}
@@ -1,300 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
IconDownload,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
FilterCondition,
FilterGroup,
} from "@/features/base/types/base.types";
import { exportBaseToCsv } from "@/features/base/services/base-service";
import { ViewTabs } from "@/features/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config";
import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type BaseToolbarProps = {
base: IBase;
// Effective view — baseline merged with any local draft. Badge counts
// and sort/filter popover seed data read from this. The real baseline
// only enters via `onDraftSortsChange` / `onDraftFiltersChange`
// callbacks defined by the parent.
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;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
onPersistViewConfig,
onDraftSortsChange,
onDraftFiltersChange,
}: BaseToolbarProps) {
const { t } = useTranslation();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [fieldsOpened, setFieldsOpened] = useState(false);
const [exporting, setExporting] = useState(false);
const toolbarRightRef = useRef<HTMLDivElement>(null);
// Mantine `<Popover>`'s built-in dismiss handlers don't fire reliably
// for the toolbar popovers (same issue that drove the property menu to
// use custom listeners in `grid-container.tsx`). Close any open toolbar
// popover on outside mousedown AND on ESC.
useEffect(() => {
if (!sortOpened && !filterOpened && !fieldsOpened) return;
const closeAll = () => {
setSortOpened(false);
setFilterOpened(false);
setFieldsOpened(false);
};
const mouseHandler = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (toolbarRightRef.current?.contains(target)) return;
// Ignore clicks that land inside any Mantine popover dropdown
// (role=dialog), any Select/Combobox dropdown (role=listbox, the
// container; option elements have role=option), or anything
// rendered into Mantine's shared portal node. Without these, a
// nested Select inside the popover would close the parent.
if (target.closest('[role="dialog"]')) return;
if (target.closest('[role="listbox"]')) return;
if (target.closest('[role="option"]')) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
closeAll();
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") closeAll();
};
const id = setTimeout(() => {
document.addEventListener("mousedown", mouseHandler);
}, 0);
document.addEventListener("keydown", keyHandler);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", mouseHandler);
document.removeEventListener("keydown", keyHandler);
};
}, [sortOpened, filterOpened, fieldsOpened]);
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" | "fields") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setFieldsOpened(panel === "fields" ? (v) => !v : false);
}, []);
const sorts = activeView?.config?.sorts ?? [];
// Stored view config uses the engine's filter tree. The popover edits
// an AND-only flat list; we unwrap the top-level group's children when
// reading and 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 hiddenFieldCount = 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 can drop the `sorts`
// axis (and remove its localStorage entry when both axes go clean).
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
},
[onDraftSortsChange],
);
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
// Wrap the AND-flat popover output into the engine's FilterGroup shape.
// Pass `undefined` to drop the filter axis from the draft entirely.
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}
baseId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
/>
<div className={classes.toolbarRight} ref={toolbarRightRef}>
<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"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{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"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
<ViewFieldVisibility
opened={fieldsOpened}
onClose={() => setFieldsOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide fields")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenFieldCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("fields")}
>
<IconEye size={16} />
{hiddenFieldCount > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{hiddenFieldCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFieldVisibility>
</div>
</div>
);
}
@@ -1,45 +0,0 @@
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="bottom"
withArrow
>
<Button
variant="light"
color="orange"
size="xs"
onClick={onSave}
loading={saving}
>
{t("Save for everyone")}
</Button>
</Tooltip>
)}
</Group>
);
}
@@ -1,36 +0,0 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({
value,
onCommit,
}: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
onCommit(!checked);
}, [checked, onCommit]);
return (
<div className={cellClasses.checkboxCell} onClick={handleChange}>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{ input: { cursor: "pointer", pointerEvents: "none" } }}
/>
</div>
);
}
@@ -1,34 +0,0 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/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>;
}
@@ -1,141 +0,0 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
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
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<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>
);
}
@@ -1,90 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellEmail({
value,
isEditing,
onCommit,
onCancel,
}: CellEmailProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
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}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -1,241 +0,0 @@
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 "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import api from "@/lib/api-client";
export type FileValue = {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
filePath?: string;
};
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: 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,
onCommit,
onCancel,
}: CellFileProps) {
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
for (const file of Array.from(fileList)) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("baseId", property.baseId);
const res = await api.post<FileValue>(
"/bases/files/upload",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
const attachment = res as unknown as FileValue;
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
filePath: attachment.filePath,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.baseId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div
key={file.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 0",
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
<IconFile
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-gray-6)",
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</div>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
</div>
))}
<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}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 0",
marginTop: 4,
fontSize: "var(--mantine-font-size-xs)",
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>
);
}
@@ -1,34 +0,0 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/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>;
}
@@ -1,53 +0,0 @@
import { useMemo } from "react";
import { Group } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/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 }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const user = useMemo(() => {
if (!userId || !membersData?.items) return null;
return membersData.items.find((u) => u.id === userId) ?? null;
}, [userId, membersData?.items]);
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={user?.name ?? ""}
size={20}
radius="xl"
/>
{user?.name && (
<span
style={{
fontSize: "var(--mantine-font-size-sm)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.name}
</span>
)}
</Group>
);
}
@@ -1,260 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/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;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onCommit,
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;
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onCommit(newIds);
},
[selectedIds, selectedSet, 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,
baseId: property.baseId,
typeOptions: {
...typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
},
});
onCommit([...selectedIds, newChoice.id]);
setSearch("");
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, 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") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
);
const MAX_VISIBLE = 3;
if (isEditing) {
const addOptionIdx = filteredChoices.length;
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />
</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 = 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)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
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)}
onMouseDown={(e) => {
e.preventDefault();
}}
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} maxVisible={MAX_VISIBLE} />;
}
function BadgeList({
choices,
maxVisible,
}: {
choices: Choice[];
maxVisible: number;
}) {
const visible = choices.slice(0, maxVisible);
const overflow = choices.length - maxVisible;
return (
<div className={cellClasses.badgeGroup}>
{visible.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -1,122 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import {
IBaseProperty,
NumberTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
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 "currency":
return `${options?.currencySymbol ?? "$"}${val.toFixed(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);
}
}
export function CellNumber({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const numValue = typeof value === "number" ? value : null;
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const [draft, setDraft] = useState(numValue != null ? String(numValue) : "");
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(numValue != null ? String(numValue) : "");
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, numValue]);
const parseDraft = useCallback(() => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
}, [draft]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(parseDraft());
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[parseDraft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(parseDraft());
}, [parseDraft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={cellClasses.cellInput}
style={{ textAlign: "right" }}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.numberValue}>
{formatNumber(numValue, typeOptions)}
</span>
);
}
@@ -1,268 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, ActionIcon, Text } 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 clsx from "clsx";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { searchSuggestions } from "@/features/search/services/search-service";
import { buildPageUrl } from "@/features/page/page.utils";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
import cellClasses from "@/features/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;
}
export function CellPage({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPageProps) {
const pageId = parsePageId(value);
const { data: base } = useBaseQuery(property.baseId);
const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]);
const { pages } = useResolvedPages(ids);
const resolvedPage = pageId ? pages.get(pageId) : undefined;
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) {
// Still resolving — render an empty pill-shaped placeholder to avoid
// the "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 title = page.title || "Untitled";
const spaceSlug = page.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, page.slugId, title);
return (
<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>
);
}
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 [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => searchRef.current?.focus());
}, []);
const trimmed = debouncedSearch.trim();
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[];
},
staleTime: 15_000,
});
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 onClose={onCancel} position="bottom-start" width={320} trapFocus>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{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} />
)}
<span className={cellClasses.personTagName}>
{resolvedPage.title || "Untitled"}
</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}
/>
</div>
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.selectDropdown}>
{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)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(page.id)}
>
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileDescription size={14} />
)}
<span className={cellClasses.personOptionName}>
{page.title || "Untitled"}
</span>
{page.space?.name && (
<Text size="xs" c="dimmed" ml="auto" truncate>
{page.space.name}
</Text>
)}
</div>
);
})}
</div>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,263 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import clsx from "clsx";
import {
IBaseProperty,
PersonTypeOptions,
} from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellPerson({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellPersonProps) {
const allowMultiple =
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const members = membersData?.items ?? [];
const memberMap = useMemo(() => {
const map = new Map<string, (typeof members)[0]>();
for (const m of members) map.set(m.id, m);
return map;
}, [members]);
const filteredMembers = search
? members.filter(
(m) =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.email && m.email.toLowerCase().includes(search.toLowerCase())),
)
: members;
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
const handleSelect = useCallback(
(memberId: string) => {
if (allowMultiple) {
// Multi mode: toggle add/remove
if (personIds.includes(memberId)) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
} else {
onCommit([...personIds, memberId]);
}
} else {
// Single mode: replace or clear
if (personIds.includes(memberId)) {
onCommit(null);
} else {
onCommit(memberId);
}
}
},
[allowMultiple, personIds, onCommit],
);
const handleRemove = useCallback(
(memberId: string) => {
if (allowMultiple) {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null);
} else {
onCommit(null);
}
},
[allowMultiple, personIds, 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 >= 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],
);
const selectedSet = new Set(personIds);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={300}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<PersonReadList personIds={personIds} memberMap={memberMap} />
</div>
</Popover.Target>
<Popover.Dropdown p={0}>
{/* Tag input area */}
<div className={cellClasses.personTagArea}>
{personIds.map((id) => {
const member = memberMap.get(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}
/>
</div>
{/* Dropdown */}
<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)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
</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} memberMap={memberMap} />;
}
function PersonReadList({
personIds,
memberMap,
}: {
personIds: string[];
memberMap: Map<
string,
{ id: string; name: string; email?: string; avatarUrl?: string }
>;
}) {
return (
<div className={cellClasses.personGroup}>
{personIds.map((id) => {
const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 8);
return (
<div key={id} className={cellClasses.personRow}>
<CustomAvatar
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={20}
radius="xl"
/>
<span className={cellClasses.personName}>{name}</span>
</div>
);
})}
</div>
);
}
@@ -1,242 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import clsx from "clsx";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
import { v7 as uuid7 } from "uuid";
import cellClasses from "@/features/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/features/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,
baseId: property.baseId,
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
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{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)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
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)}
onMouseDown={(e) => {
e.preventDefault();
}}
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 (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -1,202 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
import clsx from "clsx";
import { useListKeyboardNav } from "@/features/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
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{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)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
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 (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -1,82 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import gridClasses from "@/features/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellText({
value,
isEditing,
onCommit,
onCancel,
}: CellTextProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={gridClasses.cellContent}>{displayValue}</span>;
}
@@ -1,92 +0,0 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellUrl({
value,
isEditing,
onCommit,
onCancel,
}: CellUrlProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
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}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.urlLink}
href={displayValue}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -1,25 +0,0 @@
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: "#1971c2", textDark: "#74c0fc" },
cyan: { bg: "#c3fae8", bgDark: "#1a3a3a", text: "#0c8599", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#087f5b", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#2b8a3e", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#5c940d", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#e67700", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#d9480f", 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})`,
};
}
@@ -1,26 +0,0 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/features/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>
);
});
@@ -1,151 +0,0 @@
import { memo, useCallback } from "react";
import { Cell } from "@tanstack/react-table";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import { CellText } from "@/features/base/components/cells/cell-text";
import { CellNumber } from "@/features/base/components/cells/cell-number";
import { CellSelect } from "@/features/base/components/cells/cell-select";
import { CellStatus } from "@/features/base/components/cells/cell-status";
import { CellMultiSelect } from "@/features/base/components/cells/cell-multi-select";
import { CellDate } from "@/features/base/components/cells/cell-date";
import { CellCheckbox } from "@/features/base/components/cells/cell-checkbox";
import { CellUrl } from "@/features/base/components/cells/cell-url";
import { CellEmail } from "@/features/base/components/cells/cell-email";
import { CellPerson } from "@/features/base/components/cells/cell-person";
import { CellFile } from "@/features/base/components/cells/cell-file";
import { CellPage } from "@/features/base/components/cells/cell-page";
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
import { RowNumberCell } from "./row-number-cell";
import classes from "@/features/base/styles/grid.module.css";
type CellComponentProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const cellComponents: Record<
string,
React.ComponentType<CellComponentProps>
> = {
text: CellText,
number: CellNumber,
select: CellSelect,
status: CellStatus,
multiSelect: CellMultiSelect,
date: CellDate,
checkbox: CellCheckbox,
url: CellUrl,
email: CellEmail,
person: CellPerson,
file: CellFile,
page: CellPage,
createdAt: CellCreatedAt,
lastEditedAt: CellLastEditedAt,
lastEditedBy: CellLastEditedBy,
};
type RowDragProps = {
draggable: boolean;
onDragStart: (e: React.DragEvent) => void;
};
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
rowDragProps?: RowDragProps;
orderedRowIds?: string[];
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
onCellUpdate,
rowDragProps,
orderedRowIds,
}: 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(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id;
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, setEditingCell]);
const handleCommit = useCallback(
(value: unknown) => {
if (!property) return;
const currentValue = cell.getValue();
const hasChanged = value !== currentValue
&& !(value === "" && (currentValue === null || currentValue === undefined))
&& !(value === null && (currentValue === null || currentValue === undefined));
if (hasChanged) {
onCellUpdate(rowId, property.id, value);
}
setEditingCell(null);
},
[property, rowId, cell, onCellUpdate, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
orderedRowIds={orderedRowIds ?? []}
isPinned={Boolean(isPinned)}
pinOffset={pinOffset}
rowDragProps={rowDragProps}
/>
);
}
if (!property) return null;
const CellComponent = cellComponents[property.type];
if (!CellComponent) return null;
const value = cell.getValue();
return (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
}}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
onCommit={handleCommit}
onCancel={handleCancel}
/>
</div>
);
});
@@ -1,306 +0,0 @@
import { useRef, useMemo, useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import { SelectionActionBar } from "./selection-action-bar";
import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36;
const OVERSCAN = 10;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
baseId?: string;
onColumnReorder?: (columnId: string, overColumnId: string) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
};
export function GridContainer({
table,
properties,
onCellUpdate,
onAddRow,
baseId,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
}: GridContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
propertyMenuDirtyRef.current = propertyMenuDirty;
const closeRequestCounterRef = useRef(0);
const { selectionCount, clear: clearSelection } = useRowSelection();
const { deleteSelected } = useDeleteSelectedRows(baseId ?? "");
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
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;
if (propertyMenuDirtyRef.current) {
closeRequestCounterRef.current += 1;
setCloseRequest(closeRequestCounterRef.current);
} else {
setActivePropertyMenu(null);
}
setEditingCell(null);
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
useColumnResize(table, onResizeEnd ?? (() => {}));
useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef: scrollRef,
});
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
});
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 underlying row set shrinks (filter changed, sort toggled,
// view switched) or resets to zero, we're on a fresh pagination
// sequence — un-gate the trigger so the first page triggers a
// potential next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !baseId) 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, baseId]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return columnWidths.join(" ") + (baseId ? " 40px" : "");
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
const totalHeight = virtualizer.getTotalSize();
const paddingTop =
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
const paddingBottom =
virtualItems.length > 0
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
: 0;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, position: "above" | "below") => {
onRowReorder?.(rowId, targetRowId, position);
},
[onRowReorder],
);
const {
dragState: rowDragState,
handleDragStart: handleRowDragStart,
handleDragOver: handleRowDragOver,
handleDragEnd: handleRowDragEnd,
handleDragLeave: handleRowDragLeave,
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const handlePropertyCreated = useCallback(() => {
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: "smooth",
});
});
});
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor),
);
const sortableColumnIds = useMemo(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table, table.getState().columnOrder]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onColumnReorder?.(active.id as string, over.id as string);
},
[onColumnReorder],
);
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<div
className={classes.gridWrapper}
ref={scrollRef}
tabIndex={0}
>
<div
className={classes.grid}
style={{ gridTemplateColumns }}
role="grid"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
<GridHeader
table={table}
baseId={baseId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
{paddingTop > 0 && (
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
)}
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
onCellUpdate={onCellUpdate}
orderedRowIds={rowIds}
columnVisibility={table.getState().columnVisibility}
dragHandlers={
onRowReorder
? {
onDragStart: handleRowDragStart,
onDragOver: handleRowDragOver,
onDragEnd: handleRowDragEnd,
onDragLeave: handleRowDragLeave,
isDragging: rowDragState.dragRowId === row.id,
isDropTarget: rowDragState.dropTargetRowId === row.id,
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
}
: undefined
}
/>
);
})}
{paddingBottom > 0 && (
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
)}
<AddRowButton onClick={handleAddRow} />
{baseId && <SelectionActionBar baseId={baseId} />}
</div>
</div>
</DndContext>
);
}
@@ -1,214 +0,0 @@
import { memo, useCallback, useEffect, useRef } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Popover } from "@mantine/core";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
} from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
import { RowNumberHeaderCell } from "./row-number-header-cell";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
const typeIcons: Record<string, typeof IconLetterT> = {
text: IconLetterT,
number: IconHash,
select: IconCircleDot,
status: IconProgressCheck,
multiSelect: IconTags,
date: IconCalendar,
person: IconUser,
file: IconPaperclip,
checkbox: IconCheckbox,
url: IconLink,
email: IconMail,
createdAt: IconClockPlus,
lastEditedAt: IconClockEdit,
lastEditedBy: IconUserEdit,
};
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
}: GridHeaderCellProps) {
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const { selectionCount } = useRowSelection();
const hasSelection = selectionCount > 0;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || isPinned === "left";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: header.column.id,
disabled: isSortableDisabled,
});
const combinedRef = useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node);
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[setNodeRef],
);
const handleHeaderClick = useCallback(() => {
setEditingCell(null);
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
// Mantine's built-in `closeOnEscape` only fires when focus is inside the
// dropdown, but opening the property menu (clicking the header) leaves
// focus on the header itself. Mirror the click-outside path: when dirty,
// bump `propertyMenuCloseRequestAtom` so property-menu shows its
// "Unsaved changes" confirmation panel; otherwise close directly.
useEffect(() => {
if (!menuOpened) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (propertyMenuDirty) {
setCloseRequest(closeRequest + 1);
} else {
handleMenuClose();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [menuOpened, propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose]);
const TypeIcon = property ? typeIcons[property.type] : undefined;
const sortableStyle = transform
? {
transform: CSS.Transform.toString({
...transform,
scaleX: 1,
scaleY: 1,
}),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}
: {};
return (
<div
ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle,
}}
onClick={handleHeaderClick}
{...(isSortableDisabled ? {} : attributes)}
{...(isSortableDisabled ? {} : listeners)}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
)}
{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) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
)}
{property && !isRowNumber && (
<Popover
opened={menuOpened}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
>
<Popover.Target>
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
);
});
@@ -1,56 +0,0 @@
import { memo, useMemo } from "react";
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
import classes from "@/features/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
baseId?: string;
// Passed explicitly to break memo when columns change
// (table ref is stable from useReactTable, so memo won't fire without these)
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
properties: IBaseProperty[];
loadedRowIds: string[];
onPropertyCreated?: () => void;
};
export const GridHeader = memo(function GridHeader({
table,
baseId,
// 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,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
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}
/>
))}
{baseId && (
<CreatePropertyPopover
baseId={baseId}
onPropertyCreated={onPropertyCreated}
/>
)}
</div>
);
});
@@ -1,92 +0,0 @@
import { memo, useCallback } from "react";
import { Row, VisibilityState } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { GridCell } from "./grid-cell";
import classes from "@/features/base/styles/grid.module.css";
type RowDragHandlers = {
onDragStart: (rowId: string) => void;
onDragOver: (rowId: string, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragLeave: () => void;
isDragging: boolean;
isDropTarget: boolean;
dropPosition: "above" | "below" | null;
};
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
dragHandlers?: RowDragHandlers;
orderedRowIds: string[];
columnVisibility: VisibilityState;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
onCellUpdate,
dragHandlers,
orderedRowIds,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnVisibility: _columnVisibility,
}: GridRowProps) {
const isSelected = useRowSelection().isSelected(row.id);
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", row.id);
dragHandlers?.onDragStart(row.id);
},
[row.id, dragHandlers],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
dragHandlers?.onDragOver(row.id, e);
},
[row.id, dragHandlers],
);
const dropIndicatorClass = dragHandlers?.isDropTarget
? dragHandlers.dropPosition === "above"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
className={`${classes.row} ${dragHandlers?.isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
role="row"
onDragOver={handleDragOver}
onDrop={(e) => {
e.preventDefault();
dragHandlers?.onDragEnd();
}}
onDragLeave={dragHandlers?.onDragLeave}
>
{row.getVisibleCells().map((cell) => {
const isRowNumber = cell.column.id === "__row_number";
return (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
onCellUpdate={onCellUpdate}
orderedRowIds={orderedRowIds}
rowDragProps={
isRowNumber && dragHandlers
? {
draggable: true,
onDragStart: handleDragStart,
}
: undefined
}
/>
);
})}
</div>
);
});
@@ -1,70 +0,0 @@
import { memo, useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IconGripVertical } from "@tabler/icons-react";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
type RowDragProps = {
draggable: boolean;
onDragStart: (e: React.DragEvent) => void;
};
type RowNumberCellProps = {
rowId: string;
rowIndex: number;
orderedRowIds: string[];
isPinned: boolean;
pinOffset?: number;
rowDragProps?: RowDragProps;
};
export const RowNumberCell = memo(function RowNumberCell({
rowId,
rowIndex,
orderedRowIds,
isPinned,
pinOffset,
rowDragProps,
}: RowNumberCellProps) {
const { isSelected, toggle } = useRowSelection();
const selected = isSelected(rowId);
const handleCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds,
});
},
[rowId, rowIndex, orderedRowIds, toggle],
);
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
style={isPinned ? { left: pinOffset } : undefined}
>
<div className={classes.rowNumberCellInner}>
<span
className={classes.rowNumberDragHandle}
draggable={rowDragProps?.draggable}
onDragStart={rowDragProps?.onDragStart}
aria-label="Drag row"
>
<IconGripVertical size={12} />
</span>
<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>
);
});
@@ -1,48 +0,0 @@
import { memo, useMemo } from "react";
import { Checkbox, Tooltip } from "@mantine/core";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css";
type RowNumberHeaderCellProps = {
loadedRowIds: string[];
};
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
loadedRowIds,
}: RowNumberHeaderCellProps) {
const { selectedIds, toggleAll } = useRowSelection();
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>
);
});
@@ -1,52 +0,0 @@
import { memo } from "react";
import { Transition } from "@mantine/core";
import { IconTrash, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import classes from "@/features/base/styles/grid.module.css";
type SelectionActionBarProps = {
baseId: string;
};
export const SelectionActionBar = memo(function SelectionActionBar({
baseId,
}: SelectionActionBarProps) {
const { t } = useTranslation();
const { selectionCount, clear } = useRowSelection();
const { deleteSelected, isPending } = useDeleteSelectedRows(baseId);
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>
);
});
@@ -1,547 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef } 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 {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { Choice } from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
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;
type ChoiceEditorProps = {
initialChoices: Choice[];
onSave: (choices: Choice[]) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
hideButtons?: boolean;
};
export function ChoiceEditor({
initialChoices,
onSave,
onClose,
onDirtyChange,
showCategories = false,
hideButtons = false,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Choice[]>(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
// Sync from parent only when not in live mode (hideButtons = create flow)
useEffect(() => {
if (!hideButtons) {
setDraft(initialChoices);
}
}, [initialChoices, hideButtons]);
// In live mode, propagate draft changes to parent immediately
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;
useEffect(() => {
if (hideButtons) {
onSaveRef.current(draft.filter((c) => c.name.trim()));
}
}, [hideButtons, draft]);
const isDirty = useMemo(() => {
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]);
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));
}, []);
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);
onClose();
}, [draft, onSave, onClose]);
const handleCancel = useCallback(() => {
setDraft(initialChoices);
onDirtyChange?.(false);
onClose();
}, [initialChoices, onDirtyChange, onClose]);
const handleReorder = useCallback((activeId: string, overId: string) => {
setDraft((prev) => {
const oldIndex = prev.findIndex((c) => c.id === activeId);
const newIndex = prev.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleCategoryReorder = useCallback(
(category: string, activeId: string, overId: string) => {
setDraft((prev) => {
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
const oldIndex = catChoices.findIndex((c) => c.id === activeId);
const newIndex = catChoices.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
const reordered = arrayMove(catChoices, oldIndex, newIndex);
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} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<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}
/>
) : (
<FlatChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onReorder={handleReorder}
/>
)}
{!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,
}: {
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, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(active.id as string, over.id as string);
},
[onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{draft.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd()}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<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,
}: {
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, overId: string) => void;
}) {
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}
/>
))}
</Stack>
);
}
function CategorySection({
category,
label,
choices,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
}: {
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, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(category, active.id as string, over.id as string);
},
[category, onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t(label)}
</Text>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{choices.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd(category)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function SortableChoiceRow({
choice,
autoFocus,
onFocused,
onRename,
onColorChange,
onRemove,
}: {
choice: Choice;
autoFocus?: boolean;
onFocused?: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: choice.id });
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus();
onFocused?.();
}
}, [autoFocus, onFocused]);
const style = {
transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const hasError = !choice.name.trim();
return (
<Group ref={setNodeRef} style={style} gap={6} wrap="nowrap" align="center">
<div
{...attributes}
{...listeners}
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<ColorDot color={choice.color} onChange={(c) => onColorChange(choice.id, c)} />
<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)} />
</Group>
);
}
function ColorDot({
color,
onChange,
}: {
color: string;
onChange: (color: string) => void;
}) {
const [opened, setOpened] = useState(false);
const colors = choiceColor(color);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" shadow="sm" withinPortal>
<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>
);
}
@@ -1,317 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Popover,
Portal,
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 "@/features/base/types/base.types";
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import classes from "@/features/base/styles/grid.module.css";
type CreatePropertyPopoverProps = {
baseId: string;
onPropertyCreated?: () => void;
};
type Panel = "typePicker" | "configure" | "confirmDiscard";
const noop = () => {};
// Keep in sync with the switch cases in PropertyOptions
const typesWithOptions = new Set<BasePropertyType>([
"select",
"multiSelect",
"status",
"number",
"date",
"person",
]);
export function CreatePropertyPopover({ baseId, onPropertyCreated }: 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>>({});
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 resetState = useCallback(() => {
setPanel("typePicker");
setSelectedType(null);
setName("");
setTypeOptions({});
}, []);
const handleOpen = useCallback(() => {
resetState();
setOpened(true);
}, [resetState]);
const handleClose = useCallback(() => {
setOpened(false);
resetState();
}, [resetState]);
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({});
setPanel("configure");
}, []);
useEffect(() => {
if (panel === "configure") {
setTimeout(() => nameInputRef.current?.focus(), 0);
}
}, [panel]);
const handleCreate = useCallback(() => {
if (!selectedType) return;
const finalName = name.trim() || selectedTypeLabel;
createPropertyMutation.mutate(
{
baseId,
name: finalName,
type: selectedType,
typeOptions: Object.keys(typeOptions).length > 0
? typeOptions as TypeOptions
: undefined,
},
{
onSuccess: () => {
onPropertyCreated?.();
},
},
);
handleClose();
}, [selectedType, name, selectedTypeLabel, typeOptions, baseId, 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: "",
baseId,
name: name || "",
type: selectedType ?? "text",
position: "",
typeOptions: typeOptions as TypeOptions,
isPrimary: false,
workspaceId: "",
createdAt: "",
updatedAt: "",
}), [baseId, name, selectedType, typeOptions]);
const TypeIcon = selectedTypeIcon;
const showOptions = selectedType && typesWithOptions.has(selectedType);
return (
<>
{opened && (
<Portal>
<div
style={{
position: "fixed",
inset: 0,
zIndex: 299,
}}
onClick={attemptClose}
/>
</Portal>
)}
<Popover
opened={opened}
onClose={noop}
position="bottom-start"
shadow="md"
width={320}
withinPortal
>
<Popover.Target>
<div
className={classes.addColumnButton}
onClick={handleOpen}
role="button"
tabIndex={0}
>
<IconPlus size={16} />
</div>
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
style={{ zIndex: 300 }}
>
{panel === "typePicker" && (
<Stack gap={0} p={4}>
<PropertyTypePicker
onSelect={handleTypeSelect}
showSearch
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && (
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<TextInput
ref={nameInputRef}
size="xs"
label={t("Name")}
placeholder={selectedTypeLabel}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={handleNameKeyDown}
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
/>
</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}>
{t("Create field")}
</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>
</>
);
}
@@ -1,416 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
UnstyledButton,
TextInput,
Button,
Stack,
Text,
Group,
ActionIcon,
Divider,
ScrollArea,
} from "@mantine/core";
import {
IconTrash,
IconPencil,
IconChevronRight,
IconSettings,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/features/base/queries/base-property-query";
import { propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import cellClasses from "@/features/base/styles/cells.module.css";
type PropertyMenuContentProps = {
property: IBaseProperty;
opened: boolean;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
};
type MenuPanel = "main" | "rename" | "options" | "confirmDelete" | "confirmDiscard";
export function PropertyMenuContent({
property,
opened,
onClose,
onDirtyChange,
}: 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);
const pendingActionRef = useRef<"back" | "close" | null>(null);
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) 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);
}
}, [opened, property.name]);
useEffect(() => {
if (panel === "rename") {
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [panel]);
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
setOptionsDirty(dirty);
}, []);
// Single dirty signal to the outside — reflects whichever panel is
// currently accumulating unsaved work. Keeps rename and options in
// lockstep with the `propertyMenuDirtyAtom` so the grid-container's
// outside-click handler and the header's ESC handler both prompt
// "Unsaved changes" consistently.
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,
baseId: property.baseId,
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,
baseId: property.baseId,
typeOptions,
});
setOptionsDirty(false);
},
[property, updatePropertyMutation],
);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
});
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")}
onOptions={() => setPanel("options")}
onDelete={() => setPanel("confirmDelete")}
/>
)}
{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 === "options" || panel === "confirmDiscard") && (
<Stack 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}
style={{ transform: "rotate(180deg)" }}
/>
</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}
/>
</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>
)}
</>
);
}
// Expose requestClose for use by parent (grid-header-cell)
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,
onOptions,
onDelete,
}: {
property: IBaseProperty;
onRename: () => void;
onOptions: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isSystem = isSystemPropertyType(property.type);
const hasOptions =
!isSystem &&
(property.type === "select" ||
property.type === "multiSelect" ||
property.type === "status" ||
property.type === "number" ||
property.type === "date");
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}
/>
{!isSystem && (
<Stack gap={4} px="sm" py={6}>
<Text size="xs" c="dimmed">{t("Type")}</Text>
<TextInput
size="xs"
value={typeDef ? t(typeDef.labelKey) : property.type}
disabled
leftSection={TypeIcon ? <TypeIcon size={14} /> : null}
readOnly
/>
</Stack>
)}
{hasOptions && (
<MenuItem
icon={<IconSettings size={14} />}
label={t("Options")}
rightIcon={<IconChevronRight size={14} />}
onClick={onOptions}
/>
)}
{!property.isPrimary && (
<>
<Divider my={4} />
<MenuItem
icon={<IconTrash size={14} />}
label={t("Delete property")}
color="red"
onClick={onDelete}
/>
</>
)}
</Stack>
);
}
@@ -1,263 +0,0 @@
import { useCallback, useMemo } from "react";
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
PersonTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
};
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange, hideButtons }: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
hideButtons={hideButtons}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
/>
);
case "person":
return (
<PersonOptions
property={property}
onUpdate={onUpdate}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
hideButtons={hideButtons}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
hideButtons,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
hideButtons?: boolean;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
hideButtons={hideButtons}
/>
);
}
function NumberOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as NumberTypeOptions | undefined;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
allowDeselect={false}
data={[
{ value: "plain", label: t("Number") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
]}
value={options?.format ?? "plain"}
onChange={(val) =>
onUpdate({ ...property.typeOptions, format: val ?? "plain" })
}
/>
<NumberInput
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options?.precision ?? 0}
onChange={(val) =>
onUpdate({ ...property.typeOptions, precision: val })
}
/>
</Stack>
);
}
function DateOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as DateTypeOptions | undefined;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options?.includeTime ?? false}
onChange={(e) =>
onUpdate({
...property.typeOptions,
includeTime: e.currentTarget.checked,
})
}
/>
{options?.includeTime && (
<Select
size="xs"
label={t("Time format")}
allowDeselect={false}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options?.timeFormat ?? "12h"}
onChange={(val) =>
onUpdate({ ...property.typeOptions, timeFormat: val ?? "12h" })
}
/>
)}
</Stack>
);
}
function PersonOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as PersonTypeOptions | undefined;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Allow multiple people")}
checked={options?.allowMultiple !== false}
onChange={(e) =>
onUpdate({
...property.typeOptions,
allowMultiple: e.currentTarget.checked,
})
}
/>
</Stack>
);
}
@@ -1,112 +0,0 @@
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconFileDescription,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
IconCheck,
IconSearch,
} from "@tabler/icons-react";
import { BasePropertyType } from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
import { useState, useRef, useEffect } from "react";
import classes from "@/features/base/styles/cells.module.css";
const propertyTypes: {
type: BasePropertyType;
icon: typeof IconLetterT;
labelKey: string;
}[] = [
{ type: "text", icon: IconLetterT, labelKey: "Text" },
{ type: "number", icon: IconHash, labelKey: "Number" },
{ type: "select", icon: IconCircleDot, labelKey: "Select" },
{ type: "status", icon: IconProgressCheck, labelKey: "Status" },
{ type: "multiSelect", icon: IconTags, labelKey: "Multi-select" },
{ type: "date", icon: IconCalendar, labelKey: "Date" },
{ type: "person", icon: IconUser, labelKey: "Person" },
{ type: "file", icon: IconPaperclip, labelKey: "File" },
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
{ type: "url", icon: IconLink, labelKey: "URL" },
{ type: "email", icon: IconMail, labelKey: "Email" },
{ type: "createdAt", icon: IconClockPlus, labelKey: "Created at" },
{ type: "lastEditedAt", icon: IconClockEdit, labelKey: "Last edited at" },
{ type: "lastEditedBy", icon: IconUserEdit, labelKey: "Last edited by" },
];
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 field 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 };
@@ -1,161 +0,0 @@
import { useMemo, useCallback } from "react";
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { propertyTypes } from "@/features/base/components/property/property-type-picker";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewFieldVisibilityProps = {
opened: boolean;
onClose: () => void;
table: Table<IBaseRow>;
properties: IBaseProperty[];
onPersist: () => void;
children: React.ReactNode;
};
export function ViewFieldVisibility({
opened,
onClose,
table,
properties,
onPersist,
children,
}: ViewFieldVisibilityProps) {
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}
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("Fields")}
</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" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
disabled={!canHide}
onChange={() => {}}
// Mantine's Switch spreads `onClick` onto its hidden
// <input>. When the user clicks the visible track, the
// label's default action synthesizes a second click on
// that input — both clicks bubble to the parent
// UnstyledButton and fire handleToggle twice (hide then
// immediately unhide, net zero). stopPropagation here
// blocks ONLY the synthesized input click from reaching
// UnstyledButton; the original track click still bubbles
// normally, so handleToggle fires exactly once.
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,447 +0,0 @@
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 "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
/*
* Operator metadata for the filter popover. Values use the server
* engine's operator set (`core/base/engine/schema.zod.ts`); labels are
* i18n-translated display strings.
*/
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
{ value: "eq", labelKey: "Equals" },
{ value: "neq", labelKey: "Not equals" },
{ value: "contains", labelKey: "Contains" },
{ value: "ncontains", labelKey: "Not contains" },
{ value: "isEmpty", labelKey: "Is empty" },
{ value: "isNotEmpty", labelKey: "Is not empty" },
{ value: "gt", labelKey: "Greater than" },
{ value: "lt", labelKey: "Less than" },
{ value: "before", labelKey: "Before" },
{ value: "after", labelKey: "After" },
{ value: "any", labelKey: "Any of" },
{ value: "none", labelKey: "None of" },
];
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
function getOperatorsForType(type: string): FilterOperator[] {
switch (type) {
case "text":
case "email":
case "url":
return ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"];
case "number":
return ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"];
case "date":
case "createdAt":
case "lastEditedAt":
return ["eq", "neq", "before", "after", "isEmpty", "isNotEmpty"];
case "select":
case "status":
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
case "multiSelect":
return ["any", "none", "isEmpty", "isNotEmpty"];
case "checkbox":
return ["eq", "isEmpty", "isNotEmpty"];
case "person":
case "lastEditedBy":
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
case "file":
return ["isEmpty", "isNotEmpty"];
case "page":
return ["isEmpty", "isNotEmpty"];
default:
return ["eq", "neq", "isEmpty", "isNotEmpty"];
}
}
function FilterValueInput({
condition,
property,
onChange,
t,
}: {
condition: FilterCondition;
property: IBaseProperty | undefined;
onChange: (value: string) => 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 type = property.type;
if (type === "select" || type === "status" || type === "multiSelect") {
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"
data={choiceOptions}
value={(condition.value as string) ?? null}
onChange={(val) => onChange(val ?? "")}
w={120}
placeholder={t("Select")}
/>
);
}
if (type === "number") {
return (
<TextInput
size="xs"
type="number"
placeholder={t("Value")}
value={(condition.value as string) ?? ""}
onChange={(e) => onChange(e.currentTarget.value)}
w={100}
/>
);
}
if (type === "checkbox") {
return (
<Select
size="xs"
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);
setDraft({
...draft,
propertyId,
op: currentOperatorValid ? draft.op : validOperators[0],
value: currentOperatorValid ? draft.value : undefined,
});
},
[draft, properties],
);
const handleDraftOperatorChange = useCallback(
(operator: string | null) => {
if (!operator || !draft) return;
const op = operator as FilterOperator;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
setDraft({ ...draft, op, value: needsValue ? draft.value : undefined });
},
[draft],
);
const handleDraftValueChange = useCallback(
(value: string) => {
if (!draft) return;
setDraft({ ...draft, value: value || undefined });
},
[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);
return {
...f,
propertyId,
op: currentOperatorValid ? f.op : validOperators[0],
value: currentOperatorValid ? 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;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
onChange(
conditions.map((f, i) =>
i === index
? {
...f,
op,
value: needsValue ? f.value : undefined,
}
: f,
),
);
},
[conditions, onChange],
);
const handleValueChange = useCallback(
(index: number, value: string) => {
onChange(
conditions.map((f, i) =>
i === index ? { ...f, value: value || undefined } : f,
),
);
},
[conditions, onChange],
);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={440}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<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"
data={propertyOptions}
value={condition.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
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"
data={propertyOptions}
value={draft.propertyId}
onChange={handleDraftPropertyChange}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
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}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add filter")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,220 +0,0 @@
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 "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
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);
// Discard any half-configured draft when the popover closes.
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
// Page properties store a UUID; sorting by raw UUID is unhelpful and
// title-based sort would require a join. Hide until we support it properly.
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}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus
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"
data={propertyOptions}
value={sort.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
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"
data={propertyOptions}
value={draft.propertyId}
onChange={(val) =>
val && setDraft({ ...draft, propertyId: val })
}
style={{ flex: 1 }}
/>
<Select
size="xs"
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}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add sort")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,237 +0,0 @@
import { useState, useCallback } from "react";
import {
Group,
UnstyledButton,
Text,
ActionIcon,
Tooltip,
TextInput,
Popover,
Stack,
Divider,
} from "@mantine/core";
import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
import { IBaseView } from "@/features/base/types/base.types";
import {
useUpdateViewMutation,
useDeleteViewMutation,
} from "@/features/base/queries/base-view-query";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewTabsProps = {
views: IBaseView[];
activeViewId: string | undefined;
baseId: string;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
};
export function ViewTabs({
views,
activeViewId,
baseId,
onViewChange,
onAddView,
}: ViewTabsProps) {
const { t } = useTranslation();
const [editingViewId, setEditingViewId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const updateViewMutation = useUpdateViewMutation();
const deleteViewMutation = useDeleteViewMutation();
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,
baseId,
name: trimmed,
});
}
setEditingViewId(null);
}, [editingViewId, editingName, views, baseId, 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 (views.length <= 1) return;
deleteViewMutation.mutate({ viewId, baseId });
if (viewId === activeViewId && views.length > 1) {
const remaining = views.filter((v) => v.id !== viewId);
onViewChange(remaining[0].id);
}
},
[views, baseId, activeViewId, deleteViewMutation, onViewChange],
);
return (
<Group gap={4}>
{views.map((view) => (
<ViewTab
key={view.id}
view={view}
isActive={view.id === activeViewId}
isEditing={view.id === editingViewId}
editingName={editingName}
canDelete={views.length > 1}
onClick={() => onViewChange(view.id)}
onRenameStart={() => handleRenameStart(view)}
onRenameChange={setEditingName}
onRenameCommit={handleRenameCommit}
onRenameKeyDown={handleRenameKeyDown}
onDelete={() => handleDelete(view.id)}
/>
))}
{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,
onClick,
onRenameStart,
onRenameChange,
onRenameCommit,
onRenameKeyDown,
onDelete,
}: {
view: IBaseView;
isActive: boolean;
isEditing: boolean;
editingName: string;
canDelete: boolean;
onClick: () => void;
onRenameStart: () => void;
onRenameChange: (name: string) => void;
onRenameCommit: () => void;
onRenameKeyDown: (e: React.KeyboardEvent) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false);
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
);
}
return (
<Popover
opened={menuOpened}
onClose={() => setMenuOpened(false)}
position="bottom-start"
shadow="md"
width={180}
withinPortal
>
<Popover.Target>
<UnstyledButton
onClick={onClick}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpened(true);
}}
style={{
padding: "4px 10px",
borderRadius: "var(--mantine-radius-sm)",
fontWeight: isActive ? 600 : 400,
}}
>
<Group gap={6} wrap="nowrap">
<IconTable size={14} opacity={0.5} />
<Text
size="sm"
c={isActive ? undefined : "dimmed"}
>
{view.name}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={4}>
<Stack gap={0}>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onRenameStart();
}}
>
<Group gap={8} wrap="nowrap">
<IconPencil size={14} />
<Text size="sm">{t("Rename")}</Text>
</Group>
</UnstyledButton>
{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>
);
}
@@ -1,269 +0,0 @@
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 {
IBaseProperty,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
import { selectedRowIdsAtom } from "@/features/base/atoms/base-atoms";
import { IPagination } from "@/lib/types";
type BaseRowCreated = {
operation: "base:row:created";
baseId: string;
row: IBaseRow;
requestId?: string | null;
};
type BaseRowUpdated = {
operation: "base:row:updated";
baseId: string;
rowId: string;
updatedCells: Record<string, unknown>;
requestId?: string | null;
};
type BaseRowDeleted = {
operation: "base:row:deleted";
baseId: string;
rowId: string;
requestId?: string | null;
};
type BaseRowsDeleted = {
operation: "base:rows:deleted";
baseId: string;
rowIds: string[];
requestId?: string | null;
};
type BaseRowReordered = {
operation: "base:row:reordered";
baseId: string;
rowId: string;
position: string;
requestId?: string | null;
};
type BasePropertyEvent = {
operation:
| "base:property:created"
| "base:property:updated"
| "base:property:deleted"
| "base:property:reordered";
baseId: string;
property?: IBaseProperty;
propertyId?: string;
requestId?: string | null;
};
type BaseViewEvent = {
operation:
| "base:view:created"
| "base:view:updated"
| "base:view:deleted";
baseId: string;
view?: IBaseView;
viewId?: string;
};
type BaseInboundEvent =
| BaseRowCreated
| BaseRowUpdated
| BaseRowDeleted
| BaseRowsDeleted
| BaseRowReordered
| BasePropertyEvent
| BaseViewEvent
| { operation: string; baseId: string };
/*
* Module-level set of requestIds we've just sent to the server. When the
* socket echoes back the mutation as a `base:row:*` / `base:property:*`
* event with a matching `requestId`, the socket handler drops it because
* the local mutation already updated the cache. Bounded so it can't grow
* unbounded on a long-lived tab.
*/
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 server's `base-{baseId}`
* room on mount, leaves on unmount, and reconciles the React Query caches
* (`["base-rows", baseId, ...]` and `["bases", baseId]`) when events
* arrive from other clients.
*/
export function useBaseSocket(baseId: string | undefined): void {
const socket = useAtomValue(socketAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!socket || !baseId) return;
socket.emit("message", { operation: "base:subscribe", baseId });
const handler = (raw: unknown) => {
if (!raw || typeof raw !== "object") return;
const event = raw as BaseInboundEvent;
if (event.baseId !== baseId) 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", baseId] },
(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", baseId] },
(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", baseId] },
(old) =>
!old
? old
: {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== e.rowId),
})),
},
);
const store = getDefaultStore();
const current = store.get(selectedRowIdsAtom);
if (current.has(e.rowId)) {
const next = new Set(current);
next.delete(e.rowId);
store.set(selectedRowIdsAtom, 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", baseId] },
(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 current = store.get(selectedRowIdsAtom);
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(selectedRowIdsAtom, next);
}
break;
}
case "base:row:reordered": {
const e = event as BaseRowReordered;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", baseId] },
(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: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 touch the base's `properties` /
// `views`, not the cell data — so we invalidate just
// `["bases", baseId]` here. Row reconciliation is handled
// per-event by the row cases above.
queryClient.invalidateQueries({ queryKey: ["bases", baseId] });
break;
}
default:
break;
}
};
socket.on("message", handler);
return () => {
socket.off("message", handler);
socket.emit("message", { operation: "base:unsubscribe", baseId });
};
}, [socket, baseId, queryClient]);
}
@@ -1,418 +0,0 @@
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
createColumnHelper,
ColumnDef,
SortingState,
ColumnSizingState,
VisibilityState,
ColumnOrderState,
ColumnPinningState,
Table,
} from "@tanstack/react-table";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
ViewConfig,
} from "@/features/base/types/base.types";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
const DEFAULT_COLUMN_WIDTH = 180;
const MIN_COLUMN_WIDTH = 80;
const MAX_COLUMN_WIDTH = 600;
const ROW_NUMBER_COLUMN_WIDTH = 64;
export const SYSTEM_PROPERTY_TYPES = new Set(["createdAt", "lastEditedAt", "lastEditedBy"]);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
const columnHelper = createColumnHelper<IBaseRow>();
function getSystemAccessor(type: string): ((row: IBaseRow) => unknown) | null {
switch (type) {
case "createdAt":
return (row) => row.createdAt;
case "lastEditedAt":
return (row) => row.updatedAt;
case "lastEditedBy":
return (row) => row.lastUpdatedById ?? row.creatorId;
default:
return null;
}
}
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 = getSystemAccessor(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[],
): ColumnPinningState {
const primary = properties.find((p) => p.isPrimary);
return {
left: primary ? ["__row_number", primary.id] : ["__row_number"],
right: [],
};
}
// Serializes the live react-table state into a persisted ViewConfig.
// Sort/filter toolbar mutations and the debounced `persistViewConfig`
// both go through this so a direct mutation (e.g. adding a sort) can't
// clobber a pending hide/reorder/resize by reading stale `activeView.config`.
export function buildViewConfigFromTable(
table: Table<IBaseRow>,
base: ViewConfig | undefined,
overrides: Partial<ViewConfig> = {},
): ViewConfig {
// Guard against corrupted persisted configs — if `base` ever comes
// back as something other than a plain object (e.g. a jsonb-stored
// string `"{}"` from a buggy seed), spreading it would iterate its
// characters into keys `0`, `1`, … and poison the config forever.
const safeBase =
base && typeof base === "object" && !Array.isArray(base) ? base : {};
const state = table.getState();
const sorts = state.sorting.map((s) => ({
propertyId: s.id,
direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
}));
const propertyWidths: Record<string, number> = {};
Object.entries(state.columnSizing).forEach(([id, width]) => {
if (id !== "__row_number") propertyWidths[id] = 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 {
...safeBase,
sorts,
propertyWidths,
propertyOrder,
hiddenPropertyIds,
visiblePropertyIds: undefined,
...overrides,
};
}
export type UseBaseTableResult = {
table: Table<IBaseRow>;
persistViewConfig: () => void;
};
export type UseBaseTableOptions = {
// When provided, `persistViewConfig` uses this as the authoritative
// filter/sorts for the server write. The table's live sorting state is
// ignored for that axis so a locally-drafted sort/filter (kept in
// `activeView.config` for rendering purposes) cannot leak into the
// auto-persist column-layout path. Optional to preserve existing
// callers that pass the real baseline as `activeView`.
baselineConfig?: ViewConfig;
};
export function useBaseTable(
base: IBase | undefined,
rows: IBaseRow[],
activeView: IBaseView | undefined,
opts: UseBaseTableOptions = {},
): UseBaseTableResult {
const updateViewMutation = useUpdateViewMutation();
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// While a local edit is pending (debounce scheduled OR mutation in
// flight), the reconcile effect preserves local state so we don't
// stomp the user's in-flight toggle. When no local edit is pending,
// the effect adopts server state — that's what makes remote updates
// (another client hiding a column) actually show up on this client.
const [hasPendingEdit, setHasPendingEdit] = useState(false);
// `base?.properties ?? []` minted a fresh `[]` every render while the
// base query was loading, which invalidated every downstream memo and
// tripped the setState-in-useEffect pairs below → "Maximum update
// depth exceeded". Memoize so the identity is stable.
const properties = useMemo(() => base?.properties ?? [], [base?.properties]);
const viewConfig = activeView?.config;
const columns = useMemo(
() => buildColumns(properties),
[properties],
);
const initialSorting = useMemo(
() => buildSortingState(viewConfig),
[viewConfig],
);
const initialColumnSizing = 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);
// Re-seed from server only when the user switches views. Within the same
// view, local state is the source of truth — the debounced persist flushes
// it. Without this guard, any ws-driven `invalidateQueries(["bases", baseId])`
// or concurrent view mutation lands a new `derivedColumnVisibility`
// reference and the effect would overwrite a pending hide/reorder toggle
// before `persistViewConfig` has a chance to flush it.
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
// View switch → full re-seed from the server's stored config.
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
return;
}
// Same view. If a local edit is pending (user just toggled and
// the debounce hasn't flushed yet, or the mutation is in flight),
// preserve local state — only reconcile the id set so that newly
// created columns show up and deleted columns drop out without
// stomping the user's toggle. If nothing local is pending, adopt
// the server's state — this is what lets remote updates from
// other clients show up here.
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;
});
} else {
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [
activeView?.id,
derivedColumnOrder,
derivedColumnVisibility,
properties,
hasPendingEdit,
]);
const columnPinning = useMemo(
() => buildColumnPinning(properties),
[properties],
);
const table = useReactTable({
data: rows,
columns,
state: {
columnPinning,
columnOrder,
columnVisibility,
},
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
initialState: {
sorting: initialSorting,
columnSizing: initialColumnSizing,
},
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;
// `baseline` is the server-side-of-truth config. When the caller has
// wrapped `activeView` with draft filter/sort values for render, they
// pass the pre-wrap config here so we never round-trip drafts through
// the column-layout auto-save path.
const baseline = opts.baselineConfig ?? activeView.config;
const config = buildViewConfigFromTable(table, baseline, {
sorts: baseline?.sorts,
filter: baseline?.filter,
});
updateViewMutation.mutate(
{ viewId: activeView.id, baseId: base.id, config },
{
onSettled: () => {
// Don't clear if the user has already scheduled another
// debounce while this one was in flight.
if (persistTimerRef.current === null) {
setHasPendingEdit(false);
}
},
},
);
}, 300);
}, [activeView, base, table, updateViewMutation, opts.baselineConfig]);
return { table, persistViewConfig };
}
@@ -1,26 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow } from "@/features/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,
};
}
@@ -1,55 +0,0 @@
import { useCallback } from "react";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteRowsMutation } from "@/features/base/queries/base-row-query";
const BATCH_SIZE = 500;
export function useDeleteSelectedRows(baseId: string) {
const { t } = useTranslation();
const { selectedIds, clear } = useRowSelection();
const mutation = useDeleteRowsMutation();
const runDelete = useCallback(
async (ids: string[]) => {
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
chunks.push(ids.slice(i, i + BATCH_SIZE));
}
try {
for (const chunk of chunks) {
await mutation.mutateAsync({ baseId, rowIds: chunk });
}
notifications.show({
message: t("{{count}} rows deleted", { count: ids.length }),
});
clear();
} catch {
// mutation onError already shows notification
}
},
[baseId, mutation, clear, t],
);
const deleteSelected = useCallback(() => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
modals.openConfirmModal({
title: t("Delete {{count}} rows?", { count: ids.length }),
centered: true,
children: (
<Text size="sm">
{t("This action cannot be undone.")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => void runDelete(ids),
});
}, [selectedIds, runDelete, t]);
return { deleteSelected, isPending: mutation.isPending };
}
@@ -1,117 +0,0 @@
import { useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
type UseGridKeyboardNavOptions = {
table: Table<IBaseRow>;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
};
export function useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef,
}: UseGridKeyboardNavOptions) {
const getNavigableColumns = useCallback(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table]);
const getRowIds = useCallback(() => {
return table.getRowModel().rows.map((row) => row.id);
}, [table]);
const navigate = useCallback(
(rowDelta: number, colDelta: number) => {
if (!editingCell) return;
const columns = getNavigableColumns();
const rowIds = getRowIds();
const currentColIndex = columns.indexOf(editingCell.propertyId);
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
if (currentColIndex === -1 || currentRowIndex === -1) return;
let nextColIndex = currentColIndex + colDelta;
let nextRowIndex = currentRowIndex + rowDelta;
if (nextColIndex < 0) {
nextColIndex = columns.length - 1;
nextRowIndex -= 1;
} else if (nextColIndex >= columns.length) {
nextColIndex = 0;
nextRowIndex += 1;
}
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
setEditingCell({
rowId: rowIds[nextRowIndex],
propertyId: columns[nextColIndex],
});
},
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!editingCell) return;
const target = e.target as HTMLElement;
const isInputActive =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable;
switch (e.key) {
case "ArrowUp":
if (!isInputActive) {
e.preventDefault();
navigate(-1, 0);
}
break;
case "ArrowDown":
if (!isInputActive) {
e.preventDefault();
navigate(1, 0);
}
break;
case "ArrowLeft":
if (!isInputActive) {
e.preventDefault();
navigate(0, -1);
}
break;
case "ArrowRight":
if (!isInputActive) {
e.preventDefault();
navigate(0, 1);
}
break;
case "Tab":
e.preventDefault();
navigate(0, e.shiftKey ? -1 : 1);
break;
case "Escape":
e.preventDefault();
setEditingCell(null);
break;
}
},
[editingCell, navigate, setEditingCell],
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, handleKeyDown]);
}
@@ -1,65 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
type UseListKeyboardNavResult = {
activeIndex: number;
setActiveIndex: (idx: number) => void;
handleNavKey: (e: React.KeyboardEvent) => boolean;
setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
};
export function useListKeyboardNav(
itemCount: number,
resetDeps: ReadonlyArray<unknown>,
): UseListKeyboardNavResult {
const [activeIndex, setActiveIndex] = useState(-1);
const optionRefs = useRef<Array<HTMLElement | null>>([]);
// Reset highlight when filter/open-state changes. resetDeps is intentional.
useEffect(() => {
setActiveIndex(-1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, resetDeps);
useEffect(() => {
if (activeIndex < 0) return;
const el = optionRefs.current[activeIndex];
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const setOptionRef = useCallback(
(idx: number) => (el: HTMLElement | null) => {
optionRefs.current[idx] = el;
},
[],
);
const handleNavKey = useCallback(
(e: React.KeyboardEvent): boolean => {
if (itemCount === 0) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
return true;
}
if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
return true;
}
if (e.key === "End") {
e.preventDefault();
setActiveIndex(itemCount - 1);
return true;
}
return false;
},
[itemCount],
);
return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
}
@@ -1,115 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react";
type RowDragState = {
dragRowId: string | null;
dropTargetRowId: string | null;
dropPosition: "above" | "below" | null;
};
type UseRowDragOptions = {
rowIds: string[];
onReorder: (rowId: string, targetRowId: string, position: "above" | "below") => void;
};
export function useRowDrag({ rowIds, onReorder }: UseRowDragOptions) {
const [dragState, setDragState] = useState<RowDragState>({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
const dragRowIdRef = useRef<string | null>(null);
const dropTargetRef = useRef<string | null>(null);
const dropPositionRef = useRef<"above" | "below" | null>(null);
const onReorderRef = useRef(onReorder);
onReorderRef.current = onReorder;
const handleDragStart = useCallback((rowId: string) => {
dragRowIdRef.current = rowId;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: rowId,
dropTargetRowId: null,
dropPosition: null,
});
}, []);
const handleDragOver = useCallback(
(targetRowId: string, e: React.DragEvent) => {
e.preventDefault();
if (!dragRowIdRef.current || dragRowIdRef.current === targetRowId) return;
const rect = e.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "above" | "below" = e.clientY < midY ? "above" : "below";
if (dropTargetRef.current === targetRowId && dropPositionRef.current === position) {
return;
}
dropTargetRef.current = targetRowId;
dropPositionRef.current = position;
setDragState({
dragRowId: dragRowIdRef.current,
dropTargetRowId: targetRowId,
dropPosition: position,
});
},
[],
);
const handleDragEnd = useCallback(() => {
const dragRowId = dragRowIdRef.current;
const dropTargetRowId = dropTargetRef.current;
const dropPosition = dropPositionRef.current;
if (dragRowId && dropTargetRowId && dropPosition && dragRowId !== dropTargetRowId) {
onReorderRef.current(dragRowId, dropTargetRowId, dropPosition);
}
dragRowIdRef.current = null;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
}, []);
const handleDragLeave = useCallback(() => {
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState((prev) => ({
...prev,
dropTargetRowId: null,
dropPosition: null,
}));
}, []);
useEffect(() => {
const handleGlobalDragEnd = () => {
dragRowIdRef.current = null;
dropTargetRef.current = null;
dropPositionRef.current = null;
setDragState({
dragRowId: null,
dropTargetRowId: null,
dropPosition: null,
});
};
document.addEventListener("dragend", handleGlobalDragEnd);
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
}, []);
return {
dragState,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDragLeave,
};
}
@@ -1,99 +0,0 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import {
selectedRowIdsAtom,
lastToggledRowIndexAtom,
} from "@/features/base/atoms/base-atoms";
type ToggleOpts = {
shiftKey: boolean;
rowIndex: number;
orderedRowIds: string[];
};
export function useRowSelection() {
const [selectedIds, setSelectedIds] = useAtom(selectedRowIdsAtom) as unknown as [
Set<string>,
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => void,
];
const [lastToggledIndex, setLastToggledIndex] = useAtom(
lastToggledRowIndexAtom,
) as unknown as [number | null, (val: number | null) => void];
const isSelected = useCallback(
(rowId: string) => selectedIds.has(rowId),
[selectedIds],
);
const toggle = useCallback(
(rowId: string, opts: ToggleOpts) => {
const { shiftKey, rowIndex, orderedRowIds } = opts;
const next = new Set(selectedIds);
if (shiftKey && lastToggledIndex !== null && lastToggledIndex !== rowIndex) {
const start = Math.min(lastToggledIndex, rowIndex);
const end = Math.max(lastToggledIndex, rowIndex);
const anchorId = orderedRowIds[lastToggledIndex];
const turnOn = anchorId ? next.has(anchorId) : true;
for (let i = start; i <= end; i += 1) {
const id = orderedRowIds[i];
if (!id) continue;
if (turnOn) next.add(id);
else next.delete(id);
}
} else {
if (next.has(rowId)) next.delete(rowId);
else next.add(rowId);
}
setSelectedIds(next);
setLastToggledIndex(rowIndex);
},
[selectedIds, lastToggledIndex, setSelectedIds, setLastToggledIndex],
);
const toggleAll = useCallback(
(loadedRowIds: string[]) => {
if (loadedRowIds.length === 0) return;
const allSelected = loadedRowIds.every((id) => selectedIds.has(id));
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(loadedRowIds));
}
setLastToggledIndex(null);
},
[selectedIds, setSelectedIds, setLastToggledIndex],
);
const clear = useCallback(() => {
setSelectedIds(new Set());
setLastToggledIndex(null);
}, [setSelectedIds, setLastToggledIndex]);
const removeIds = useCallback(
(rowIds: string[]) => {
if (rowIds.length === 0) return;
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
let changed = false;
const next = new Set(prev);
for (const id of rowIds) {
if (next.delete(id)) changed = true;
}
return changed ? next : prev;
});
},
[setSelectedIds],
);
return {
selectedIds,
selectionCount: selectedIds.size,
isSelected,
toggle,
toggleAll,
clear,
removeIds,
};
}
@@ -1,156 +0,0 @@
import { useCallback, useMemo } from "react";
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import {
BaseViewDraft,
FilterGroup,
ViewConfig,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { viewDraftAtomFamily } from "@/features/base/atoms/view-draft-atom";
export type UseViewDraftArgs = {
userId: string | undefined;
baseId: string | undefined;
viewId: string | undefined;
baselineFilter: FilterGroup | undefined;
baselineSorts: ViewSortConfig[] | undefined;
};
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;
};
// JSON-stringify equality is good enough for FilterGroup (pure data tree)
// and ViewSortConfig[] — V8 preserves non-numeric key insertion order so
// the same object graph serializes identically. Avoids pulling in
// lodash/fast-deep-equal for two known-shaped types. (Spec "Dirty check".)
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);
}
export function useViewDraft(args: UseViewDraftArgs): ViewDraftState {
const { userId, baseId, viewId, baselineFilter, baselineSorts } = args;
const ready = !!(userId && baseId && viewId);
// Always mount an atom with a stable shape so hook order is consistent.
// When not ready we still feed a key, but we won't read/write it.
const atomKey = useMemo(
() => ({
userId: userId ?? "",
baseId: baseId ?? "",
viewId: viewId ?? "",
}),
[userId, baseId, viewId],
);
const [storedDraft, setDraft] = useAtom(viewDraftAtomFamily(atomKey));
const draft = ready ? storedDraft : null;
const setFilter = useCallback(
(next: FilterGroup | undefined) => {
if (!ready) return;
const current = storedDraft ?? null;
const mergedFilter = next;
const mergedSorts = current?.sorts;
if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
setDraft(RESET);
return;
}
setDraft({
filter: mergedFilter,
sorts: mergedSorts,
updatedAt: new Date().toISOString(),
});
},
[ready, storedDraft, setDraft],
);
const setSorts = useCallback(
(next: ViewSortConfig[] | undefined) => {
if (!ready) return;
const current = storedDraft ?? null;
const mergedFilter = current?.filter;
const mergedSorts = next;
if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
setDraft(RESET);
return;
}
setDraft({
filter: mergedFilter,
sorts: mergedSorts,
updatedAt: new Date().toISOString(),
});
},
[ready, storedDraft, setDraft],
);
const reset = useCallback(() => {
if (!ready) return;
setDraft(RESET);
}, [ready, setDraft]);
const effectiveFilter = useMemo(
() => (draft?.filter !== undefined ? draft.filter : baselineFilter),
[draft?.filter, baselineFilter],
);
const effectiveSorts = useMemo(
() => (draft?.sorts !== undefined ? draft.sorts : baselineSorts),
[draft?.sorts, baselineSorts],
);
const isDirty = useMemo(() => {
if (!draft) return false;
const filterDirty =
draft.filter !== undefined && !filterEq(draft.filter, baselineFilter);
const sortsDirty =
draft.sorts !== undefined && !sortsEq(draft.sorts, baselineSorts);
return filterDirty || sortsDirty;
}, [draft, baselineFilter, baselineSorts]);
const buildPromotedConfig = useCallback(
(baseline: ViewConfig): ViewConfig => ({
...baseline,
filter: draft?.filter ?? baseline.filter,
sorts: draft?.sorts ?? baseline.sorts,
}),
[draft],
);
if (!ready) {
return {
draft: null,
effectiveFilter: baselineFilter,
effectiveSorts: baselineSorts,
isDirty: false,
setFilter: () => {},
setSorts: () => {},
reset: () => {},
buildPromotedConfig: (baseline) => baseline,
};
}
return {
draft,
effectiveFilter,
effectiveSorts,
isDirty,
setFilter,
setSorts,
reset,
buildPromotedConfig,
};
}
@@ -1,65 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import api from "@/lib/api-client";
export type ResolvedPage = {
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string } | null;
};
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
if (pageIds.length === 0) return [];
const res = await api.post<{ items: ResolvedPage[] }>(
"/bases/pages/resolve",
{ pageIds },
);
return res.data.items;
}
// Stable, sorted, deduped list so the query key is consistent across renders
// no matter what order the caller hands us the ids in.
function normalize(ids: (string | null | undefined)[]): string[] {
const set = new Set<string>();
for (const id of ids) {
if (typeof id === "string" && id.length > 0) set.add(id);
}
return Array.from(set).sort();
}
export type PageResolution = {
// Map distinguishes three states via lookup:
// - key absent → id not requested
// - value undefined → still resolving (query pending, or stale fetch in flight)
// - value null → resolved and not accessible (deleted, restricted, cross-workspace)
// - value ResolvedPage → resolved and accessible
pages: Map<string, ResolvedPage | null | undefined>;
isLoading: boolean;
};
export function useResolvedPages(
pageIds: (string | null | undefined)[],
): PageResolution {
const normalized = useMemo(() => normalize(pageIds), [pageIds]);
const { data, isSuccess, isLoading } = useQuery({
queryKey: ["bases", "pages", "resolve", normalized],
queryFn: () => resolvePages(normalized),
enabled: normalized.length > 0,
staleTime: 30_000,
gcTime: 5 * 60_000,
});
const pages = useMemo(() => {
const map = new Map<string, ResolvedPage | null | undefined>();
// Seed with undefined (= "still resolving") until the fetch succeeds.
for (const id of normalized) map.set(id, isSuccess ? null : undefined);
for (const item of data ?? []) map.set(item.id, item);
return map;
}, [normalized, data, isSuccess]);
return { pages, isLoading };
}
@@ -1,164 +0,0 @@
import { InfiniteData, useMutation } from "@tanstack/react-query";
import {
createProperty,
updateProperty,
deleteProperty,
reorderProperty,
} from "@/features/base/services/base-service";
import {
IBase,
IBaseProperty,
IBaseRow,
CreatePropertyInput,
UpdatePropertyInput,
DeletePropertyInput,
ReorderPropertyInput,
UpdatePropertyResult,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types";
export function useCreatePropertyMutation() {
const { t } = useTranslation();
return useMutation<IBaseProperty, Error, CreatePropertyInput>({
mutationFn: (data) => createProperty(data),
onSuccess: (newProperty) => {
queryClient.setQueryData<IBase>(
["bases", newProperty.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: [...old.properties, newProperty],
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create property"),
color: "red",
});
},
});
}
export function useUpdatePropertyMutation() {
const { t } = useTranslation();
return useMutation<UpdatePropertyResult, Error, UpdatePropertyInput>({
mutationFn: (data) => updateProperty(data),
onSuccess: (result, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.map((p) =>
p.id === result.property.id ? result.property : p,
),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to update property"),
color: "red",
});
},
});
}
export function useDeletePropertyMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeletePropertyInput>({
mutationFn: (data) => deleteProperty(data),
onSuccess: (_, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.filter(
(p) => p.id !== variables.propertyId,
),
};
},
);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) => {
if (!(variables.propertyId in row.cells)) return row;
const { [variables.propertyId]: _, ...rest } = row.cells;
return { ...row, cells: rest };
}),
})),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to delete property"),
color: "red",
});
},
});
}
export function useReorderPropertyMutation() {
const { t } = useTranslation();
return useMutation<void, Error, ReorderPropertyInput, { previous: IBase | undefined }>({
mutationFn: (data) => reorderProperty(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["bases", variables.baseId],
});
const previous = queryClient.getQueryData<IBase>([
"bases",
variables.baseId,
]);
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
properties: old.properties.map((p) =>
p.id === variables.propertyId
? { ...p, position: variables.position }
: p,
),
};
},
);
return { previous };
},
onError: (_, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.baseId],
context.previous,
);
}
notifications.show({
message: t("Failed to reorder property"),
color: "red",
});
},
});
}
@@ -1,87 +0,0 @@
import {
useMutation,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
createBase,
getBaseInfo,
updateBase,
deleteBase,
} from "@/features/base/services/base-service";
import {
IBase,
CreateBaseInput,
UpdateBaseInput,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
export function useBaseQuery(
baseId: string | undefined,
): UseQueryResult<IBase, Error> {
return useQuery({
queryKey: ["bases", baseId],
queryFn: () => getBaseInfo(baseId!),
enabled: !!baseId,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateBaseMutation() {
const { t } = useTranslation();
return useMutation<IBase, Error, CreateBaseInput>({
mutationFn: (data) => createBase(data),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ["bases", "list", data.spaceId],
});
},
onError: () => {
notifications.show({
message: t("Failed to create base"),
color: "red",
});
},
});
}
export function useUpdateBaseMutation() {
const { t } = useTranslation();
return useMutation<IBase, Error, UpdateBaseInput>({
mutationFn: (data) => updateBase(data),
onSuccess: (data) => {
queryClient.setQueryData<IBase>(["bases", data.id], (old) => {
if (!old) return old;
return { ...old, ...data };
});
},
onError: () => {
notifications.show({
message: t("Failed to update base"),
color: "red",
});
},
});
}
export function useDeleteBaseMutation() {
const { t } = useTranslation();
return useMutation<void, Error, { baseId: string; spaceId: string }>({
mutationFn: ({ baseId }) => deleteBase(baseId),
onSuccess: (_, { baseId, spaceId }) => {
queryClient.removeQueries({ queryKey: ["bases", baseId] });
queryClient.invalidateQueries({
queryKey: ["bases", "list", spaceId],
});
notifications.show({ message: t("Base deleted") });
},
onError: () => {
notifications.show({
message: t("Failed to delete base"),
color: "red",
});
},
});
}
@@ -1,314 +0,0 @@
import {
useInfiniteQuery,
useMutation,
InfiniteData,
} from "@tanstack/react-query";
import {
createRow,
updateRow,
deleteRow,
deleteRows,
listRows,
reorderRow,
} from "@/features/base/services/base-service";
import {
IBaseRow,
CreateRowInput,
UpdateRowInput,
DeleteRowInput,
DeleteRowsInput,
ReorderRowInput,
FilterNode,
SearchSpec,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types";
import { markRequestIdOutbound } from "@/features/base/hooks/use-base-socket";
type RowCacheContext = {
snapshots: [readonly unknown[], InfiniteData<IPagination<IBaseRow>> | undefined][];
};
// Generate a fresh requestId and pre-register it as outbound so the
// incoming socket echo is suppressed by `useBaseSocket`.
function newRequestId(): string {
const id =
typeof crypto !== "undefined" &&
typeof (crypto as any).randomUUID === "function"
? (crypto as any).randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
markRequestIdOutbound(id);
return id;
}
export function useBaseRowsQuery(
baseId: string | undefined,
filter?: FilterNode,
sorts?: ViewSortConfig[],
search?: SearchSpec,
) {
const activeFilter = filter ?? undefined;
const activeSorts = sorts?.length ? sorts : undefined;
const activeSearch = search?.query ? search : undefined;
return useInfiniteQuery({
queryKey: ["base-rows", baseId, activeFilter, activeSorts, activeSearch],
queryFn: ({ pageParam }) =>
listRows(baseId!, {
cursor: pageParam,
limit: 100,
filter: activeFilter,
sorts: activeSorts,
search: activeSearch,
}),
enabled: !!baseId,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
lastPage.meta?.nextCursor ?? undefined,
staleTime: 5 * 60 * 1000,
});
}
export function flattenRows(
data: InfiniteData<IPagination<IBaseRow>> | undefined,
): IBaseRow[] {
if (!data) return [];
return data.pages.flatMap((page) => page.items);
}
export function useCreateRowMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, CreateRowInput>({
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
onSuccess: (newRow) => {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", newRow.baseId] },
(old) => {
if (!old) return old;
const lastPageIndex = old.pages.length - 1;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === lastPageIndex) {
return { ...page, items: [...page.items, newRow] };
}
return page;
}),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create row"),
color: "red",
});
},
});
}
export function useUpdateRowMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === variables.rowId
? {
...row,
cells: { ...row.cells, ...variables.cells },
}
: row,
),
})),
};
},
);
return { snapshots };
},
onError: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to update row"),
color: "red",
});
},
onSuccess: (updatedRow) => {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", updatedRow.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === updatedRow.id
? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
: row,
),
})),
};
},
);
},
});
}
export function useDeleteRowMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => row.id !== variables.rowId),
})),
};
},
);
return { snapshots };
},
onError: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete row"),
color: "red",
});
},
});
}
export function useDeleteRowsMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
const removeSet = new Set(variables.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((row) => !removeSet.has(row.id)),
})),
};
},
);
return { snapshots };
},
onError: (_, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to delete rows"),
color: "red",
});
},
});
}
export function useReorderRowMutation() {
const { t } = useTranslation();
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ queryKey: ["base-rows", variables.baseId] },
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((row) =>
row.id === variables.rowId
? { ...row, position: variables.position }
: row,
),
})),
};
},
);
return { snapshots };
},
onError: (_, variables, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to reorder row"),
color: "red",
});
},
});
}
@@ -1,137 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import {
createView,
updateView,
deleteView,
} from "@/features/base/services/base-service";
import {
IBase,
IBaseView,
CreateViewInput,
UpdateViewInput,
DeleteViewInput,
} from "@/features/base/types/base.types";
import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main";
import { useTranslation } from "react-i18next";
export function useCreateViewMutation() {
const { t } = useTranslation();
return useMutation<IBaseView, Error, CreateViewInput>({
mutationFn: (data) => createView(data),
onSuccess: (newView) => {
queryClient.setQueryData<IBase>(
["bases", newView.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: [...old.views, newView],
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to create view"),
color: "red",
});
},
});
}
export function useUpdateViewMutation() {
const { t } = useTranslation();
return useMutation<IBaseView, Error, UpdateViewInput, { previous: IBase | undefined }>({
mutationFn: (data) => updateView(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["bases", variables.baseId],
});
const previous = queryClient.getQueryData<IBase>([
"bases",
variables.baseId,
]);
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.map((v) =>
v.id === variables.viewId
? {
...v,
...(variables.name !== undefined && {
name: variables.name,
}),
...(variables.type !== undefined && {
type: variables.type,
}),
...(variables.config !== undefined && {
config: variables.config,
}),
}
: v,
),
};
},
);
return { previous };
},
onError: (_, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
["bases", variables.baseId],
context.previous,
);
}
notifications.show({
message: t("Failed to update view"),
color: "red",
});
},
onSuccess: (updatedView) => {
queryClient.setQueryData<IBase>(
["bases", updatedView.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.map((v) =>
v.id === updatedView.id ? updatedView : v,
),
};
},
);
},
});
}
export function useDeleteViewMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteViewInput>({
mutationFn: (data) => deleteView(data),
onSuccess: (_, variables) => {
queryClient.setQueryData<IBase>(
["bases", variables.baseId],
(old) => {
if (!old) return old;
return {
...old,
views: old.views.filter((v) => v.id !== variables.viewId),
};
},
);
},
onError: () => {
notifications.show({
message: t("Failed to delete view"),
color: "red",
});
},
});
}
@@ -1,173 +0,0 @@
import api from "@/lib/api-client";
import { saveAs } from "file-saver";
import {
IBase,
IBaseProperty,
IBaseRow,
IBaseView,
CreateBaseInput,
UpdateBaseInput,
CreatePropertyInput,
UpdatePropertyInput,
DeletePropertyInput,
ReorderPropertyInput,
CreateRowInput,
UpdateRowInput,
DeleteRowInput,
DeleteRowsInput,
ReorderRowInput,
CreateViewInput,
UpdateViewInput,
DeleteViewInput,
UpdatePropertyResult,
FilterNode,
SearchSpec,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { IPagination } from "@/lib/types";
// --- Bases ---
export async function createBase(data: CreateBaseInput): Promise<IBase> {
const req = await api.post<IBase>("/bases/create", data);
return req.data;
}
export async function getBaseInfo(baseId: string): Promise<IBase> {
const req = await api.post<IBase>("/bases/info", { baseId });
return req.data;
}
export async function updateBase(data: UpdateBaseInput): Promise<IBase> {
const req = await api.post<IBase>("/bases/update", data);
return req.data;
}
export async function deleteBase(baseId: string): Promise<void> {
await api.post("/bases/delete", { baseId });
}
export async function exportBaseToCsv(baseId: string): Promise<void> {
const req = await api.post(
"/bases/export-csv",
{ baseId },
{ responseType: "blob" },
);
const header = (req?.headers?.["content-disposition"] as string) ?? "";
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
const plainMatch = header.match(/filename="?([^";]+)"?/i);
let fileName = utf8Match?.[1] ?? plainMatch?.[1] ?? "base.csv";
try {
fileName = decodeURIComponent(fileName);
} catch {
// fallback to raw filename
}
saveAs(req.data, fileName);
}
export async function listBases(
spaceId: string,
params?: { cursor?: string; limit?: number },
): Promise<IPagination<IBase>> {
const req = await api.post("/bases", { spaceId, ...params });
return req.data;
}
// --- Properties ---
export async function createProperty(
data: CreatePropertyInput,
): Promise<IBaseProperty> {
const req = await api.post<IBaseProperty>("/bases/properties/create", data);
return req.data;
}
export async function updateProperty(
data: UpdatePropertyInput,
): Promise<UpdatePropertyResult> {
const req = await api.post<UpdatePropertyResult>(
"/bases/properties/update",
data,
);
return req.data;
}
export async function deleteProperty(data: DeletePropertyInput): Promise<void> {
await api.post("/bases/properties/delete", data);
}
export async function reorderProperty(
data: ReorderPropertyInput,
): Promise<void> {
await api.post("/bases/properties/reorder", data);
}
// --- Rows ---
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/create", data);
return req.data;
}
export async function getRowInfo(
rowId: string,
baseId: string,
): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/info", { rowId, baseId });
return req.data;
}
export async function updateRow(data: UpdateRowInput): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/update", data);
return req.data;
}
export async function deleteRow(data: DeleteRowInput): Promise<void> {
await api.post("/bases/rows/delete", data);
}
export async function deleteRows(data: DeleteRowsInput): Promise<void> {
await api.post("/bases/rows/delete-many", data);
}
export async function listRows(
baseId: string,
params?: {
viewId?: string;
cursor?: string;
limit?: number;
filter?: FilterNode;
sorts?: ViewSortConfig[];
search?: SearchSpec;
},
): Promise<IPagination<IBaseRow>> {
const req = await api.post("/bases/rows", { baseId, ...params });
return req.data;
}
export async function reorderRow(data: ReorderRowInput): Promise<void> {
await api.post("/bases/rows/reorder", data);
}
// --- Views ---
export async function createView(data: CreateViewInput): Promise<IBaseView> {
const req = await api.post<IBaseView>("/bases/views/create", data);
return req.data;
}
export async function updateView(data: UpdateViewInput): Promise<IBaseView> {
const req = await api.post<IBaseView>("/bases/views/update", data);
return req.data;
}
export async function deleteView(data: DeleteViewInput): Promise<void> {
await api.post("/bases/views/delete", data);
}
export async function listViews(baseId: string): Promise<IBaseView[]> {
const req = await api.post<IBaseView[]>("/bases/views", { baseId });
return req.data;
}
@@ -1,47 +0,0 @@
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
}
.toolbarTabs {
display: flex;
gap: 6px;
flex: 1;
}
.toolbarActions {
display: flex;
gap: var(--mantine-spacing-xs);
margin-left: auto;
}
.gridWrapper {
overflow: hidden;
flex: 1;
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.grid {
display: grid;
}
.cellInner {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 0 8px;
}
.headerCellInner {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
width: 100%;
padding: 0 8px;
}
@@ -1,342 +0,0 @@
.cellInput {
width: 100%;
height: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--mantine-font-size-sm);
font-family: inherit;
color: inherit;
padding: 0 8px;
}
.cellInput::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.numberValue {
text-align: right;
width: 100%;
}
.badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 12px;
font-size: var(--mantine-font-size-xs);
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.badgeGroup {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.overflowCount {
font-size: 11px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
flex-shrink: 0;
white-space: nowrap;
}
.checkboxCell {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
cursor: pointer;
}
.urlLink {
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.urlLink:hover {
text-decoration: underline;
}
.emailLink {
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.emailLink:hover {
text-decoration: underline;
}
.dateValue {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.emptyValue {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
}
/* Person cell — read mode (vertical list like Notion) */
.personGroup {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.personRow {
display: flex;
align-items: center;
gap: 6px;
}
.personName {
font-size: var(--mantine-font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Person cell — edit mode (tag input + dropdown) */
.personTagArea {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 6px 8px;
min-height: 34px;
}
.personTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 2px;
border-radius: 3px;
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
font-size: var(--mantine-font-size-xs);
white-space: nowrap;
max-width: 160px;
}
.personTagName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.personTagRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 2px;
background: transparent;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
cursor: pointer;
flex-shrink: 0;
}
.personTagRemove:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}
.personTagInput {
flex: 1;
min-width: 60px;
border: none;
outline: none;
background: transparent;
font-size: var(--mantine-font-size-xs);
font-family: inherit;
color: inherit;
padding: 2px 0;
}
.personTagInput::placeholder {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.personDropdownDivider {
height: 1px;
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.personDropdownHint {
padding: 6px 8px;
font-size: 11px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.personOptionName {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fileGroup {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.fileBadge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: var(--mantine-radius-sm);
font-size: 11px;
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.selectDropdown {
max-height: 240px;
overflow-y: auto;
}
.selectOption {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: var(--mantine-radius-sm);
transition: background-color 100ms ease;
}
.selectOption:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.selectOptionActive {
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-blue-9));
}
.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.selectOptionActive.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8));
}
.selectCategoryLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
padding: 8px 8px 4px;
letter-spacing: 0.5px;
}
.menuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
cursor: pointer;
transition: background-color 100ms ease;
}
.menuItem:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.addOptionRow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: var(--mantine-radius-sm);
transition: background-color 100ms ease;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
margin-top: 4px;
}
.addOptionRow:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.addOptionLabel {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
flex-shrink: 0;
}
.pagePill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
text-decoration: none;
max-width: 100%;
overflow: hidden;
}
.pagePill:hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.pagePillIcon {
display: inline-flex;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.pagePillIconFallback {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
flex-shrink: 0;
}
.pagePillText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pageMissing {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-style: italic;
}
@@ -1,466 +0,0 @@
.gridWrapper {
position: relative;
overflow: auto;
overflow-anchor: none;
flex: 1;
min-height: 0;
padding-left: 6px;
}
.grid {
display: grid;
min-width: max-content;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
}
.headerRow {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.headerCell {
position: relative;
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 8px;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-right: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
}
.headerCell:last-child {
border-right: none;
}
.headerCellPinned {
position: sticky;
z-index: 2;
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
.headerCellContent {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.headerCellName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerTypeIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.headerConvertingSpinner {
flex-shrink: 0;
margin-left: auto;
}
.resizeHandle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 3;
}
.resizeHandle::after {
content: "";
position: absolute;
right: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: 1px;
background-color: transparent;
transition: background-color 150ms ease;
}
.resizeHandle:hover::after,
.resizeHandleActive::after {
background-color: var(--mantine-color-blue-5);
}
.rowContainer {
display: contents;
}
.row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.row:hover .cell {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
.cell {
display: flex;
align-items: center;
min-height: 36px;
padding: 0 8px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-right: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
overflow: hidden;
cursor: default;
outline: none;
}
.cell:last-child {
border-right: none;
}
.cellPinned {
position: sticky;
z-index: 1;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
}
.row:hover .cellPinned {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
.cellEditing {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: -2px;
z-index: 1;
padding: 0;
}
.cellContent {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowNumberCell {
justify-content: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.rowNumberDraggable {
cursor: grab;
}
.rowNumberDraggable:active {
cursor: grabbing;
}
.rowDragging .cell {
opacity: 0.4;
}
.rowDropAbove .cell {
box-shadow: inset 0 2px 0 0 var(--mantine-color-blue-5);
}
.rowDropBelow .cell {
box-shadow: inset 0 -2px 0 0 var(--mantine-color-blue-5);
}
.addRowButton {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 8px;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
border-top: 1px dashed
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
cursor: pointer;
user-select: none;
transition: background-color 150ms ease;
grid-column: 1 / -1;
}
.addRowButton:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.addColumnButton {
display: flex;
align-items: center;
justify-content: center;
height: 34px;
min-width: 40px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
cursor: pointer;
user-select: none;
transition: background-color 150ms ease;
}
.addColumnButton:hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
grid-column: 1 / -1;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
flex-wrap: wrap;
}
.toolbarRight {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
}
.primaryCell {
font-weight: 500;
}
.rowNumberCellInner {
position: relative;
width: 100%;
height: 100%;
}
.rowNumberIndex,
.rowNumberCheckbox {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
}
.rowNumberCheckbox {
display: none;
}
.rowNumberDragHandle {
position: absolute;
top: 50%;
left: -6px;
transform: translateY(-50%);
display: none;
align-items: center;
justify-content: center;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
cursor: grab;
}
.rowNumberDragHandle:active {
cursor: grabbing;
}
/* On row hover: swap index for checkbox, reveal drag handle */
.row:hover .rowNumberIndex {
display: none;
}
.row:hover .rowNumberCheckbox,
.row:hover .rowNumberDragHandle {
display: inline-flex;
}
/* Selected row: checkbox always visible, drag handle still only on hover */
.rowSelected .rowNumberIndex {
display: none;
}
.rowSelected .rowNumberCheckbox {
display: inline-flex;
}
.rowSelected .cell {
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
}
.row.rowSelected:hover .cell,
.row.rowSelected:hover .cellPinned {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
}
.rowNumberHeaderInner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.rowNumberHeaderHash {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-xs);
}
.rowNumberHeaderCheckbox {
display: none;
}
.headerCell:hover .rowNumberHeaderHash,
.hasSelection .rowNumberHeaderHash {
display: none;
}
.headerCell:hover .rowNumberHeaderCheckbox,
.hasSelection .rowNumberHeaderCheckbox {
display: inline-flex;
}
.selectionActionBarWrapper {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
justify-content: center;
pointer-events: none;
z-index: 200;
}
.selectionActionBar {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 6px 6px 14px;
border-radius: 999px;
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(0, 0, 0, 0.18);
background: light-dark(
var(--mantine-color-dark-8),
var(--mantine-color-dark-5)
);
color: var(--mantine-color-white);
border: 1px solid light-dark(
var(--mantine-color-dark-9),
var(--mantine-color-dark-4)
);
}
.selectionActionBarCount {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: var(--mantine-color-white);
padding-right: 10px;
margin-right: 2px;
border-right: 1px solid rgba(255, 255, 255, 0.15);
}
.selectionActionBarDelete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: none;
background: transparent;
color: var(--mantine-color-red-4);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease;
}
.selectionActionBarDelete:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.selectionActionBarDelete:disabled {
opacity: 0.6;
cursor: default;
}
.selectionActionBarClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--mantine-color-gray-3);
border-radius: 999px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.selectionActionBarClose:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--mantine-color-white);
}
@@ -1,312 +0,0 @@
export type BasePropertyType =
| 'text'
| 'number'
| 'select'
| 'status'
| 'multiSelect'
| 'date'
| 'person'
| 'file'
| 'page'
| 'checkbox'
| 'url'
| 'email'
| 'createdAt'
| 'lastEditedAt'
| 'lastEditedBy';
export type Choice = {
id: string;
name: string;
color: string;
category?: 'todo' | 'inProgress' | 'complete';
};
export type SelectTypeOptions = {
choices: Choice[];
choiceOrder: string[];
disableColors?: boolean;
defaultValue?: string | string[] | null;
};
export type NumberTypeOptions = {
format?: 'plain' | 'currency' | 'percent' | 'progress';
precision?: number;
currencySymbol?: string;
defaultValue?: number | null;
};
export type DateTypeOptions = {
dateFormat?: string;
timeFormat?: '12h' | '24h';
includeTime?: boolean;
defaultValue?: string | null;
};
export type TextTypeOptions = {
richText?: boolean;
defaultValue?: string | null;
};
export type CheckboxTypeOptions = {
defaultValue?: boolean;
};
export type UrlTypeOptions = {
defaultValue?: string | null;
};
export type EmailTypeOptions = {
defaultValue?: string | null;
};
export type PersonTypeOptions = {
allowMultiple?: boolean;
};
export type PageTypeOptions = Record<string, never>;
export type TypeOptions =
| SelectTypeOptions
| NumberTypeOptions
| DateTypeOptions
| TextTypeOptions
| CheckboxTypeOptions
| UrlTypeOptions
| EmailTypeOptions
| PersonTypeOptions
| PageTypeOptions
| Record<string, unknown>;
export type IBaseProperty = {
id: string;
baseId: string;
name: string;
type: BasePropertyType;
position: string;
typeOptions: TypeOptions;
// Set while a background type-conversion job is rewriting cells. The
// live `type` stays on the old kind until the job commits, so cells
// render correctly; the column header shows a "Converting…" badge.
pendingType?: BasePropertyType | null;
pendingTypeOptions?: TypeOptions | null;
isPrimary: boolean;
workspaceId: string;
createdAt: string;
updatedAt: string;
};
export type IBaseRow = {
id: string;
baseId: string;
cells: Record<string, unknown>;
position: string;
creatorId: string;
lastUpdatedById: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
};
export type ViewSortConfig = {
propertyId: string;
direction: 'asc' | 'desc';
};
// Matches the server's engine operator set (core/base/engine/schema.zod.ts).
export type FilterOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'ncontains'
| 'startsWith'
| 'endsWith'
| 'isEmpty'
| 'isNotEmpty'
| 'before'
| 'after'
| 'onOrBefore'
| 'onOrAfter'
| 'any'
| 'none'
| 'all';
export type FilterCondition = {
propertyId: string;
op: FilterOperator;
value?: unknown;
};
export type FilterGroup = {
op: 'and' | 'or';
children: Array<FilterCondition | FilterGroup>;
};
export type FilterNode = FilterCondition | FilterGroup;
export type SearchSpec = {
query: string;
mode?: 'trgm' | 'fts';
};
export type ViewConfig = {
sorts?: ViewSortConfig[];
filter?: FilterGroup;
visiblePropertyIds?: string[];
hiddenPropertyIds?: string[];
propertyWidths?: Record<string, number>;
propertyOrder?: string[];
};
export type IBaseView = {
id: string;
baseId: string;
name: string;
type: 'table' | 'kanban' | 'calendar';
config: ViewConfig;
workspaceId: string;
creatorId: string;
createdAt: string;
updatedAt: string;
};
export type IBase = {
id: string;
name: string;
description?: string;
icon?: string;
pageId?: string;
spaceId: string;
workspaceId: string;
creatorId: string;
properties: IBaseProperty[];
views: IBaseView[];
createdAt: string;
updatedAt: string;
};
export type EditingCell = {
rowId: string;
propertyId: string;
} | null;
export type CreateBaseInput = {
name: string;
description?: string;
icon?: string;
pageId?: string;
spaceId: string;
};
export type UpdateBaseInput = {
baseId: string;
name?: string;
description?: string;
icon?: string;
};
export type CreatePropertyInput = {
baseId: string;
name: string;
type: BasePropertyType;
typeOptions?: TypeOptions;
requestId?: string;
};
export type UpdatePropertyInput = {
propertyId: string;
baseId: string;
name?: string;
typeOptions?: TypeOptions;
requestId?: string;
};
export type DeletePropertyInput = {
propertyId: string;
baseId: string;
requestId?: string;
};
export type ReorderPropertyInput = {
propertyId: string;
baseId: string;
position: string;
requestId?: string;
};
export type CreateRowInput = {
baseId: string;
cells?: Record<string, unknown>;
afterRowId?: string;
requestId?: string;
};
export type UpdateRowInput = {
rowId: string;
baseId: string;
cells: Record<string, unknown>;
requestId?: string;
};
export type DeleteRowInput = {
rowId: string;
baseId: string;
requestId?: string;
};
export type DeleteRowsInput = {
baseId: string;
rowIds: string[];
requestId?: string;
};
export type ReorderRowInput = {
rowId: string;
baseId: string;
position: string;
requestId?: string;
};
export type CreateViewInput = {
baseId: string;
name: string;
type?: 'table' | 'kanban' | 'calendar';
config?: ViewConfig;
};
export type UpdateViewInput = {
viewId: string;
baseId: string;
name?: string;
type?: 'table' | 'kanban' | 'calendar';
config?: ViewConfig;
};
export type DeleteViewInput = {
viewId: string;
baseId: string;
};
export type UpdatePropertyResult = {
property: IBaseProperty;
// Non-null when the property change kicked off a BullMQ type-conversion
// job (select/multiSelect/person/file → anything, or any → system type).
// Client can listen for `base:schema:bumped` on the base room to know
// when the job finished migrating cells.
jobId: string | null;
};
// Local-first draft of filter / sort tweaks for a single view, stored in
// localStorage scoped to (userId, baseId, viewId). An absent `filter` or
// `sorts` field means "inherit the baseline for that axis". See
// `.claude/superpowers/specs/2026-04-20-base-view-draft-design.md`.
export type BaseViewDraft = {
filter?: FilterGroup;
sorts?: ViewSortConfig[];
// ISO timestamp written on each put; diagnostic only, not read by logic.
updatedAt: string;
};
-8
View File
@@ -1,8 +0,0 @@
import "@tanstack/react-table";
import { IBaseProperty } from "@/features/base/types/base.types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
property?: IBaseProperty;
}
}
@@ -102,7 +102,6 @@ function PageByline({
<Group
gap="sm"
mb="md"
className="print-hide"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
>
{creator && (
@@ -14,11 +14,11 @@ import {
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
export function useFavoritesQuery(type?: FavoriteType) {
return useInfiniteQuery({
queryKey: ["favorites", type, spaceId],
queryKey: ["favorites", type],
queryFn: ({ pageParam }) =>
getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }),
getFavorites({ type, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
@@ -26,10 +26,10 @@ export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
});
}
export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string> {
export function useFavoriteIds(type: FavoriteType): Set<string> {
const { data } = useQuery({
queryKey: ["favorite-ids", type, spaceId],
queryFn: () => getFavoriteIds(type, spaceId),
queryKey: ["favorite-ids", type],
queryFn: () => getFavoriteIds(type),
refetchOnMount: true,
});
@@ -52,9 +52,9 @@ export function useAddFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
if (!old) return old;
if (old.items.includes(entityId)) return old;
return { ...old, items: [...old.items, entityId] };
@@ -76,9 +76,9 @@ export function useRemoveFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
if (!old) return old;
return { ...old, items: old.items.filter((id) => id !== entityId) };
},
@@ -21,14 +21,13 @@ export async function removeFavorite(
await api.post("/favorites/remove", params);
}
export async function getFavoriteIds(type: FavoriteType, spaceId?: string): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type, spaceId });
export async function getFavoriteIds(type: FavoriteType): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type });
return req.data;
}
export async function getFavorites(params?: {
type?: FavoriteType;
spaceId?: string;
limit?: number;
cursor?: string;
}): Promise<IPagination<IFavorite>> {
@@ -18,11 +18,7 @@ import { getSpaceUrl } from "@/lib/config";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color";
interface Props {
spaceId?: string;
}
export default function FavoritesPages({ spaceId }: Props) {
export default function FavoritesPages() {
const { t } = useTranslation();
const {
data,
@@ -31,7 +27,7 @@ export default function FavoritesPages({ spaceId }: Props) {
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useFavoritesQuery("page", spaceId);
} = useFavoritesQuery("page");
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
@@ -76,21 +72,19 @@ export default function FavoritesPages({ spaceId }: Props) {
</Group>
</UnstyledButton>
</Table.Td>
{!spaceId && (
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
)}
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
<Table.Td>
<Text
c="dimmed"
@@ -145,7 +145,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId);
const favoriteIds = useFavoriteIds("page");
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
@@ -509,7 +509,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page", spaceId);
const favoriteIds = useFavoriteIds("page");
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.data.id);
@@ -47,7 +47,7 @@ export default function SpaceHomeTabs() {
{space?.id && <RecentChanges spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="favorites">
{space?.id && <FavoritesPages spaceId={space.id} />}
<FavoritesPages />
</Tabs.Panel>
<Tabs.Panel value="created">
{space?.id && <CreatedByMe spaceId={space.id} />}
@@ -9,11 +9,9 @@ export enum SpaceCaslSubject {
Settings = "settings",
Member = "member",
Page = "page",
Base = "base",
}
export type SpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page]
| [SpaceCaslAction, SpaceCaslSubject.Base];
| [SpaceCaslAction, SpaceCaslSubject.Page];
+1 -5
View File
@@ -10,11 +10,7 @@ const api: AxiosInstance = axios.create({
api.interceptors.response.use(
(response) => {
// we need the response headers for these endpoints
const exemptEndpoints = [
"/api/pages/export",
"/api/spaces/export",
"/api/bases/export-csv",
];
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
-32
View File
@@ -1,32 +0,0 @@
import { useParams } from "react-router-dom";
import { Container, Title, Text, Stack } from "@mantine/core";
import { BaseTable } from "@/features/base/components/base-table";
import { useBaseQuery } from "@/features/base/queries/base-query";
export default function BasePage() {
const { baseId } = useParams<{ baseId: string }>();
const { data: base } = useBaseQuery(baseId);
if (!baseId) {
return (
<Stack align="center" p="xl">
<Text c="dimmed">No base ID provided</Text>
</Stack>
);
}
return (
<Container
fluid
p="md"
style={{ height: "calc(100vh - 60px)", display: "flex", flexDirection: "column" }}
>
{base && (
<Title order={3} mb="xs">
{base.icon ? `${base.icon} ` : ""}{base.name}
</Title>
)}
<BaseTable baseId={baseId} />
</Container>
);
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.80.0",
"version": "0.71.1",
"description": "",
"author": "",
"private": true,
@@ -75,7 +75,6 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"csv-stringify": "^6",
"fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
@@ -15,26 +15,4 @@ export enum EventName {
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
BASE_CREATED = 'base.created',
BASE_UPDATED = 'base.updated',
BASE_DELETED = 'base.deleted',
BASE_ROW_CREATED = 'base.row.created',
BASE_ROW_UPDATED = 'base.row.updated',
BASE_ROW_DELETED = 'base.row.deleted',
BASE_ROWS_DELETED = 'base.rows.deleted',
BASE_ROW_RESTORED = 'base.row.restored',
BASE_ROW_REORDERED = 'base.row.reordered',
BASE_PROPERTY_CREATED = 'base.property.created',
BASE_PROPERTY_UPDATED = 'base.property.updated',
BASE_PROPERTY_DELETED = 'base.property.deleted',
BASE_PROPERTY_REORDERED = 'base.property.reordered',
BASE_VIEW_CREATED = 'base.view.created',
BASE_VIEW_UPDATED = 'base.view.updated',
BASE_VIEW_DELETED = 'base.view.deleted',
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
}
-1
View File
@@ -18,7 +18,6 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -47,7 +47,6 @@ import {
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
@@ -72,7 +71,6 @@ export class AttachmentController {
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly baseRepo: BaseRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
@@ -165,87 +163,6 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('bases/files/upload')
@UseInterceptors(FileInterceptor)
async uploadBaseFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const baseId = file.fields?.baseId?.value;
if (!baseId) {
throw new BadRequestException('baseId is required');
}
if (!isValidUUID(baseId)) {
throw new BadRequestException('Invalid baseId');
}
const base = await this.baseRepo.findById(baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const spaceId = base.spaceId;
const spaceAbilityCheck = await this.spaceAbility.createForUser(
user,
spaceId,
);
if (
spaceAbilityCheck.cannot(
SpaceCaslAction.Edit,
SpaceCaslSubject.Base,
)
) {
throw new ForbiddenException();
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@@ -43,7 +43,7 @@ export class AttachmentService {
async uploadFile(opts: {
filePromise: Promise<MultipartFile>;
pageId?: string;
pageId: string;
userId: string;
spaceId: string;
workspaceId: string;
@@ -5,8 +5,6 @@ export enum JwtType {
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
}
export type JwtPayload = {
sub: string;
@@ -47,15 +45,3 @@ export type JwtApiKeyPayload = {
apiKeyId: string;
type: 'api_key';
};
export type JwtPdfRenderPayload = {
pageId: string;
workspaceId: string;
type: 'pdf_render';
};
export type JwtPdfExportDownloadPayload = {
fileTaskId: string;
workspaceId: string;
type: 'pdf_export_download';
};
@@ -13,8 +13,6 @@ import {
JwtExchangePayload,
JwtMfaTokenPayload,
JwtPayload,
JwtPdfExportDownloadPayload,
JwtPdfRenderPayload,
JwtType,
} from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types';
@@ -117,30 +115,6 @@ export class TokenService {
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async generatePdfRenderToken(
pageId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfRenderPayload = {
pageId,
workspaceId,
type: JwtType.PDF_RENDER,
};
return this.jwtService.sign(payload, { expiresIn: '60s' });
}
async generatePdfExportDownloadToken(
fileTaskId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfExportDownloadPayload = {
fileTaskId,
workspaceId,
type: JwtType.PDF_EXPORT_DOWNLOAD,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
-48
View File
@@ -1,48 +0,0 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { BaseController } from './controllers/base.controller';
import { BasePropertyController } from './controllers/base-property.controller';
import { BaseRowController } from './controllers/base-row.controller';
import { BaseViewController } from './controllers/base-view.controller';
import { BaseService } from './services/base.service';
import { BasePropertyService } from './services/base-property.service';
import { BaseRowService } from './services/base-row.service';
import { BaseViewService } from './services/base-view.service';
import { BaseCsvExportService } from './services/base-csv-export.service';
import { BasePageResolverService } from './services/base-page-resolver.service';
import { BaseQueueProcessor } from './processors/base-queue.processor';
import { BaseWsService } from './realtime/base-ws.service';
import { BaseWsConsumers } from './realtime/base-ws-consumers';
import { BasePresenceService } from './realtime/base-presence.service';
import { QueueName } from '../../integrations/queue/constants';
@Module({
imports: [BullModule.registerQueue({ name: QueueName.BASE_QUEUE })],
controllers: [
BaseController,
BasePropertyController,
BaseRowController,
BaseViewController,
],
providers: [
BaseService,
BasePropertyService,
BaseRowService,
BaseViewService,
BaseCsvExportService,
BasePageResolverService,
BaseQueueProcessor,
BasePresenceService,
BaseWsService,
BaseWsConsumers,
],
exports: [
BaseService,
BasePropertyService,
BaseRowService,
BaseViewService,
BaseWsService,
BasePresenceService,
],
})
export class BaseModule {}
-395
View File
@@ -1,395 +0,0 @@
import { z } from 'zod';
export const BasePropertyType = {
TEXT: 'text',
NUMBER: 'number',
SELECT: 'select',
STATUS: 'status',
MULTI_SELECT: 'multiSelect',
DATE: 'date',
PERSON: 'person',
FILE: 'file',
PAGE: 'page',
CHECKBOX: 'checkbox',
URL: 'url',
EMAIL: 'email',
CREATED_AT: 'createdAt',
LAST_EDITED_AT: 'lastEditedAt',
LAST_EDITED_BY: 'lastEditedBy',
} as const;
const SYSTEM_PROPERTY_TYPES: Set<string> = new Set([
BasePropertyType.CREATED_AT,
BasePropertyType.LAST_EDITED_AT,
BasePropertyType.LAST_EDITED_BY,
]);
export function isSystemPropertyType(type: string): boolean {
return SYSTEM_PROPERTY_TYPES.has(type);
}
export type BasePropertyTypeValue =
(typeof BasePropertyType)[keyof typeof BasePropertyType];
export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
export const choiceSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
color: z.string(),
category: z.enum(['todo', 'inProgress', 'complete']).optional(),
});
export const selectTypeOptionsSchema = z
.object({
choices: z.array(choiceSchema).default([]),
choiceOrder: z.array(z.uuid()).default([]),
disableColors: z.boolean().optional(),
defaultValue: z
.union([z.uuid(), z.array(z.uuid())])
.nullable()
.optional(),
})
.passthrough();
export const numberTypeOptionsSchema = z
.object({
format: z
.enum(['plain', 'currency', 'percent', 'progress'])
.optional()
.default('plain'),
precision: z.number().int().min(0).max(10).optional(),
currencySymbol: z.string().max(5).optional(),
defaultValue: z.number().nullable().optional(),
})
.passthrough();
export const dateTypeOptionsSchema = z
.object({
dateFormat: z.string().optional(),
timeFormat: z.enum(['12h', '24h']).optional(),
includeTime: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const textTypeOptionsSchema = z
.object({
richText: z.boolean().optional(),
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const checkboxTypeOptionsSchema = z
.object({
defaultValue: z.boolean().optional(),
})
.passthrough();
export const urlTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const emailTypeOptionsSchema = z
.object({
defaultValue: z.string().nullable().optional(),
})
.passthrough();
export const personTypeOptionsSchema = z
.object({
allowMultiple: z.boolean().default(true),
})
.passthrough();
export const emptyTypeOptionsSchema = z.object({}).passthrough();
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
[BasePropertyType.TEXT]: textTypeOptionsSchema,
[BasePropertyType.NUMBER]: numberTypeOptionsSchema,
[BasePropertyType.SELECT]: selectTypeOptionsSchema,
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
[BasePropertyType.DATE]: dateTypeOptionsSchema,
[BasePropertyType.PERSON]: personTypeOptionsSchema,
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
[BasePropertyType.URL]: urlTypeOptionsSchema,
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
[BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
[BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
};
export function validateTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): z.ZodSafeParseResult<unknown> {
const schema = typeOptionsSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(typeOptions ?? {});
}
export function parseTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): unknown {
const result = validateTypeOptions(type, typeOptions);
if (!result.success) {
throw result.error;
}
return result.data;
}
const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
[BasePropertyType.TEXT]: z.string(),
[BasePropertyType.NUMBER]: z.number(),
[BasePropertyType.SELECT]: z.uuid(),
[BasePropertyType.STATUS]: z.uuid(),
[BasePropertyType.MULTI_SELECT]: z.array(z.uuid()),
[BasePropertyType.DATE]: z.string(),
[BasePropertyType.PERSON]: z.union([z.uuid(), z.array(z.uuid())]),
[BasePropertyType.FILE]: z.array(z.object({
id: z.uuid(),
fileName: z.string(),
mimeType: z.string().optional(),
fileSize: z.number().optional(),
filePath: z.string().optional(),
})),
[BasePropertyType.PAGE]: z.uuid(),
[BasePropertyType.CHECKBOX]: z.boolean(),
[BasePropertyType.URL]: z.url(),
[BasePropertyType.EMAIL]: z.email(),
};
export function getCellValueSchema(
type: BasePropertyTypeValue,
): z.ZodType | undefined {
return cellValueSchemaMap[type];
}
export function validateCellValue(
type: BasePropertyTypeValue,
value: unknown,
): z.ZodSafeParseResult<unknown> {
const schema = cellValueSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(value);
}
/*
* Resolution context for conversions where the source type stores IDs
* (select / multiSelect: choice uuid; person: user uuid; file: attachment
* uuid). Callers must always supply this the only invoker is the
* `BASE_TYPE_CONVERSION` BullMQ worker, which builds the context per
* chunk of rows (see `tasks/base-type-conversion.task.ts`).
*/
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>;
};
function resolveChoiceName(
typeOptions: unknown,
id: unknown,
): string | undefined {
if (!typeOptions || typeof typeOptions !== 'object') return undefined;
const choices = (typeOptions as any).choices;
if (!Array.isArray(choices)) return undefined;
const match = choices.find((c: any) => c?.id === String(id));
return typeof match?.name === 'string' ? match.name : undefined;
}
export function attemptCellConversion(
fromType: BasePropertyTypeValue,
toType: BasePropertyTypeValue,
value: unknown,
ctx: CellConversionContext,
): { converted: boolean; value: unknown } {
if (value === null || value === undefined) {
return { converted: true, value: null };
}
// Resolve IDs to display strings before any direct parse. `select → text`
// and `multiSelect → text` would otherwise short-circuit on z.string()
// parsing the UUID itself and return the raw UUID instead of the name.
if (toType === BasePropertyType.TEXT) {
if (
fromType === BasePropertyType.SELECT ||
fromType === BasePropertyType.STATUS
) {
const name = resolveChoiceName(ctx.fromTypeOptions, value);
return { converted: true, value: name ?? '' };
}
if (fromType === BasePropertyType.MULTI_SELECT && Array.isArray(value)) {
const parts = value
.map((v) => resolveChoiceName(ctx.fromTypeOptions, v))
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
if (fromType === BasePropertyType.PERSON && ctx.userNames) {
const ids = Array.isArray(value) ? value : [value];
const parts = ids
.map((v) => ctx.userNames!.get(String(v)))
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
if (fromType === BasePropertyType.FILE && Array.isArray(value)) {
const parts = value
.map((f: any) => {
if (f && typeof f === 'object') {
if (typeof f.fileName === 'string') return f.fileName;
if (typeof f.id === 'string' && ctx.attachmentNames) {
return ctx.attachmentNames.get(f.id);
}
}
if (typeof f === 'string' && ctx.attachmentNames) {
return ctx.attachmentNames.get(f);
}
return undefined;
})
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return { converted: true, value: parts.join(', ') };
}
if (fromType === BasePropertyType.PAGE && typeof value === 'string') {
const title = ctx.pageTitles?.get(value);
return { converted: true, value: title ?? '' };
}
}
// Page cells only accept a page UUID. Free text / other IDs can't be
// coerced into a valid page reference — drop to null.
if (toType === BasePropertyType.PAGE && fromType !== BasePropertyType.PAGE) {
return { converted: true, value: null };
}
const targetSchema = cellValueSchemaMap[toType];
if (!targetSchema) {
return { converted: false, value: null };
}
const directResult = targetSchema.safeParse(value);
if (directResult.success) {
return { converted: true, value: directResult.data };
}
if (toType === BasePropertyType.TEXT) {
return { converted: true, value: String(value) };
}
if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
const num = Number(value);
if (!isNaN(num)) {
return { converted: true, value: num };
}
}
if (toType === BasePropertyType.CHECKBOX) {
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true' || lower === '1' || lower === 'yes') {
return { converted: true, value: true };
}
if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
return { converted: true, value: false };
}
}
if (typeof value === 'number') {
return { converted: true, value: value !== 0 };
}
}
if (
toType === BasePropertyType.MULTI_SELECT &&
fromType === BasePropertyType.SELECT &&
typeof value === 'string'
) {
return { converted: true, value: [value] };
}
if (
toType === BasePropertyType.SELECT &&
fromType === BasePropertyType.MULTI_SELECT &&
Array.isArray(value) &&
value.length > 0
) {
return { converted: true, value: value[0] };
}
return { converted: false, value: null };
}
export const viewSortSchema = z.object({
propertyId: z.uuid(),
direction: z.enum(['asc', 'desc']),
});
/*
* View-stored filter shape matches the engine's predicate tree (see
* `core/base/engine/schema.zod.ts`). No legacy flat-array / operator-name
* variants are accepted stored view configs use `op` (eq / neq / gt /
* lt / contains / ncontains / ...) and nested and/or groups.
*/
const viewFilterConditionSchema = z.object({
propertyId: z.uuid(),
op: z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'ncontains',
'startsWith',
'endsWith',
'isEmpty',
'isNotEmpty',
'before',
'after',
'onOrBefore',
'onOrAfter',
'any',
'none',
'all',
]),
value: z.unknown().optional(),
});
type ViewFilterCondition = z.infer<typeof viewFilterConditionSchema>;
type ViewFilterGroup = {
op: 'and' | 'or';
children: Array<ViewFilterCondition | ViewFilterGroup>;
};
const viewFilterNodeSchema: z.ZodType<ViewFilterCondition | ViewFilterGroup> =
z.lazy(() => z.union([viewFilterConditionSchema, viewFilterGroupSchema]));
const viewFilterGroupSchema: z.ZodType<ViewFilterGroup> = z.lazy(() =>
z.object({
op: z.enum(['and', 'or']),
children: z.array(viewFilterNodeSchema),
}),
);
export const viewConfigSchema = z
.object({
sorts: z.array(viewSortSchema).optional(),
filter: viewFilterGroupSchema.optional(),
visiblePropertyIds: z.array(z.uuid()).optional(),
hiddenPropertyIds: z.array(z.uuid()).optional(),
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
propertyOrder: z.array(z.uuid()).optional(),
})
.passthrough();
export type ViewConfig = z.infer<typeof viewConfigSchema>;

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