mirror of
https://github.com/docmost/docmost.git
synced 2026-08-26 00:07:04 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbc1b9cbb | ||
|
|
5ec4f19839 | ||
|
|
572452c80b |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
# Base `page` Property Type — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Draft
|
||||||
|
**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist.
|
||||||
|
|
||||||
|
Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below).
|
||||||
|
- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices.
|
||||||
|
- **Creating pages from within the picker.**
|
||||||
|
- **Cross-workspace page linking.**
|
||||||
|
- **Rich previews / hover cards** showing page excerpts — pill-only.
|
||||||
|
- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task.
|
||||||
|
|
||||||
|
## UX overview
|
||||||
|
|
||||||
|
### Picker (edit mode)
|
||||||
|
|
||||||
|
- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`.
|
||||||
|
- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`).
|
||||||
|
- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results.
|
||||||
|
- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct).
|
||||||
|
- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint.
|
||||||
|
- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes.
|
||||||
|
- Esc / click-outside → `onCancel`.
|
||||||
|
- Clicking the "Remove" affordance on the current tag → `onCommit(null)`.
|
||||||
|
- Keyboard: reuse `useListKeyboardNav`.
|
||||||
|
|
||||||
|
### View mode
|
||||||
|
|
||||||
|
- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`).
|
||||||
|
- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses.
|
||||||
|
- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`.
|
||||||
|
- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types).
|
||||||
|
|
||||||
|
### Sort / filter UI
|
||||||
|
|
||||||
|
- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set.
|
||||||
|
- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### Cell value
|
||||||
|
|
||||||
|
- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode.
|
||||||
|
- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID.
|
||||||
|
|
||||||
|
### Property type options
|
||||||
|
|
||||||
|
- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`).
|
||||||
|
- **Future:** `{ allowMultiple?: boolean }`.
|
||||||
|
|
||||||
|
### Schema additions
|
||||||
|
|
||||||
|
**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const BasePropertyType = {
|
||||||
|
// ...existing entries...
|
||||||
|
PAGE: 'page',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// typeOptionsSchemaMap
|
||||||
|
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||||
|
|
||||||
|
// cellValueSchemaMap
|
||||||
|
[BasePropertyType.PAGE]: z.uuid(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type BasePropertyType = ... | 'page';
|
||||||
|
export type PageTypeOptions = Record<string, never>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Property kind & engine
|
||||||
|
|
||||||
|
**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const PropertyKind = {
|
||||||
|
// ...existing...
|
||||||
|
PAGE: 'page',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// propertyKind()
|
||||||
|
case BasePropertyType.PAGE:
|
||||||
|
return PropertyKind.PAGE;
|
||||||
|
```
|
||||||
|
|
||||||
|
**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text):
|
||||||
|
|
||||||
|
- `isEmpty` / `isNotEmpty` → `textCell` is null or empty
|
||||||
|
- `eq` / `neq` → text equality / inequality (null-safe for `neq`)
|
||||||
|
- `any` → `textCell IN (...)`
|
||||||
|
- `none` → `textCell NOT IN (...)` or null
|
||||||
|
|
||||||
|
Wired into the `switch (kind)` in `buildCondition`:
|
||||||
|
```ts
|
||||||
|
case PropertyKind.PAGE:
|
||||||
|
return pageCondition(eb, cond);
|
||||||
|
```
|
||||||
|
|
||||||
|
**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1).
|
||||||
|
|
||||||
|
### Type conversion
|
||||||
|
|
||||||
|
**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type CellConversionContext = {
|
||||||
|
fromTypeOptions?: unknown;
|
||||||
|
userNames?: Map<string, string>;
|
||||||
|
attachmentNames?: Map<string, string>;
|
||||||
|
pageTitles?: Map<string, string>; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`.
|
||||||
|
|
||||||
|
**`attemptCellConversion` branches:**
|
||||||
|
- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing).
|
||||||
|
- `page → *` (anything else): return `{converted: true, value: null}`.
|
||||||
|
- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID).
|
||||||
|
|
||||||
|
## Server: page resolver endpoint
|
||||||
|
|
||||||
|
New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time.
|
||||||
|
|
||||||
|
### `POST /bases/pages/resolve`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```ts
|
||||||
|
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
space: { id: string; slug: string; name: string };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
1. Deduplicate input IDs.
|
||||||
|
2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`.
|
||||||
|
3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts).
|
||||||
|
4. Join `spaces` to include `space.slug` and `space.name` for navigation.
|
||||||
|
5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found".
|
||||||
|
|
||||||
|
### Code layout
|
||||||
|
|
||||||
|
- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses.
|
||||||
|
- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file.
|
||||||
|
- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules.
|
||||||
|
|
||||||
|
## Client: cell component & resolver
|
||||||
|
|
||||||
|
### Batch resolver hook
|
||||||
|
|
||||||
|
New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Deduplicate + sort IDs to form a stable React Query key.
|
||||||
|
- Fetch `POST /bases/pages/resolve` with `{ pageIds }`.
|
||||||
|
- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response.
|
||||||
|
- `staleTime: 30_000`, `gcTime: 5 * 60_000`.
|
||||||
|
- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing.
|
||||||
|
|
||||||
|
### Cell component
|
||||||
|
|
||||||
|
New file `apps/client/src/features/base/components/cells/cell-page.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CellPageProps = {
|
||||||
|
value: unknown;
|
||||||
|
property: IBaseProperty;
|
||||||
|
rowId: string;
|
||||||
|
isEditing: boolean;
|
||||||
|
onCommit: (value: unknown) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded).
|
||||||
|
- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request.
|
||||||
|
- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found".
|
||||||
|
- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`.
|
||||||
|
|
||||||
|
Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const cellComponents = {
|
||||||
|
// ...existing...
|
||||||
|
page: CellPage,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Property type picker
|
||||||
|
|
||||||
|
[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter editor
|
||||||
|
|
||||||
|
[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`:
|
||||||
|
- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`.
|
||||||
|
- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
|
||||||
|
|
||||||
|
### Sort editor
|
||||||
|
|
||||||
|
[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Server — unit
|
||||||
|
|
||||||
|
- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty).
|
||||||
|
- **Conversion:**
|
||||||
|
- `attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })` → resolved title.
|
||||||
|
- Same call with empty `pageTitles` → `""`.
|
||||||
|
- `page → number/date/select/…` → `{converted: true, value: null}`.
|
||||||
|
- `text → page` with any string input → `{converted: true, value: null}`.
|
||||||
|
- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape.
|
||||||
|
|
||||||
|
### Server — integration
|
||||||
|
|
||||||
|
- **Resolver endpoint `POST /bases/pages/resolve`:**
|
||||||
|
- valid IDs in an accessible space → present in `items`
|
||||||
|
- deleted pages (trash) → absent
|
||||||
|
- pages in a space the user isn't a member of → absent
|
||||||
|
- pages in another workspace → absent
|
||||||
|
- empty array → 400
|
||||||
|
- array length > 100 → 400
|
||||||
|
- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`.
|
||||||
|
- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned.
|
||||||
|
|
||||||
|
### Client — unit (Vitest + React Testing Library)
|
||||||
|
|
||||||
|
- `cell-page.test.tsx`:
|
||||||
|
- view mode with resolved page → renders pill with icon + title and an `<a>` to the computed URL
|
||||||
|
- view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `<a>`
|
||||||
|
- double-click opens picker
|
||||||
|
- Enter on highlighted result commits `pageId`
|
||||||
|
- Esc cancels
|
||||||
|
- Remove tag button commits `null`
|
||||||
|
- `base-page-resolver-query.test.ts`:
|
||||||
|
- dedupes IDs
|
||||||
|
- stable query key across re-renders with same set
|
||||||
|
- missing IDs render as `null` in the returned map
|
||||||
|
|
||||||
|
### Manual QA checklist
|
||||||
|
|
||||||
|
- Link a page in the same space.
|
||||||
|
- Link a page in another space → pill shows, picker shows muted space-name hint.
|
||||||
|
- Remove link → cell empties.
|
||||||
|
- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch.
|
||||||
|
- Viewer loses space access → same "Page not found" fallback.
|
||||||
|
- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch.
|
||||||
|
- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`.
|
||||||
|
- Conversion `page → text` populates cells with page titles.
|
||||||
|
- Conversion `text → page` wipes cells.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint.
|
||||||
|
- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier.
|
||||||
|
- Existing bases continue to work unchanged.
|
||||||
|
|
||||||
|
## Risks & open questions
|
||||||
|
|
||||||
|
- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus.
|
||||||
|
- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior.
|
||||||
|
- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review.
|
||||||
|
|
||||||
|
## Future extension (multiple pages per cell)
|
||||||
|
|
||||||
|
When `allowMultiple` lands:
|
||||||
|
|
||||||
|
1. Widen cell-value schema: `z.uuid()` → `z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate.
|
||||||
|
2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties).
|
||||||
|
3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path.
|
||||||
|
4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx).
|
||||||
|
5. No data writes required for existing cells.
|
||||||
|
|
||||||
|
This spec leaves room for that change without locking the storage shape.
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# Base View Draft (Local-First Filter & Sort) — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Draft
|
||||||
|
**Feature area:** `apps/client/src/features/base` (client-only)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it.
|
||||||
|
|
||||||
|
This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.)
|
||||||
|
- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design.
|
||||||
|
- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1.
|
||||||
|
- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land.
|
||||||
|
- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft.
|
||||||
|
- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning.
|
||||||
|
|
||||||
|
## UX overview
|
||||||
|
|
||||||
|
### Draft banner
|
||||||
|
|
||||||
|
Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `<BaseToolbar />` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view.
|
||||||
|
|
||||||
|
Render condition: `isDirty === true` (see "Dirty check").
|
||||||
|
|
||||||
|
Layout (match the reference screenshot):
|
||||||
|
|
||||||
|
- Mantine `<Paper withBorder radius="sm" px="md" py="xs">` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left.
|
||||||
|
- Left region: short message — `t("Filter and sort changes are visible only to you.")`.
|
||||||
|
- Right region (a `<Group gap="sm">`):
|
||||||
|
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
|
||||||
|
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used).
|
||||||
|
- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows.
|
||||||
|
- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `<Transition mounted={isDirty} transition="slide-down" duration={120}>` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard.
|
||||||
|
|
||||||
|
### Filter/sort editors in draft mode
|
||||||
|
|
||||||
|
No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components.
|
||||||
|
|
||||||
|
### Reset behavior
|
||||||
|
|
||||||
|
Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback.
|
||||||
|
|
||||||
|
### Save for everyone
|
||||||
|
|
||||||
|
Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast.
|
||||||
|
|
||||||
|
### Permission gating
|
||||||
|
|
||||||
|
A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden.
|
||||||
|
|
||||||
|
**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### localStorage key
|
||||||
|
|
||||||
|
```
|
||||||
|
docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers.
|
||||||
|
- `v1` is the schema version so a future breaking change can shed old entries by skipping.
|
||||||
|
- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code.
|
||||||
|
- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to.
|
||||||
|
|
||||||
|
### Value shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/client/src/features/base/types/base.types.ts (additive)
|
||||||
|
export type BaseViewDraft = {
|
||||||
|
filter?: FilterGroup;
|
||||||
|
sorts?: ViewSortConfig[];
|
||||||
|
updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts.
|
||||||
|
|
||||||
|
Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline.
|
||||||
|
|
||||||
|
## Client architecture
|
||||||
|
|
||||||
|
### Storage atom family
|
||||||
|
|
||||||
|
**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts`
|
||||||
|
|
||||||
|
Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { atomFamily, atomWithStorage } from "jotai/utils";
|
||||||
|
import { BaseViewDraft } from "@/features/base/types/base.types";
|
||||||
|
|
||||||
|
export type ViewDraftKey = {
|
||||||
|
userId: string;
|
||||||
|
baseId: string;
|
||||||
|
viewId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyFor = (k: ViewDraftKey) =>
|
||||||
|
`docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`;
|
||||||
|
|
||||||
|
export const viewDraftAtomFamily = atomFamily(
|
||||||
|
(k: ViewDraftKey) =>
|
||||||
|
atomWithStorage<BaseViewDraft | null>(keyFor(k), null),
|
||||||
|
(a, b) =>
|
||||||
|
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render.
|
||||||
|
|
||||||
|
### Hook: `useViewDraft`
|
||||||
|
|
||||||
|
**File:** `apps/client/src/features/base/hooks/use-view-draft.ts`
|
||||||
|
|
||||||
|
Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ViewDraftState = {
|
||||||
|
draft: BaseViewDraft | null;
|
||||||
|
effectiveFilter: FilterGroup | undefined;
|
||||||
|
effectiveSorts: ViewSortConfig[] | undefined;
|
||||||
|
isDirty: boolean;
|
||||||
|
setFilter: (filter: FilterGroup | undefined) => void;
|
||||||
|
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
|
||||||
|
reset: () => void;
|
||||||
|
buildPromotedConfig: (baseline: ViewConfig) => ViewConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useViewDraft(args: {
|
||||||
|
userId: string | undefined;
|
||||||
|
baseId: string | undefined;
|
||||||
|
viewId: string | undefined;
|
||||||
|
baselineFilter: FilterGroup | undefined;
|
||||||
|
baselineSorts: ViewSortConfig[] | undefined;
|
||||||
|
}): ViewDraftState;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet.
|
||||||
|
2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set.
|
||||||
|
3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering.
|
||||||
|
4. `reset()` is `setDraft(RESET)`.
|
||||||
|
5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty.
|
||||||
|
6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged.
|
||||||
|
|
||||||
|
**Return composition:**
|
||||||
|
|
||||||
|
- `effectiveFilter = draft?.filter ?? baselineFilter`
|
||||||
|
- `effectiveSorts = draft?.sorts ?? baselineSorts`
|
||||||
|
|
||||||
|
**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required.
|
||||||
|
|
||||||
|
### Integration into `useBaseTable` and `base-table.tsx`
|
||||||
|
|
||||||
|
`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`.
|
||||||
|
|
||||||
|
Two cut options were considered:
|
||||||
|
|
||||||
|
**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter.
|
||||||
|
|
||||||
|
**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities.
|
||||||
|
|
||||||
|
Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// NEW: wire the draft hook
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } =
|
||||||
|
useViewDraft({
|
||||||
|
userId: user?.user.id,
|
||||||
|
baseId,
|
||||||
|
viewId: activeView?.id,
|
||||||
|
baselineFilter: activeView?.config?.filter,
|
||||||
|
baselineSorts: activeView?.config?.sorts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swap the raw `activeView` for a view with effective config so the table and row query see drafts.
|
||||||
|
const effectiveView = useMemo(
|
||||||
|
() =>
|
||||||
|
activeView
|
||||||
|
? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } }
|
||||||
|
: undefined,
|
||||||
|
[activeView, effectiveFilter, effectiveSorts],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Row query reads effective filter/sorts.
|
||||||
|
const { data: rowsData, ... } = useBaseRowsQuery(
|
||||||
|
base ? baseId : undefined,
|
||||||
|
effectiveFilter,
|
||||||
|
effectiveSorts,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table is seeded from effectiveView for rendering, but the auto-persist
|
||||||
|
// write-path uses the real `activeView.config` as the baseline so draft
|
||||||
|
// filter/sort values can never leak into a column-layout save.
|
||||||
|
// See "Filter & sort write-path changes" below for the exact mechanism.
|
||||||
|
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
|
||||||
|
baselineConfig: activeView?.config,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation.
|
||||||
|
|
||||||
|
### Filter & sort write-path changes
|
||||||
|
|
||||||
|
Today, filter/sort editors feed `BaseToolbar`'s handlers:
|
||||||
|
|
||||||
|
- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })` → `updateViewMutation.mutate(...)`.
|
||||||
|
- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`.
|
||||||
|
|
||||||
|
Both write directly to the server. That's the exact site to branch.
|
||||||
|
|
||||||
|
**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onDraftSortsChange: (sorts: ViewSortConfig[]) => void;
|
||||||
|
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleSortsChange = useCallback(
|
||||||
|
(newSorts: ViewSortConfig[]) => {
|
||||||
|
onDraftSortsChange(newSorts); // writes to useViewDraft via base-table
|
||||||
|
},
|
||||||
|
[onDraftSortsChange],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes.
|
||||||
|
|
||||||
|
**`base-table.tsx`** wires those callbacks to the draft hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleDraftSortsChange = useCallback(
|
||||||
|
(sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined),
|
||||||
|
[setSorts],
|
||||||
|
);
|
||||||
|
const handleDraftFiltersChange = useCallback(
|
||||||
|
(filter: FilterGroup | undefined) => setFilter(filter),
|
||||||
|
[setFilter],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in.
|
||||||
|
|
||||||
|
**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form.
|
||||||
|
|
||||||
|
**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted.
|
||||||
|
|
||||||
|
However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.**
|
||||||
|
|
||||||
|
Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const config = buildViewConfigFromTable(table, activeView.config, {
|
||||||
|
sorts: activeView.config?.sorts,
|
||||||
|
filter: activeView.config?.filter,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path.
|
||||||
|
|
||||||
|
Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active.
|
||||||
|
|
||||||
|
This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit.
|
||||||
|
|
||||||
|
**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue.
|
||||||
|
|
||||||
|
## Banner component
|
||||||
|
|
||||||
|
**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BaseViewDraftBannerProps = {
|
||||||
|
isDirty: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
onReset: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving }: BaseViewDraftBannerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!isDirty) return null;
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<IconInfoCircle size={16} />
|
||||||
|
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
|
||||||
|
{canSave && (
|
||||||
|
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
|
||||||
|
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||||
|
const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base);
|
||||||
|
const updateViewMutation = useUpdateViewMutation();
|
||||||
|
const handleSaveDraft = useCallback(async () => {
|
||||||
|
if (!activeView || !base) return;
|
||||||
|
const config = buildPromotedConfig(activeView.config);
|
||||||
|
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||||
|
reset();
|
||||||
|
notifications.show({ message: t("View updated for everyone") });
|
||||||
|
}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{...}}>
|
||||||
|
<BaseViewDraftBanner
|
||||||
|
isDirty={isDirty}
|
||||||
|
canSave={canSave}
|
||||||
|
onReset={reset}
|
||||||
|
onSave={handleSaveDraft}
|
||||||
|
saving={updateViewMutation.isPending}
|
||||||
|
/>
|
||||||
|
<BaseToolbar ... />
|
||||||
|
<GridContainer ... />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx).
|
||||||
|
|
||||||
|
## Cross-tab sync
|
||||||
|
|
||||||
|
Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`.
|
||||||
|
|
||||||
|
React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path.
|
||||||
|
|
||||||
|
Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable).
|
||||||
|
|
||||||
|
## Save flow (pseudocode)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function onSaveForEveryone() {
|
||||||
|
if (!activeView || !base) return;
|
||||||
|
// 1. Compose the promoted config from the server baseline + draft values.
|
||||||
|
// baseline is activeView.config (NOT effectiveView.config) because the
|
||||||
|
// baseline might include layout fields (propertyWidths, propertyOrder,
|
||||||
|
// hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim.
|
||||||
|
const config: ViewConfig = {
|
||||||
|
...activeView.config,
|
||||||
|
filter: draft.filter ?? activeView.config.filter,
|
||||||
|
sorts: draft.sorts ?? activeView.config.sorts,
|
||||||
|
};
|
||||||
|
// 2. Fire the existing mutation. `updateViewMutation` already:
|
||||||
|
// - optimistically updates the ["bases", baseId] query cache
|
||||||
|
// - rolls back on error
|
||||||
|
// - writes the server response back on success
|
||||||
|
await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config });
|
||||||
|
// 3. Clear the draft. Because the baseline has now caught up to what the
|
||||||
|
// draft said, isDirty flips to false and the banner unmounts.
|
||||||
|
reset();
|
||||||
|
notifications.show({ message: t("View updated for everyone") });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry.
|
||||||
|
|
||||||
|
## Dirty check
|
||||||
|
|
||||||
|
`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true:
|
||||||
|
|
||||||
|
- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)`
|
||||||
|
- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)`
|
||||||
|
|
||||||
|
**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options:
|
||||||
|
|
||||||
|
1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape.
|
||||||
|
2. Hand-written structural compare — overkill for two types with known finite shapes.
|
||||||
|
|
||||||
|
Go with option 1. Helpers live in `use-view-draft.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
|
||||||
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||||
|
}
|
||||||
|
function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) {
|
||||||
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature.
|
||||||
|
|
||||||
|
### Unit tests (proposed, Vitest — gated on harness being added)
|
||||||
|
|
||||||
|
`use-view-draft.test.ts`:
|
||||||
|
|
||||||
|
- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline.
|
||||||
|
- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`.
|
||||||
|
- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa.
|
||||||
|
- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null.
|
||||||
|
- **`reset` clears both state and storage.**
|
||||||
|
- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC).
|
||||||
|
- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X` — `isDirty` flips to `false` without draft being cleared.
|
||||||
|
- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices.
|
||||||
|
- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline.
|
||||||
|
- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline.
|
||||||
|
|
||||||
|
### Manual QA checklist
|
||||||
|
|
||||||
|
**Single user, single tab.**
|
||||||
|
- Apply a filter. Banner appears. Row list updates locally.
|
||||||
|
- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts.
|
||||||
|
- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server).
|
||||||
|
- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched).
|
||||||
|
|
||||||
|
**Single user, multiple tabs.**
|
||||||
|
- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner.
|
||||||
|
- In tab B, click Reset. Tab A's banner disappears and sort reverts.
|
||||||
|
|
||||||
|
**Multi-user baseline race.**
|
||||||
|
- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline.
|
||||||
|
|
||||||
|
**Permission gating.**
|
||||||
|
- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button.
|
||||||
|
- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders.
|
||||||
|
|
||||||
|
**Reset with popover open.**
|
||||||
|
- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open.
|
||||||
|
|
||||||
|
**Save clears draft + updates server.**
|
||||||
|
- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server).
|
||||||
|
|
||||||
|
**Browser storage cleared.**
|
||||||
|
- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- **No DB migration.** No server change.
|
||||||
|
- **No feature flag.** Behavior change ships as-is.
|
||||||
|
- **No data migration.** Existing users have no drafts; the system starts empty.
|
||||||
|
- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one.
|
||||||
|
- Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship.
|
||||||
|
- **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1.
|
||||||
|
|
||||||
|
## Risks & open questions
|
||||||
|
|
||||||
|
- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 5–10MB per-origin cap, but practically negligible. No cleanup logic needed.
|
||||||
|
- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals.
|
||||||
|
- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged.
|
||||||
|
- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include:
|
||||||
|
1. "Save for everyone" (current behavior)
|
||||||
|
2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`)
|
||||||
|
- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have.
|
||||||
|
- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition.
|
||||||
|
- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice.
|
||||||
|
|
||||||
|
## Future extension
|
||||||
|
|
||||||
|
1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow.
|
||||||
|
2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical.
|
||||||
|
3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel.
|
||||||
|
4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it.
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
**/node_modules
|
node_modules
|
||||||
**/.git
|
.git
|
||||||
**/dist
|
dist
|
||||||
/data
|
/data
|
||||||
**/.env*
|
.env*
|
||||||
**/.nx
|
.nx
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
.env.prod
|
.env.prod
|
||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
+16
-17
@@ -21,20 +21,20 @@
|
|||||||
"@docmost/base-formula": "workspace:*",
|
"@docmost/base-formula": "workspace:*",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "9.3.2",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "9.3.2",
|
"@mantine/dates": "8.3.18",
|
||||||
"@mantine/form": "9.3.2",
|
"@mantine/form": "8.3.18",
|
||||||
"@mantine/hooks": "9.3.2",
|
"@mantine/hooks": "8.3.18",
|
||||||
"@mantine/modals": "9.3.2",
|
"@mantine/modals": "8.3.18",
|
||||||
"@mantine/notifications": "9.3.2",
|
"@mantine/notifications": "8.3.18",
|
||||||
"@mantine/spotlight": "9.3.2",
|
"@mantine/spotlight": "8.3.18",
|
||||||
"@slidoapp/emoji-mart": "5.8.7",
|
"@slidoapp/emoji-mart": "5.8.7",
|
||||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
"@tabler/icons-react": "3.40.0",
|
"@tabler/icons-react": "3.40.0",
|
||||||
"@tanstack/react-query": "5.90.17",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "3.14.3",
|
"@tanstack/react-virtual": "3.14.2",
|
||||||
"alfaaz": "1.1.0",
|
"alfaaz": "1.1.0",
|
||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
"i18next": "25.10.1",
|
"i18next": "25.10.1",
|
||||||
"i18next-http-backend": "3.0.6",
|
"i18next-http-backend": "3.0.6",
|
||||||
"jotai": "2.20.1",
|
"jotai": "2.18.1",
|
||||||
"jotai-optics": "0.4.0",
|
"jotai-optics": "0.4.0",
|
||||||
"js-cookie": "3.0.7",
|
"js-cookie": "3.0.7",
|
||||||
"jwt-decode": "4.0.0",
|
"jwt-decode": "4.0.0",
|
||||||
@@ -52,16 +52,15 @@
|
|||||||
"mantine-form-zod-resolver": "1.3.0",
|
"mantine-form-zod-resolver": "1.3.0",
|
||||||
"mermaid": "11.15.0",
|
"mermaid": "11.15.0",
|
||||||
"mitt": "3.0.1",
|
"mitt": "3.0.1",
|
||||||
"nanoid": "3.3.8",
|
"posthog-js": "1.372.2",
|
||||||
"posthog-js": "1.391.2",
|
"react": "18.3.1",
|
||||||
"react": "19.2.7",
|
|
||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "1.0.7",
|
"react-drawio": "1.0.7",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.1.1",
|
||||||
"react-helmet-async": "3.0.0",
|
"react-helmet-async": "3.0.0",
|
||||||
"react-i18next": "16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
"react-router-dom": "7.18.0",
|
"react-router-dom": "7.13.1",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"socket.io-client": "4.8.3",
|
"socket.io-client": "4.8.3",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
@@ -76,8 +75,8 @@
|
|||||||
"@types/js-cookie": "3.0.6",
|
"@types/js-cookie": "3.0.6",
|
||||||
"@types/katex": "0.16.8",
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "22.19.1",
|
"@types/node": "22.19.1",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.16",
|
"vite": "8.0.5",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
|
"Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt",
|
||||||
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
|
"Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt",
|
||||||
"Page menu for {{name}}": "Seitenmenü für {{name}}",
|
"Page menu for {{name}}": "Seitenmenü für {{name}}",
|
||||||
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen"
|
"Create subpage of {{name}}": "Unterseite von {{name}} erstellen",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,6 @@
|
|||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
"Date": "Date",
|
"Date": "Date",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
"Remove from page": "Remove from page",
|
|
||||||
"Base options": "Base options",
|
|
||||||
"Delete group": "Delete group",
|
"Delete group": "Delete group",
|
||||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
|
||||||
"Description": "Description",
|
"Description": "Description",
|
||||||
@@ -78,24 +76,6 @@
|
|||||||
"Failed to import pages": "Failed to import pages",
|
"Failed to import pages": "Failed to import pages",
|
||||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||||
"Failed to update data": "Failed to update data",
|
"Failed to update data": "Failed to update data",
|
||||||
"Failed to create base": "Failed to create base",
|
|
||||||
"Failed to update base": "Failed to update base",
|
|
||||||
"Failed to delete base": "Failed to delete base",
|
|
||||||
"Failed to create property": "Failed to create property",
|
|
||||||
"Failed to update property": "Failed to update property",
|
|
||||||
"Failed to delete property": "Failed to delete property",
|
|
||||||
"Failed to reorder property": "Failed to reorder property",
|
|
||||||
"Failed to create view": "Failed to create view",
|
|
||||||
"Failed to update view": "Failed to update view",
|
|
||||||
"Failed to delete view": "Failed to delete view",
|
|
||||||
"Failed to create row": "Failed to create row",
|
|
||||||
"Failed to update row": "Failed to update row",
|
|
||||||
"Failed to delete row": "Failed to delete row",
|
|
||||||
"Failed to delete rows": "Failed to delete rows",
|
|
||||||
"Failed to reorder row": "Failed to reorder row",
|
|
||||||
"Failed to move card": "Failed to move card",
|
|
||||||
"Failed to add card": "Failed to add card",
|
|
||||||
"Failed to export CSV": "Failed to export CSV",
|
|
||||||
"Favorite spaces": "Favorite spaces",
|
"Favorite spaces": "Favorite spaces",
|
||||||
"Favorite spaces appear here": "Favorite spaces appear here",
|
"Favorite spaces appear here": "Favorite spaces appear here",
|
||||||
"Favorites": "Favorites",
|
"Favorites": "Favorites",
|
||||||
@@ -418,8 +398,6 @@
|
|||||||
"Insert mermaid diagram": "Insert mermaid diagram",
|
"Insert mermaid diagram": "Insert mermaid diagram",
|
||||||
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
||||||
"Insert current date": "Insert current date",
|
"Insert current date": "Insert current date",
|
||||||
"Time": "Time",
|
|
||||||
"Insert current time": "Insert current time",
|
|
||||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
"Turn into": "Turn into",
|
"Turn into": "Turn into",
|
||||||
@@ -619,8 +597,6 @@
|
|||||||
"Deleted by": "Deleted by",
|
"Deleted by": "Deleted by",
|
||||||
"Deleted at": "Deleted at",
|
"Deleted at": "Deleted at",
|
||||||
"Preview": "Preview",
|
"Preview": "Preview",
|
||||||
"Base preview unavailable": "Base preview unavailable",
|
|
||||||
"Restore this base to view its contents.": "Restore this base to view its contents.",
|
|
||||||
"Subpages": "Subpages",
|
"Subpages": "Subpages",
|
||||||
"Failed to load subpages": "Failed to load subpages",
|
"Failed to load subpages": "Failed to load subpages",
|
||||||
"No subpages": "No subpages",
|
"No subpages": "No subpages",
|
||||||
@@ -1002,7 +978,7 @@
|
|||||||
"Search pages and spaces...": "Search pages and spaces...",
|
"Search pages and spaces...": "Search pages and spaces...",
|
||||||
"No results found": "No results found",
|
"No results found": "No results found",
|
||||||
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
"You don't have permission to create pages here": "You don't have permission to create pages here",
|
||||||
"Chat menu for {{title}}": "Chat menu for {{title}}",
|
"Chat menu": "Chat menu",
|
||||||
"API key menu": "API key menu",
|
"API key menu": "API key menu",
|
||||||
"Jump to comment selection": "Jump to comment selection",
|
"Jump to comment selection": "Jump to comment selection",
|
||||||
"Slash commands": "Slash commands",
|
"Slash commands": "Slash commands",
|
||||||
@@ -1088,7 +1064,7 @@
|
|||||||
"Filter": "Filter",
|
"Filter": "Filter",
|
||||||
"Page title": "Page title",
|
"Page title": "Page title",
|
||||||
"Page content": "Page content",
|
"Page content": "Page content",
|
||||||
"Member actions for {{name}}": "Member actions for {{name}}",
|
"Member actions": "Member actions",
|
||||||
"Toggle password visibility": "Toggle password visibility",
|
"Toggle password visibility": "Toggle password visibility",
|
||||||
"Send comment": "Send comment",
|
"Send comment": "Send comment",
|
||||||
"Token actions": "Token actions",
|
"Token actions": "Token actions",
|
||||||
@@ -1110,12 +1086,6 @@
|
|||||||
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
||||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||||
"Allow personal spaces": "Allow personal spaces",
|
|
||||||
"Members can create their own personal space.": "Members can create their own personal space.",
|
|
||||||
"Toggle allow personal spaces": "Toggle allow personal spaces",
|
|
||||||
"Create personal space": "Create personal space",
|
|
||||||
"Personal space": "Personal space",
|
|
||||||
"{{name}}'s space": "{{name}}'s space",
|
|
||||||
"Apply": "Apply",
|
"Apply": "Apply",
|
||||||
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
|
"Added {{name}} to favorites": "Se agregó {{name}} a favoritos",
|
||||||
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
|
"Removed {{name}} from favorites": "Se quitó {{name}} de favoritos",
|
||||||
"Page menu for {{name}}": "Menú de página para {{name}}",
|
"Page menu for {{name}}": "Menú de página para {{name}}",
|
||||||
"Create subpage of {{name}}": "Crear subpágina de {{name}}"
|
"Create subpage of {{name}}": "Crear subpágina de {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
|
"Added {{name}} to favorites": "{{name}} a été ajouté aux favoris",
|
||||||
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
|
"Removed {{name}} from favorites": "{{name}} a été retiré des favoris",
|
||||||
"Page menu for {{name}}": "Menu de la page pour {{name}}",
|
"Page menu for {{name}}": "Menu de la page pour {{name}}",
|
||||||
"Create subpage of {{name}}": "Créer une sous-page de {{name}}"
|
"Create subpage of {{name}}": "Créer une sous-page de {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
|
"Added {{name}} to favorites": "{{name}} aggiunto ai preferiti",
|
||||||
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
|
"Removed {{name}} from favorites": "{{name}} rimosso dai preferiti",
|
||||||
"Page menu for {{name}}": "Menu della pagina per {{name}}",
|
"Page menu for {{name}}": "Menu della pagina per {{name}}",
|
||||||
"Create subpage of {{name}}": "Crea sottopagina di {{name}}"
|
"Create subpage of {{name}}": "Crea sottopagina di {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
|
"Added {{name}} to favorites": "{{name}} をお気に入りに追加しました",
|
||||||
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
|
"Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました",
|
||||||
"Page menu for {{name}}": "{{name}} のページメニュー",
|
"Page menu for {{name}}": "{{name}} のページメニュー",
|
||||||
"Create subpage of {{name}}": "{{name}} のサブページを作成"
|
"Create subpage of {{name}}": "{{name}} のサブページを作成",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
|
"Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨",
|
||||||
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
|
"Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨",
|
||||||
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
|
"Page menu for {{name}}": "{{name}}의 페이지 메뉴",
|
||||||
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기"
|
"Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
|
"Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten",
|
||||||
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
|
"Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten",
|
||||||
"Page menu for {{name}}": "Paginamenu voor {{name}}",
|
"Page menu for {{name}}": "Paginamenu voor {{name}}",
|
||||||
"Create subpage of {{name}}": "Subpagina van {{name}} maken"
|
"Create subpage of {{name}}": "Subpagina van {{name}} maken",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
|
"Added {{name}} to favorites": "{{name}} adicionado aos favoritos",
|
||||||
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
|
"Removed {{name}} from favorites": "{{name}} removido dos favoritos",
|
||||||
"Page menu for {{name}}": "Menu da página de {{name}}",
|
"Page menu for {{name}}": "Menu da página de {{name}}",
|
||||||
"Create subpage of {{name}}": "Criar subpágina de {{name}}"
|
"Create subpage of {{name}}": "Criar subpágina de {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}"
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "{{name}} додано до обраного",
|
"Added {{name}} to favorites": "{{name}} додано до обраного",
|
||||||
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
|
"Removed {{name}} from favorites": "{{name}} видалено з обраного",
|
||||||
"Page menu for {{name}}": "Меню сторінки для {{name}}",
|
"Page menu for {{name}}": "Меню сторінки для {{name}}",
|
||||||
"Create subpage of {{name}}": "Створити підсторінку для {{name}}"
|
"Create subpage of {{name}}": "Створити підсторінку для {{name}}",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,5 +1084,23 @@
|
|||||||
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
|
"Added {{name}} to favorites": "已将 {{name}} 添加到收藏",
|
||||||
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
|
"Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除",
|
||||||
"Page menu for {{name}}": "{{name}} 的页面菜单",
|
"Page menu for {{name}}": "{{name}} 的页面菜单",
|
||||||
"Create subpage of {{name}}": "创建 {{name}} 的子页面"
|
"Create subpage of {{name}}": "创建 {{name}} 的子页面",
|
||||||
|
"Apply": "Apply",
|
||||||
|
"Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.",
|
||||||
|
"Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.",
|
||||||
|
"Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.",
|
||||||
|
"Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.",
|
||||||
|
"Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.",
|
||||||
|
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||||
|
"Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.",
|
||||||
|
"Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.",
|
||||||
|
"Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.",
|
||||||
|
"Cells will be replaced with the option name.": "Cells will be replaced with the option name.",
|
||||||
|
"Cells will be replaced with the page title.": "Cells will be replaced with the page title.",
|
||||||
|
"Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.",
|
||||||
|
"Change type": "Change type",
|
||||||
|
"Change type to {{label}}?": "Change type to {{label}}?",
|
||||||
|
"Converting…": "Converting…",
|
||||||
|
"Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.",
|
||||||
|
"Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,13 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
Divider,
|
Divider,
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||||
exportPage,
|
|
||||||
exportPageToDocx,
|
|
||||||
} from "@/features/page/services/page-service.ts";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { exportSpace } from "@/features/space/services/space-service";
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,25 +32,17 @@ export default function ExportModal({
|
|||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
const isDocx = format === ExportFormat.Docx;
|
|
||||||
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
|
||||||
const blockedByLicense = isDocx && !docxEntitled;
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
if (format === ExportFormat.Docx) {
|
await exportPage({
|
||||||
await exportPageToDocx({ pageId: id });
|
pageId: id,
|
||||||
} else {
|
format,
|
||||||
await exportPage({
|
includeChildren,
|
||||||
pageId: id,
|
includeAttachments,
|
||||||
format,
|
});
|
||||||
includeChildren,
|
|
||||||
includeAttachments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
@@ -104,15 +88,10 @@ export default function ExportModal({
|
|||||||
<div>
|
<div>
|
||||||
<Text size="md">{t("Format")}</Text>
|
<Text size="md">{t("Format")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<ExportFormatSelection
|
<ExportFormatSelection format={format} onChange={handleChange} />
|
||||||
format={format}
|
|
||||||
onChange={handleChange}
|
|
||||||
includeDocx={type === "page"}
|
|
||||||
docxEntitled={docxEntitled}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{type === "page" && !isDocx && (
|
{type === "page" && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
|
|
||||||
@@ -164,16 +143,7 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
||||||
<Button
|
|
||||||
onClick={handleExport}
|
|
||||||
loading={isExporting}
|
|
||||||
disabled={blockedByLicense}
|
|
||||||
data-disabled={blockedByLicense || undefined}
|
|
||||||
>
|
|
||||||
{t("Export")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
@@ -184,49 +154,23 @@ export default function ExportModal({
|
|||||||
interface ExportFormatSelection {
|
interface ExportFormatSelection {
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
includeDocx?: boolean;
|
|
||||||
docxEntitled?: boolean;
|
|
||||||
}
|
}
|
||||||
function ExportFormatSelection({
|
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
||||||
format,
|
|
||||||
onChange,
|
|
||||||
includeDocx,
|
|
||||||
docxEntitled,
|
|
||||||
}: ExportFormatSelection) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const data = [
|
|
||||||
{ value: "markdown", label: "Markdown" },
|
|
||||||
{ value: "html", label: "HTML" },
|
|
||||||
...(includeDocx
|
|
||||||
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
data={data}
|
data={[
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
]}
|
||||||
defaultValue={format}
|
defaultValue={format}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
styles={{ wrapper: { maxWidth: 120 } }}
|
||||||
comboboxProps={{ width: 200 }}
|
comboboxProps={{ width: "120" }}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
withCheckIcon={false}
|
withCheckIcon={false}
|
||||||
aria-label={t("Select export format")}
|
aria-label={t("Select export format")}
|
||||||
renderOption={({ option }) =>
|
|
||||||
option.value === "docx" && !docxEntitled ? (
|
|
||||||
<div>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
<Badge size="xs" mt={4}>
|
|
||||||
{t("Enterprise")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Text size="sm">{option.label}</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function GlobalSidebar() {
|
|||||||
|
|
||||||
<Divider my="xs" />
|
<Divider my="xs" />
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
<Text component="h2" className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
|
||||||
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
<Text size="xs" c="dimmed" pl="xs" py={4}>
|
||||||
{t("Favorite spaces appear here")}
|
{t("Favorite spaces appear here")}
|
||||||
|
|||||||
@@ -15,16 +15,9 @@ import {
|
|||||||
IconMoon,
|
IconMoon,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconUser,
|
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
|
||||||
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -43,20 +36,11 @@ export default function TopMenu() {
|
|||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
|
|
||||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
|
||||||
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
|
||||||
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
|
||||||
const [
|
|
||||||
createOpened,
|
|
||||||
{ open: openCreate, close: closeCreate },
|
|
||||||
] = useDisclosure(false);
|
|
||||||
|
|
||||||
if (!user || !workspace) {
|
if (!user || !workspace) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
@@ -131,26 +115,6 @@ export default function TopMenu() {
|
|||||||
{t("My preferences")}
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{personalSpace ? (
|
|
||||||
<Menu.Item
|
|
||||||
component={Link}
|
|
||||||
to={getSpaceUrl(personalSpace.slug)}
|
|
||||||
leftSection={<IconUser size={16} />}
|
|
||||||
>
|
|
||||||
{t("Personal space")}
|
|
||||||
</Menu.Item>
|
|
||||||
) : (
|
|
||||||
hasPersonalSpaces &&
|
|
||||||
settingEnabled && (
|
|
||||||
<Menu.Item
|
|
||||||
onClick={openCreate}
|
|
||||||
leftSection={<IconUser size={16} />}
|
|
||||||
>
|
|
||||||
{t("Create personal space")}
|
|
||||||
</Menu.Item>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Sub>
|
<Menu.Sub>
|
||||||
<Menu.Sub.Target>
|
<Menu.Sub.Target>
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||||
@@ -196,8 +160,5 @@ export default function TopMenu() {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ interface CustomAvatarProps {
|
|||||||
mt?: string | number;
|
mt?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// color.shade picks whose FILLED variant (white text on the shade) meets WCAG AA 4.5:1.
|
// `color.shade` pairs whose contrast meets WCAG AA (4.5:1) in BOTH variants:
|
||||||
// Avoids lime/yellow/green/orange, too light even at dark shades.
|
// - filled: white text on the shade as bg
|
||||||
// For non-filled variants, initials text is forced to the .9 shade at render time:
|
// - light: shade as text on the color's light-bg (10% color.6 over white)
|
||||||
// Mantine otherwise caps light-variant placeholder text at .6, dropping contrast to ~3:1.
|
// Avoids lime/yellow/green/orange — even their dark shades have weak
|
||||||
|
// contrast. grape and indigo were bumped from .7 to darker shades because
|
||||||
|
// the original picks failed: grape.7 was 4.02/3.61 (both fail) and
|
||||||
|
// indigo.7 was 4.98/4.39 (light fails by a hair).
|
||||||
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
const SAFE_INITIALS_COLORS: MantineColor[] = [
|
||||||
"blue.8",
|
"blue.8",
|
||||||
"cyan.9",
|
"cyan.9",
|
||||||
@@ -51,24 +54,12 @@ function sanitizeInitialsSource(name: string) {
|
|||||||
export const CustomAvatar = React.forwardRef<
|
export const CustomAvatar = React.forwardRef<
|
||||||
HTMLInputElement,
|
HTMLInputElement,
|
||||||
CustomAvatarProps
|
CustomAvatarProps
|
||||||
>(({ avatarUrl, name, type, color, variant, ...props }: CustomAvatarProps, ref) => {
|
>(({ avatarUrl, name, type, color, ...props }: CustomAvatarProps, ref) => {
|
||||||
const avatarLink = getAvatarUrl(avatarUrl, type);
|
const avatarLink = getAvatarUrl(avatarUrl, type);
|
||||||
const isInitials = !color || color === "initials";
|
const resolvedColor =
|
||||||
const pickedColor = isInitials ? pickInitialsColor(name ?? "") : color;
|
!color || color === "initials" ? pickInitialsColor(name ?? "") : color;
|
||||||
const hue = pickedColor.split(".")[0];
|
|
||||||
const initialsSource = sanitizeInitialsSource(name ?? "");
|
const initialsSource = sanitizeInitialsSource(name ?? "");
|
||||||
|
|
||||||
const resolvedColor = variant === "filled" ? pickedColor : hue;
|
|
||||||
|
|
||||||
const placeholderStyles =
|
|
||||||
isInitials && variant !== "filled"
|
|
||||||
? {
|
|
||||||
placeholder: {
|
|
||||||
color: `var(--mantine-color-${hue}-9)`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -76,8 +67,6 @@ export const CustomAvatar = React.forwardRef<
|
|||||||
name={initialsSource}
|
name={initialsSource}
|
||||||
alt={name}
|
alt={name}
|
||||||
color={resolvedColor}
|
color={resolvedColor}
|
||||||
variant={variant}
|
|
||||||
styles={placeholderStyles}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { UnstyledButton } from "@mantine/core";
|
|
||||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
|
||||||
|
|
||||||
// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemradio" so aria-checked works.
|
|
||||||
export const RadioMenuItem = forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
ComponentPropsWithoutRef<"button">
|
|
||||||
>((props, ref) => (
|
|
||||||
<UnstyledButton ref={ref} {...props} role="menuitemradio" />
|
|
||||||
));
|
|
||||||
|
|
||||||
RadioMenuItem.displayName = "RadioMenuItem";
|
|
||||||
@@ -1,39 +1 @@
|
|||||||
Files in this directory are subject to the Docmost Enterprise Edition license.
|
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||||
|
|
||||||
The Docmost Enterprise License (the “Enterprise License”)
|
|
||||||
Copyright (c) 2023-present Docmost, Inc
|
|
||||||
|
|
||||||
|
|
||||||
With regard to the Docmost Software:
|
|
||||||
|
|
||||||
This software and associated documentation files (the "Software") may only be
|
|
||||||
used in production, if you (and any entity that you represent) have agreed to,
|
|
||||||
and are in compliance with, the Docmost Subscription Terms of Service, available
|
|
||||||
at https://docmost.com/terms (the “Enterprise Terms”), or other
|
|
||||||
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
|
|
||||||
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
|
|
||||||
Subject to the foregoing sentence, you are free to
|
|
||||||
modify this Software and publish patches to the Software. You agree that Docmost
|
|
||||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
|
||||||
to all such modifications and/or patches, and all such modifications and/or
|
|
||||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
|
||||||
exploited with a valid Docmost Enterprise Edition subscription for the correct
|
|
||||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
|
||||||
the Software for development and testing purposes, without requiring a
|
|
||||||
subscription. You agree that Docmost and/or its licensors (as applicable) retain
|
|
||||||
all right, title and interest in and to all such modifications. You are not
|
|
||||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
|
||||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
For all third party components incorporated into the Docmost Software, those
|
|
||||||
components are licensed under the original license provided by the owner of the
|
|
||||||
applicable component.
|
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ export default function AiChatSidebarItem({
|
|||||||
[chat.updatedAt, i18n.language],
|
[chat.updatedAt, i18n.language],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chatTitle = chat.title || t("Untitled chat");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renaming) {
|
if (renaming) {
|
||||||
// Wait for the input to be mounted before selecting.
|
// Wait for the input to be mounted before selecting.
|
||||||
@@ -122,7 +120,9 @@ export default function AiChatSidebarItem({
|
|||||||
className={classes.chatItem}
|
className={classes.chatItem}
|
||||||
data-active={isActive || undefined}
|
data-active={isActive || undefined}
|
||||||
>
|
>
|
||||||
<span className={classes.chatItemTitle}>{chatTitle}</span>
|
<span className={classes.chatItemTitle}>
|
||||||
|
{chat.title || t("Untitled chat")}
|
||||||
|
</span>
|
||||||
<span className={classes.chatItemDate}>{formattedDate}</span>
|
<span className={classes.chatItemDate}>{formattedDate}</span>
|
||||||
<div className={classes.chatItemActions}>
|
<div className={classes.chatItemActions}>
|
||||||
<Menu position="bottom-end" withinPortal>
|
<Menu position="bottom-end" withinPortal>
|
||||||
@@ -132,7 +132,7 @@ export default function AiChatSidebarItem({
|
|||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
aria-label={t("Chat menu for {{title}}", { title: chatTitle })}
|
aria-label={t("Chat menu")}
|
||||||
>
|
>
|
||||||
<IconDots size={14} />
|
<IconDots size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useId, useRef, useEffect, useState } from "react";
|
import { useCallback, useRef, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
|
||||||
import { Popover } from "@mantine/core";
|
import { Popover } from "@mantine/core";
|
||||||
@@ -107,7 +107,6 @@ export default function ChatInput({
|
|||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
|
||||||
const plusMenuId = useId();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const onSendRef = useRef(onSend);
|
const onSendRef = useRef(onSend);
|
||||||
onSendRef.current = onSend;
|
onSendRef.current = onSend;
|
||||||
@@ -177,7 +176,7 @@ export default function ChatInput({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!editor || editor.isDestroyed || isStreaming) return;
|
if (!editor || isStreaming) return;
|
||||||
const json = editor.getJSON();
|
const json = editor.getJSON();
|
||||||
const text = editorJsonToText(json).trim();
|
const text = editorJsonToText(json).trim();
|
||||||
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
|
||||||
@@ -264,7 +263,7 @@ export default function ChatInput({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor && !editor.isDestroyed && autofocus) {
|
if (editor && autofocus) {
|
||||||
editor.commands.focus();
|
editor.commands.focus();
|
||||||
}
|
}
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
@@ -343,7 +342,6 @@ export default function ChatInput({
|
|||||||
position="top-start"
|
position="top-start"
|
||||||
width={220}
|
width={220}
|
||||||
shadow="md"
|
shadow="md"
|
||||||
withRoles={false}
|
|
||||||
trapFocus
|
trapFocus
|
||||||
returnFocus
|
returnFocus
|
||||||
>
|
>
|
||||||
@@ -353,17 +351,13 @@ export default function ChatInput({
|
|||||||
className={classes.plusButton}
|
className={classes.plusButton}
|
||||||
onClick={() => setPlusMenuOpen((o) => !o)}
|
onClick={() => setPlusMenuOpen((o) => !o)}
|
||||||
aria-label="Add content"
|
aria-label="Add content"
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={plusMenuOpen}
|
|
||||||
aria-controls={plusMenuOpen ? plusMenuId : undefined}
|
|
||||||
>
|
>
|
||||||
<IconPlus size={14} />
|
<IconPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown id={plusMenuId} role="menu" p={4}>
|
<Popover.Dropdown p={4}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
className={classes.plusMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
@@ -383,7 +377,6 @@ export default function ChatInput({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
|
||||||
className={classes.plusMenuItem}
|
className={classes.plusMenuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor?.commands.insertContent("@");
|
editor?.commands.insertContent("@");
|
||||||
@@ -392,7 +385,7 @@ export default function ChatInput({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconAt size={16} className={classes.plusMenuIcon} />
|
<IconAt size={16} className={classes.plusMenuIcon} />
|
||||||
{t("Mention a page")}
|
Mention a page
|
||||||
</button>
|
</button>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty state - Notion AI style centered layout */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { ActionIcon, TextInput } from "@mantine/core";
|
import { ActionIcon, TextInput } from "@mantine/core";
|
||||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type JSX,
|
|
||||||
} from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { IconArrowUp } from "@tabler/icons-react";
|
import { IconArrowUp } from "@tabler/icons-react";
|
||||||
@@ -21,7 +14,7 @@ import { ResultPreview } from "./result-preview.tsx";
|
|||||||
import classes from "./ai-menu.module.css";
|
import classes from "./ai-menu.module.css";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import { copyToClipboard, htmlToMarkdown, isEditorReady } from "@docmost/editor-ext";
|
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
interface EditorAiMenuProps {
|
interface EditorAiMenuProps {
|
||||||
@@ -56,7 +49,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
});
|
});
|
||||||
}, [prompt, output, activeCommandSet]);
|
}, [prompt, output, activeCommandSet]);
|
||||||
const updateMenuPlacement = useCallback(() => {
|
const updateMenuPlacement = useCallback(() => {
|
||||||
if (!isEditorReady(editor) || !showAiMenu) return;
|
if (!editor || !showAiMenu) return;
|
||||||
|
|
||||||
const { view } = editor;
|
const { view } = editor;
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
@@ -102,7 +95,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
);
|
);
|
||||||
const handleGenerate = useCallback(
|
const handleGenerate = useCallback(
|
||||||
(item?: CommandItem) => {
|
(item?: CommandItem) => {
|
||||||
if (!isEditorReady(editor) || isLoading) return;
|
if (!editor || isLoading) return;
|
||||||
|
|
||||||
let command: CommandItem | null = item || null;
|
let command: CommandItem | null = item || null;
|
||||||
|
|
||||||
@@ -165,7 +158,6 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
return setActiveCommandSet("main");
|
return setActiveCommandSet("main");
|
||||||
}
|
}
|
||||||
if (item.id === "result-replace") {
|
if (item.id === "result-replace") {
|
||||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
|
||||||
const chain = editor.chain().focus();
|
const chain = editor.chain().focus();
|
||||||
|
|
||||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
||||||
@@ -191,7 +183,6 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
return setShowAiMenu(false);
|
return setShowAiMenu(false);
|
||||||
}
|
}
|
||||||
if (item.id === "result-insert-below") {
|
if (item.id === "result-insert-below") {
|
||||||
if (!isEditorReady(editor)) return setShowAiMenu(false);
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
@@ -255,7 +246,7 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditorReady(editor)) return;
|
if (!editor) return;
|
||||||
|
|
||||||
const handleClose = () => setShowAiMenu(false);
|
const handleClose = () => setShowAiMenu(false);
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export default function AuditLogsTable({
|
|||||||
{expandable && (
|
{expandable && (
|
||||||
<Table.Tr className={classes.detailRow}>
|
<Table.Tr className={classes.detailRow}>
|
||||||
<Table.Td colSpan={4} p={0}>
|
<Table.Td colSpan={4} p={0}>
|
||||||
<Collapse expanded={isExpanded}>
|
<Collapse in={isExpanded}>
|
||||||
<Box
|
<Box
|
||||||
px="md"
|
px="md"
|
||||||
py="sm"
|
py="sm"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily } from "jotai/utils";
|
import { atomFamily } from "jotai/utils";
|
||||||
import { EditingCell, FocusedCell } from "@/ee/base/types/base.types";
|
import { EditingCell } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
|
// Atoms are scoped per-base via `pageId` so that two BaseTable instances on
|
||||||
// the same page don't share UI state.
|
// the same page don't share UI state.
|
||||||
@@ -41,15 +41,3 @@ export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) =>
|
|||||||
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
|
export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) =>
|
||||||
atom<number | null>(null),
|
atom<number | null>(null),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const focusedCellAtomFamily = atomFamily((_pageId: string) =>
|
|
||||||
atom<FocusedCell>(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type PendingTypeInsert = {
|
|
||||||
rowId: string;
|
|
||||||
propertyId: string;
|
|
||||||
char: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
export const pendingTypeInsertAtom = atom<PendingTypeInsert>(null);
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type BaseTableProps = {
|
|||||||
isFetchingNextPage: boolean;
|
isFetchingNextPage: boolean;
|
||||||
onFetchNextPage: () => void;
|
onFetchNextPage: () => void;
|
||||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||||
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
|
onAddRow: () => void;
|
||||||
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
||||||
onResizeEnd: () => void;
|
onResizeEnd: () => void;
|
||||||
onRowReorder: (
|
onRowReorder: (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
FilterGroup,
|
FilterGroup,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { exportBaseToCsv } from "@/ee/base/services/base-service";
|
import { exportBaseToCsv } from "@/ee/base/services/base-service";
|
||||||
import { getApiErrorMessage } from "@/lib/api-error";
|
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||||
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
|
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
|
||||||
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
|
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
|
||||||
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
|
||||||
@@ -61,6 +61,7 @@ export function BaseToolbar({
|
|||||||
getViewShareUrl,
|
getViewShareUrl,
|
||||||
}: BaseToolbarProps) {
|
}: BaseToolbarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const editable = useBaseEditable();
|
||||||
const [sortOpened, setSortOpened] = useState(false);
|
const [sortOpened, setSortOpened] = useState(false);
|
||||||
const [filterOpened, setFilterOpened] = useState(false);
|
const [filterOpened, setFilterOpened] = useState(false);
|
||||||
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
const [propertiesOpened, setPropertiesOpened] = useState(false);
|
||||||
@@ -77,7 +78,7 @@ export function BaseToolbar({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: "red",
|
color: "red",
|
||||||
message: getApiErrorMessage(err, t("Failed to export CSV")),
|
message: t("Failed to export CSV"),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
@@ -137,17 +138,19 @@ export function BaseToolbar({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={classes.toolbarRight}>
|
<div className={classes.toolbarRight}>
|
||||||
<Tooltip label={t("Export CSV")}>
|
{editable && (
|
||||||
<ActionIcon
|
<Tooltip label={t("Export CSV")}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size="sm"
|
variant="subtle"
|
||||||
color="gray"
|
size="sm"
|
||||||
loading={exporting}
|
color="gray"
|
||||||
onClick={handleExport}
|
loading={exporting}
|
||||||
>
|
onClick={handleExport}
|
||||||
<IconDownload size={16} />
|
>
|
||||||
</ActionIcon>
|
<IconDownload size={16} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<ViewFilterConfigPopover
|
<ViewFilterConfigPopover
|
||||||
opened={filterOpened}
|
opened={filterOpened}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Text, Stack } from "@mantine/core";
|
import { Text, Stack } from "@mantine/core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { IconTable } from "@tabler/icons-react";
|
import { IconDatabase } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
FilterGroup,
|
FilterGroup,
|
||||||
ViewSortConfig,
|
ViewSortConfig,
|
||||||
EditingCell,
|
EditingCell,
|
||||||
FocusedCell,
|
|
||||||
IBaseProperty,
|
IBaseProperty,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +25,6 @@ import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
|||||||
import {
|
import {
|
||||||
activeViewIdAtomFamily,
|
activeViewIdAtomFamily,
|
||||||
editingCellAtomFamily,
|
editingCellAtomFamily,
|
||||||
focusedCellAtomFamily,
|
|
||||||
} from "@/ee/base/atoms/base-atoms";
|
} from "@/ee/base/atoms/base-atoms";
|
||||||
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
|
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
|
||||||
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
||||||
@@ -91,10 +89,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
editingCellAtomFamily(pageId),
|
editingCellAtomFamily(pageId),
|
||||||
) as unknown as [EditingCell, (val: EditingCell) => void];
|
) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||||
|
|
||||||
const [, setFocusedCell] = useAtom(
|
|
||||||
focusedCellAtomFamily(pageId),
|
|
||||||
) as unknown as [FocusedCell, (val: FocusedCell) => void];
|
|
||||||
|
|
||||||
const views = useMemo(
|
const views = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(base?.views ?? [])].sort((a, b) =>
|
[...(base?.views ?? [])].sort((a, b) =>
|
||||||
@@ -227,42 +221,33 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
[editable, pageId, updateRow],
|
[editable, pageId, updateRow],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddRow = useCallback(
|
const handleAddRow = useCallback(() => {
|
||||||
(afterRowId?: string, focusPropertyId?: string) => {
|
if (!editable) return;
|
||||||
if (!editable) return;
|
createRowMutation.mutate(
|
||||||
createRowMutation.mutate(
|
{ pageId },
|
||||||
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
|
{
|
||||||
{
|
onSuccess: (newRow) => {
|
||||||
onSuccess: (newRow) => {
|
const firstEditable = table.getVisibleLeafColumns().find((col) => {
|
||||||
let propertyId = focusPropertyId;
|
if (col.id === "__row_number") return false;
|
||||||
if (!propertyId) {
|
const prop = col.columnDef.meta?.property as
|
||||||
const firstEditable = table.getVisibleLeafColumns().find((col) => {
|
| IBaseProperty
|
||||||
if (col.id === "__row_number") return false;
|
| undefined;
|
||||||
const prop = col.columnDef.meta?.property as
|
return (
|
||||||
| IBaseProperty
|
!!prop &&
|
||||||
| undefined;
|
prop.type !== "checkbox" &&
|
||||||
return (
|
!isSystemPropertyType(prop.type)
|
||||||
!!prop &&
|
);
|
||||||
prop.type !== "checkbox" &&
|
});
|
||||||
!isSystemPropertyType(prop.type)
|
const propertyId = (
|
||||||
);
|
firstEditable?.columnDef.meta?.property as IBaseProperty | undefined
|
||||||
});
|
)?.id;
|
||||||
propertyId = (
|
if (propertyId) {
|
||||||
firstEditable?.columnDef.meta?.property as
|
setEditingCell({ rowId: newRow.id, propertyId });
|
||||||
| IBaseProperty
|
}
|
||||||
| undefined
|
|
||||||
)?.id;
|
|
||||||
}
|
|
||||||
if (propertyId) {
|
|
||||||
setEditingCell({ rowId: newRow.id, propertyId });
|
|
||||||
setFocusedCell({ rowId: newRow.id, propertyId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
|
}, [editable, pageId, createRowMutation, table, setEditingCell]);
|
||||||
);
|
|
||||||
|
|
||||||
const handleViewChange = useCallback(
|
const handleViewChange = useCallback(
|
||||||
(viewId: string) => {
|
(viewId: string) => {
|
||||||
@@ -379,14 +364,14 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
|
|||||||
if (baseError) {
|
if (baseError) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="sm" p="xl">
|
<Stack align="center" gap="sm" p="xl">
|
||||||
<IconTable size={40} color="var(--mantine-color-gray-5)" />
|
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
|
||||||
<Text c="dimmed">{t("Failed to load base")}</Text>
|
<Text c="dimmed">{t("Failed to load base")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
|
|
||||||
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
|
// Ghost rows are an "empty database" affordance, not a "filter matched nothing" state.
|
||||||
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
|
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
|
|||||||
@@ -15,18 +15,9 @@ type CellEmailProps = {
|
|||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft || null;
|
const parse = (draft: string) => draft || null;
|
||||||
|
|
||||||
export function CellEmail({ value, property, rowId, isEditing, onCommit, onCancel }: CellEmailProps) {
|
export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) {
|
||||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
|
||||||
value,
|
|
||||||
isEditing,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
toDraft,
|
|
||||||
parse,
|
|
||||||
rowId,
|
|
||||||
propertyId: property.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ export function CellFile({
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div className={cellClasses.popoverTarget}>
|
<div className={cellClasses.popoverTarget}>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type CellLongTextProps = {
|
|||||||
onCommit: (value: unknown) => void;
|
onCommit: (value: unknown) => void;
|
||||||
onValueChange: (value: unknown) => void;
|
onValueChange: (value: unknown) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onTabNavigate?: (shiftKey: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
@@ -28,7 +27,6 @@ export function CellLongText({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
onTabNavigate,
|
|
||||||
}: CellLongTextProps) {
|
}: CellLongTextProps) {
|
||||||
const [draft, setDraft] = useState(() => toText(value));
|
const [draft, setDraft] = useState(() => toText(value));
|
||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
@@ -129,11 +127,7 @@ export function CellLongText({
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
|
||||||
commit();
|
|
||||||
onTabNavigate?.(e.shiftKey);
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancel();
|
cancel();
|
||||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
|
||||||
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export function CellMultiSelect({
|
|||||||
const handleAddOption = useCallback(() => {
|
const handleAddOption = useCallback(() => {
|
||||||
if (!trimmedSearch) return;
|
if (!trimmedSearch) return;
|
||||||
const newChoice: Choice = {
|
const newChoice: Choice = {
|
||||||
id: generateBaseChoiceId(),
|
id: uuid7(),
|
||||||
name: trimmedSearch,
|
name: trimmedSearch,
|
||||||
color: addOptionColor,
|
color: addOptionColor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
NumberTypeOptions,
|
NumberTypeOptions,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { formatCurrency } from "@/ee/base/constants/currencies";
|
import { formatCurrency } from "@/ee/base/constants/currencies";
|
||||||
import { snapNumber } from "@docmost/base-formula/client";
|
|
||||||
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
@@ -17,98 +16,49 @@ type CellNumberProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEPARATOR_CHARS: Record<string, { group: string; decimal: string }> = {
|
|
||||||
comma_period: { group: ",", decimal: "." },
|
|
||||||
period_comma: { group: ".", decimal: "," },
|
|
||||||
space_comma: { group: " ", decimal: "," },
|
|
||||||
space_period: { group: " ", decimal: "." },
|
|
||||||
};
|
|
||||||
|
|
||||||
function separatorChars(style: string): { group: string; decimal: string } {
|
|
||||||
if (style === "local") {
|
|
||||||
const parts = new Intl.NumberFormat().formatToParts(11111.1);
|
|
||||||
return {
|
|
||||||
group: parts.find((p) => p.type === "group")?.value ?? ",",
|
|
||||||
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return SEPARATOR_CHARS[style] ?? { group: ",", decimal: "." };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPlain(
|
|
||||||
value: number,
|
|
||||||
precision: number | undefined,
|
|
||||||
style: string,
|
|
||||||
): string {
|
|
||||||
const fixed = precision == null ? String(value) : value.toFixed(precision);
|
|
||||||
if (style === "none") return fixed;
|
|
||||||
const { group, decimal } = separatorChars(style);
|
|
||||||
const neg = fixed[0] === "-";
|
|
||||||
const abs = neg ? fixed.slice(1) : fixed;
|
|
||||||
const dot = abs.indexOf(".");
|
|
||||||
const intPart = dot === -1 ? abs : abs.slice(0, dot);
|
|
||||||
const fracPart = dot === -1 ? "" : abs.slice(dot + 1);
|
|
||||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
||||||
const out = fracPart ? `${grouped}${decimal}${fracPart}` : grouped;
|
|
||||||
return neg ? `-${out}` : out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumber(
|
export function formatNumber(
|
||||||
val: number | null | undefined,
|
val: number | null | undefined,
|
||||||
options: NumberTypeOptions | undefined,
|
options: NumberTypeOptions | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (val == null) return "";
|
if (val == null) return "";
|
||||||
const precision = options?.precision;
|
const precision = options?.precision ?? 0;
|
||||||
const format = options?.format ?? "plain";
|
const format = options?.format ?? "plain";
|
||||||
const style = options?.separators ?? "none";
|
|
||||||
const v = precision == null ? snapNumber(val) : val;
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
|
case "separators":
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: precision,
|
||||||
|
maximumFractionDigits: precision,
|
||||||
|
}).format(val);
|
||||||
case "currency":
|
case "currency":
|
||||||
return formatCurrency(v, options?.currencyCode, precision);
|
return formatCurrency(val, options?.currencyCode, options?.precision);
|
||||||
case "percent":
|
case "percent":
|
||||||
return `${formatPlain(v, precision, style)}%`;
|
return `${val.toFixed(precision)}%`;
|
||||||
case "progress":
|
case "progress":
|
||||||
return `${Math.min(100, Math.max(0, v)).toFixed(0)}%`;
|
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
|
||||||
default:
|
default:
|
||||||
return formatPlain(v, precision, style);
|
return precision > 0 ? val.toFixed(precision) : String(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDraft = (value: unknown) =>
|
const toDraft = (value: unknown) =>
|
||||||
typeof value === "number" ? String(value) : "";
|
typeof value === "number" ? String(value) : "";
|
||||||
|
|
||||||
export function sanitizeNumberInput(text: string): string {
|
const parse = (draft: string) => {
|
||||||
return text.replace(/[^0-9.-]/g, "");
|
const parsed = draft === "" ? null : Number(draft);
|
||||||
}
|
return parsed != null && isNaN(parsed) ? null : parsed;
|
||||||
|
};
|
||||||
export function parseNumberDraft(draft: string): number | null {
|
|
||||||
const cleaned = sanitizeNumberInput(draft);
|
|
||||||
if (cleaned === "" || cleaned === "-") return null;
|
|
||||||
const parsed = Number(cleaned);
|
|
||||||
return isNaN(parsed) ? null : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CellNumber({
|
export function CellNumber({
|
||||||
value,
|
value,
|
||||||
property,
|
property,
|
||||||
rowId,
|
|
||||||
isEditing,
|
isEditing,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: CellNumberProps) {
|
}: CellNumberProps) {
|
||||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
|
||||||
value,
|
|
||||||
isEditing,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
toDraft,
|
|
||||||
parse: parseNumberDraft,
|
|
||||||
rowId,
|
|
||||||
propertyId: property.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
@@ -124,17 +74,6 @@ export function CellNumber({
|
|||||||
setDraft(v);
|
setDraft(v);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const el = e.currentTarget;
|
|
||||||
const start = el.selectionStart ?? draft.length;
|
|
||||||
const end = el.selectionEnd ?? draft.length;
|
|
||||||
setDraft(
|
|
||||||
draft.slice(0, start) +
|
|
||||||
sanitizeNumberInput(e.clipboardData.getData("text")) +
|
|
||||||
draft.slice(end),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ function PagePicker({
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div className={cellClasses.popoverTarget}>
|
<div className={cellClasses.popoverTarget}>
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export function CellPerson({
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<div className={cellClasses.popoverTarget}>
|
<div className={cellClasses.popoverTarget}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
|
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
|
||||||
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function CellSelect({
|
|||||||
const handleAddOption = useCallback(() => {
|
const handleAddOption = useCallback(() => {
|
||||||
if (!trimmedSearch) return;
|
if (!trimmedSearch) return;
|
||||||
const newChoice: Choice = {
|
const newChoice: Choice = {
|
||||||
id: generateBaseChoiceId(),
|
id: uuid7(),
|
||||||
name: trimmedSearch,
|
name: trimmedSearch,
|
||||||
color: addOptionColor,
|
color: addOptionColor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,18 +16,9 @@ type CellTextProps = {
|
|||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft;
|
const parse = (draft: string) => draft;
|
||||||
|
|
||||||
export function CellText({ value, property, rowId, isEditing, onCommit, onCancel }: CellTextProps) {
|
export function CellText({ value, isEditing, onCommit, onCancel }: CellTextProps) {
|
||||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
|
||||||
value,
|
|
||||||
isEditing,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
toDraft,
|
|
||||||
parse,
|
|
||||||
rowId,
|
|
||||||
propertyId: property.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,18 +16,9 @@ type CellUrlProps = {
|
|||||||
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
const toDraft = (value: unknown) => (typeof value === "string" ? value : "");
|
||||||
const parse = (draft: string) => draft || null;
|
const parse = (draft: string) => draft || null;
|
||||||
|
|
||||||
export function CellUrl({ value, property, rowId, isEditing, onCommit, onCancel }: CellUrlProps) {
|
export function CellUrl({ value, isEditing, onCommit, onCancel }: CellUrlProps) {
|
||||||
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
const { draft, setDraft, inputRef, handleKeyDown, handleBlur } =
|
||||||
useEditableTextCell({
|
useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse });
|
||||||
value,
|
|
||||||
isEditing,
|
|
||||||
onCommit,
|
|
||||||
onCancel,
|
|
||||||
toDraft,
|
|
||||||
parse,
|
|
||||||
rowId,
|
|
||||||
propertyId: property.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function ChoicePicker({
|
|||||||
const handleAddOption = useCallback(() => {
|
const handleAddOption = useCallback(() => {
|
||||||
if (!trimmedSearch) return;
|
if (!trimmedSearch) return;
|
||||||
const newChoice: Choice = {
|
const newChoice: Choice = {
|
||||||
id: generateBaseChoiceId(),
|
id: uuid7(),
|
||||||
name: trimmedSearch,
|
name: trimmedSearch,
|
||||||
color: addOptionColor,
|
color: addOptionColor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ export const AddRowButton = memo(function AddRowButton({
|
|||||||
<div
|
<div
|
||||||
className={classes.addRowButton}
|
className={classes.addRowButton}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { memo, useCallback, useMemo } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { flushSync } from "react-dom";
|
|
||||||
import { Cell } from "@tanstack/react-table";
|
import { Cell } from "@tanstack/react-table";
|
||||||
import { Popover, Tooltip } from "@mantine/core";
|
import { Popover, Tooltip } from "@mantine/core";
|
||||||
import { IconArrowsDiagonal } from "@tabler/icons-react";
|
import { IconArrowsDiagonal } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAtom, useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { selectAtom } from "jotai/utils";
|
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
|
||||||
import { IBaseRow, EditingCell, FocusedCell } from "@/ee/base/types/base.types";
|
|
||||||
import {
|
import {
|
||||||
editingCellAtomFamily,
|
editingCellAtomFamily,
|
||||||
focusedCellAtomFamily,
|
|
||||||
activeFormulaEditorAtomFamily,
|
activeFormulaEditorAtomFamily,
|
||||||
FormulaEditorTarget,
|
FormulaEditorTarget,
|
||||||
} from "@/ee/base/atoms/base-atoms";
|
} from "@/ee/base/atoms/base-atoms";
|
||||||
@@ -19,7 +16,6 @@ import {
|
|||||||
getDescriptor,
|
getDescriptor,
|
||||||
} from "@/ee/base/property-types/property-type.registry";
|
} from "@/ee/base/property-types/property-type.registry";
|
||||||
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
|
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
|
||||||
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
|
|
||||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||||
import { useRowExpand } from "@/ee/base/context/row-expand";
|
import { useRowExpand } from "@/ee/base/context/row-expand";
|
||||||
import { RowNumberCell } from "./row-number-cell";
|
import { RowNumberCell } from "./row-number-cell";
|
||||||
@@ -28,7 +24,6 @@ import classes from "@/ee/base/styles/grid.module.css";
|
|||||||
type GridCellProps = {
|
type GridCellProps = {
|
||||||
cell: Cell<IBaseRow, unknown>;
|
cell: Cell<IBaseRow, unknown>;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
colIndex?: number;
|
|
||||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
};
|
};
|
||||||
@@ -36,7 +31,6 @@ type GridCellProps = {
|
|||||||
export const GridCell = memo(function GridCell({
|
export const GridCell = memo(function GridCell({
|
||||||
cell,
|
cell,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
colIndex,
|
|
||||||
onCellUpdate,
|
onCellUpdate,
|
||||||
pageId,
|
pageId,
|
||||||
}: GridCellProps) {
|
}: GridCellProps) {
|
||||||
@@ -50,18 +44,6 @@ export const GridCell = memo(function GridCell({
|
|||||||
activeFormulaEditorAtomFamily(pageId),
|
activeFormulaEditorAtomFamily(pageId),
|
||||||
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
||||||
|
|
||||||
const setFocusedCell = useSetAtom(focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>);
|
|
||||||
const isFocused = useAtomValue(
|
|
||||||
useMemo(
|
|
||||||
() =>
|
|
||||||
selectAtom(
|
|
||||||
focusedCellAtomFamily(pageId),
|
|
||||||
(fc) => fc?.rowId === cell.row.id && fc?.propertyId === property?.id,
|
|
||||||
),
|
|
||||||
[pageId, cell.row.id, property?.id],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editable = useBaseEditable();
|
const editable = useBaseEditable();
|
||||||
const readOnly = !editable;
|
const readOnly = !editable;
|
||||||
@@ -73,14 +55,14 @@ export const GridCell = memo(function GridCell({
|
|||||||
editingCell?.propertyId === property?.id &&
|
editingCell?.propertyId === property?.id &&
|
||||||
(editable || property?.type === "file");
|
(editable || property?.type === "file");
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
const handleDoubleClick = useCallback(() => {
|
||||||
if (!property || isRowNumber) return;
|
if (!property || isRowNumber) return;
|
||||||
if (property.type === "checkbox") return;
|
if (property.type === "checkbox") return;
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
// Read-only: only the file cell opens (a download-only popover) so
|
// Read-only: only the file cell opens (a download-only popover) so
|
||||||
// attachments stay reachable.
|
// attachments stay reachable.
|
||||||
if (property.type === "file") {
|
if (property.type === "file") {
|
||||||
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
|
setEditingCell({ rowId, propertyId: property.id });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,32 +71,9 @@ export const GridCell = memo(function GridCell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSystemPropertyType(property.type)) return;
|
if (isSystemPropertyType(property.type)) return;
|
||||||
flushSync(() => setEditingCell({ rowId, propertyId: property.id }));
|
setEditingCell({ rowId, propertyId: property.id });
|
||||||
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!property || e.button !== 0) return;
|
|
||||||
setFocusedCell({ rowId, propertyId: property.id });
|
|
||||||
},
|
|
||||||
[property, rowId, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!property) return;
|
|
||||||
setFocusedCell({ rowId, propertyId: property.id });
|
|
||||||
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
|
|
||||||
preventScroll: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[property, rowId, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cellReadOnly = property
|
|
||||||
? readOnly || isSystemPropertyType(property.type)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const closeFormulaEditor = useCallback(
|
const closeFormulaEditor = useCallback(
|
||||||
() => setActiveFormulaEditor(null),
|
() => setActiveFormulaEditor(null),
|
||||||
[setActiveFormulaEditor],
|
[setActiveFormulaEditor],
|
||||||
@@ -142,31 +101,6 @@ export const GridCell = memo(function GridCell({
|
|||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
}, [setEditingCell]);
|
}, [setEditingCell]);
|
||||||
|
|
||||||
const handleTabNavigate = useCallback(
|
|
||||||
(shiftKey: boolean) => {
|
|
||||||
if (!property) return;
|
|
||||||
const tableInstance = cell.getContext().table;
|
|
||||||
const colIds = tableInstance
|
|
||||||
.getVisibleLeafColumns()
|
|
||||||
.filter((c) => c.id !== "__row_number")
|
|
||||||
.map((c) => c.id);
|
|
||||||
const rowIds = tableInstance.getRowModel().rows.map((r) => r.id);
|
|
||||||
const next = computeNextCell(
|
|
||||||
rowIds,
|
|
||||||
colIds,
|
|
||||||
{ rowId, propertyId: property.id },
|
|
||||||
0,
|
|
||||||
shiftKey ? -1 : 1,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (next) {
|
|
||||||
setEditingCell(next);
|
|
||||||
setFocusedCell(next);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[cell, rowId, property, setEditingCell, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isRowNumber) {
|
if (isRowNumber) {
|
||||||
return (
|
return (
|
||||||
<RowNumberCell
|
<RowNumberCell
|
||||||
@@ -188,19 +122,13 @@ export const GridCell = memo(function GridCell({
|
|||||||
|
|
||||||
const cellInner = (
|
const cellInner = (
|
||||||
<div
|
<div
|
||||||
id={`base-cell-${rowId}-${property.id}`}
|
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
||||||
role="gridcell"
|
|
||||||
aria-colindex={colIndex != null ? colIndex + 1 : undefined}
|
|
||||||
aria-readonly={cellReadOnly || undefined}
|
|
||||||
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${isFocused && !isEditing ? classes.cellFocused : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
|
||||||
style={
|
style={
|
||||||
isPinned
|
isPinned
|
||||||
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onDoubleClick={handleEdit}
|
|
||||||
>
|
>
|
||||||
<CellComponent
|
<CellComponent
|
||||||
value={value}
|
value={value}
|
||||||
@@ -211,15 +139,12 @@ export const GridCell = memo(function GridCell({
|
|||||||
onCommit={handleCommit}
|
onCommit={handleCommit}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onTabNavigate={handleTabNavigate}
|
|
||||||
/>
|
/>
|
||||||
{property.isPrimary && onExpandRow && !isEditing && (
|
{property.isPrimary && onExpandRow && !isEditing && (
|
||||||
<span className={classes.rowExpandAnchor}>
|
<span className={classes.rowExpandAnchor}>
|
||||||
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
|
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
|
||||||
data-base-row-expand=""
|
|
||||||
className={classes.rowExpandButton}
|
className={classes.rowExpandButton}
|
||||||
onClick={() => onExpandRow(rowId)}
|
onClick={() => onExpandRow(rowId)}
|
||||||
onDoubleClick={(e) => e.stopPropagation()}
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
@@ -284,7 +209,6 @@ gridCellPropsEqual);
|
|||||||
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
|
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
|
||||||
if (
|
if (
|
||||||
prev.rowIndex !== next.rowIndex ||
|
prev.rowIndex !== next.rowIndex ||
|
||||||
prev.colIndex !== next.colIndex ||
|
|
||||||
prev.pageId !== next.pageId ||
|
prev.pageId !== next.pageId ||
|
||||||
prev.onCellUpdate !== next.onCellUpdate
|
prev.onCellUpdate !== next.onCellUpdate
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,24 +6,9 @@ import {
|
|||||||
useVirtualizer,
|
useVirtualizer,
|
||||||
windowScroll,
|
windowScroll,
|
||||||
} from "@tanstack/react-virtual";
|
} from "@tanstack/react-virtual";
|
||||||
import { useAtom, useSetAtom, type PrimitiveAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
|
||||||
IBaseRow,
|
import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||||
IBaseProperty,
|
|
||||||
EditingCell,
|
|
||||||
FocusedCell,
|
|
||||||
CellCoord,
|
|
||||||
} from "@/ee/base/types/base.types";
|
|
||||||
import {
|
|
||||||
editingCellAtomFamily,
|
|
||||||
focusedCellAtomFamily,
|
|
||||||
activeFormulaEditorAtomFamily,
|
|
||||||
pendingTypeInsertAtom,
|
|
||||||
type FormulaEditorTarget,
|
|
||||||
type PendingTypeInsert,
|
|
||||||
} from "@/ee/base/atoms/base-atoms";
|
|
||||||
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
|
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
|
||||||
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
|
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
|
||||||
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
|
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
|
||||||
@@ -37,7 +22,6 @@ import { AddRowButton } from "./add-row-button";
|
|||||||
import { GridGhostRows } from "./grid-ghost-rows";
|
import { GridGhostRows } from "./grid-ghost-rows";
|
||||||
import { SelectionActionBar } from "./selection-action-bar";
|
import { SelectionActionBar } from "./selection-action-bar";
|
||||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||||
import { useRowExpand } from "@/ee/base/context/row-expand";
|
|
||||||
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
|
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
|
||||||
import classes from "@/ee/base/styles/grid.module.css";
|
import classes from "@/ee/base/styles/grid.module.css";
|
||||||
|
|
||||||
@@ -67,7 +51,7 @@ type GridContainerProps = {
|
|||||||
table: Table<IBaseRow>;
|
table: Table<IBaseRow>;
|
||||||
properties: IBaseProperty[];
|
properties: IBaseProperty[];
|
||||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||||
onAddRow?: (afterRowId?: string, focusPropertyId?: string) => void;
|
onAddRow?: () => void;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||||
onResizeEnd?: () => void;
|
onResizeEnd?: () => void;
|
||||||
@@ -121,23 +105,14 @@ export function GridContainer({
|
|||||||
rowIdsRef.current = rowIds;
|
rowIdsRef.current = rowIds;
|
||||||
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
|
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
|
||||||
const editable = useBaseEditable();
|
const editable = useBaseEditable();
|
||||||
const onExpandRow = useRowExpand();
|
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||||
const editingCellRef = useRef(editingCell);
|
const editingCellRef = useRef(editingCell);
|
||||||
editingCellRef.current = editingCell;
|
editingCellRef.current = editingCell;
|
||||||
|
|
||||||
const { selectionCount, clear: clearSelection, toggle: toggleRow } = useRowSelection(pageId);
|
const { selectionCount, clear: clearSelection } = useRowSelection(pageId);
|
||||||
const { deleteSelected } = useDeleteSelectedRows(pageId);
|
const { deleteSelected } = useDeleteSelectedRows(pageId);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [focusedCell, setFocusedCell] = useAtom(focusedCellAtomFamily(pageId)) as unknown as [FocusedCell, (val: FocusedCell) => void];
|
|
||||||
const focusedCellRef = useRef(focusedCell);
|
|
||||||
focusedCellRef.current = focusedCell;
|
|
||||||
const [, setActiveFormulaEditor] = useAtom(activeFormulaEditorAtomFamily(pageId)) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
|
||||||
const setPendingTypeInsert = useSetAtom(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
// Only act while an inline cell editor is open. Popover-based cells
|
// Only act while an inline cell editor is open. Popover-based cells
|
||||||
@@ -166,6 +141,12 @@ export function GridContainer({
|
|||||||
|
|
||||||
useColumnResize(table, onResizeEnd ?? (() => {}));
|
useColumnResize(table, onResizeEnd ?? (() => {}));
|
||||||
|
|
||||||
|
useGridKeyboardNav({
|
||||||
|
table,
|
||||||
|
editingCell,
|
||||||
|
setEditingCell,
|
||||||
|
containerRef: bodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
// When the scroll container is the window (inline embed mode), the default
|
// When the scroll container is the window (inline embed mode), the default
|
||||||
// Element-mode observers read scrollTop/scrollLeft, which Window does not
|
// Element-mode observers read scrollTop/scrollLeft, which Window does not
|
||||||
@@ -245,177 +226,6 @@ export function GridContainer({
|
|||||||
|
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
const pinnedLeftWidth = useCallback(
|
|
||||||
() =>
|
|
||||||
table
|
|
||||||
.getVisibleLeafColumns()
|
|
||||||
.filter((c) => c.getIsPinned() === "left")
|
|
||||||
.reduce((sum, c) => sum + c.getSize(), 0),
|
|
||||||
[table],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollCellIntoView = useCallback(
|
|
||||||
(coord: CellCoord, rowIndex: number) => {
|
|
||||||
if (rowIndex >= 0) virtualizer.scrollToIndex(rowIndex, { align: "auto" });
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const scroller = bodyRef.current;
|
|
||||||
const el = document.getElementById(
|
|
||||||
`base-cell-${coord.rowId}-${coord.propertyId}`,
|
|
||||||
);
|
|
||||||
if (!scroller || !el) return;
|
|
||||||
const cellRect = el.getBoundingClientRect();
|
|
||||||
const scRect = scroller.getBoundingClientRect();
|
|
||||||
const pinned = pinnedLeftWidth();
|
|
||||||
if (cellRect.left < scRect.left + pinned) {
|
|
||||||
scroller.scrollLeft -= scRect.left + pinned - cellRect.left;
|
|
||||||
} else if (cellRect.right > scRect.right) {
|
|
||||||
scroller.scrollLeft += cellRect.right - scRect.right;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[virtualizer, pinnedLeftWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editingCell) return;
|
|
||||||
const idx = rowIdsRef.current.indexOf(editingCell.rowId);
|
|
||||||
if (idx >= 0) scrollCellIntoView(editingCell, idx);
|
|
||||||
}, [editingCell, scrollCellIntoView]);
|
|
||||||
|
|
||||||
const openEditor = useCallback(
|
|
||||||
(coord: CellCoord) => {
|
|
||||||
const prop = properties.find((p) => p.id === coord.propertyId);
|
|
||||||
if (!prop) return;
|
|
||||||
if (prop.type === "checkbox") {
|
|
||||||
if (!editable) return;
|
|
||||||
const current = table.getRow(coord.rowId, true)?.getValue(coord.propertyId);
|
|
||||||
onCellUpdate(coord.rowId, coord.propertyId, !current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!editable) {
|
|
||||||
if (prop.type === "file") setEditingCell(coord);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (prop.type === "formula") {
|
|
||||||
setActiveFormulaEditor({ propertyId: coord.propertyId, rowId: coord.rowId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSystemPropertyType(prop.type)) return;
|
|
||||||
setEditingCell(coord);
|
|
||||||
},
|
|
||||||
[properties, editable, table, onCellUpdate, setEditingCell, setActiveFormulaEditor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearCell = useCallback(
|
|
||||||
(coord: CellCoord) => {
|
|
||||||
if (!editable) return;
|
|
||||||
const prop = properties.find((p) => p.id === coord.propertyId);
|
|
||||||
if (!prop || isSystemPropertyType(prop.type)) return;
|
|
||||||
onCellUpdate(coord.rowId, coord.propertyId, null);
|
|
||||||
},
|
|
||||||
[editable, properties, onCellUpdate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const beginTypeToEdit = useCallback(
|
|
||||||
(coord: CellCoord, char: string) => {
|
|
||||||
if (!editable) return;
|
|
||||||
const prop = properties.find((p) => p.id === coord.propertyId);
|
|
||||||
if (!prop || isSystemPropertyType(prop.type) || prop.type === "checkbox") return;
|
|
||||||
if (["text", "number", "url", "email"].includes(prop.type)) {
|
|
||||||
setPendingTypeInsert({ rowId: coord.rowId, propertyId: coord.propertyId, char });
|
|
||||||
setEditingCell(coord);
|
|
||||||
} else {
|
|
||||||
openEditor(coord);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editable, properties, setPendingTypeInsert, setEditingCell, openEditor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleRowSelection = useCallback(
|
|
||||||
(rowId: string) => {
|
|
||||||
toggleRow(rowId, {
|
|
||||||
shiftKey: false,
|
|
||||||
rowIndex: rowIdsRef.current.indexOf(rowId),
|
|
||||||
orderedRowIds: rowIdsRef.current,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[toggleRow],
|
|
||||||
);
|
|
||||||
|
|
||||||
const expandRow = useCallback(
|
|
||||||
(rowId: string) => {
|
|
||||||
onExpandRow?.(rowId);
|
|
||||||
},
|
|
||||||
[onExpandRow],
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevEditingRef = useRef(editingCell);
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevEditingRef.current;
|
|
||||||
prevEditingRef.current = editingCell;
|
|
||||||
if (prev && !editingCell) {
|
|
||||||
if (!focusedCellRef.current) setFocusedCell(prev);
|
|
||||||
const grid = bodyRef.current;
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (grid && active && !grid.contains(active)) {
|
|
||||||
grid.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [editingCell, setFocusedCell]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fc = focusedCellRef.current;
|
|
||||||
if (!fc) return;
|
|
||||||
const rowOk = rowIds.includes(fc.rowId);
|
|
||||||
const colOk = table.getVisibleLeafColumns().some((c) => c.id === fc.propertyId);
|
|
||||||
if (!rowOk || !colOk) setFocusedCell(null);
|
|
||||||
}, [rowIds, table.getState().columnVisibility, table.getState().columnOrder, setFocusedCell]);
|
|
||||||
|
|
||||||
const handleGridFocus = useCallback(
|
|
||||||
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target !== e.currentTarget) return;
|
|
||||||
if (editingCellRef.current || focusedCellRef.current) return;
|
|
||||||
const firstRow = rowIdsRef.current[0];
|
|
||||||
const firstCol = table
|
|
||||||
.getVisibleLeafColumns()
|
|
||||||
.find((c) => c.id !== "__row_number")?.id;
|
|
||||||
if (firstRow && firstCol) setFocusedCell({ rowId: firstRow, propertyId: firstCol });
|
|
||||||
},
|
|
||||||
[table, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddRowBelow = useCallback(
|
|
||||||
(afterRowId: string, focusPropertyId: string) => {
|
|
||||||
onAddRow?.(afterRowId, focusPropertyId);
|
|
||||||
},
|
|
||||||
[onAddRow],
|
|
||||||
);
|
|
||||||
|
|
||||||
useGridKeyboardNav({
|
|
||||||
table,
|
|
||||||
properties,
|
|
||||||
containerRef: bodyRef,
|
|
||||||
focusedCell,
|
|
||||||
setFocusedCell,
|
|
||||||
editingCell,
|
|
||||||
setEditingCell,
|
|
||||||
openEditor,
|
|
||||||
clearCell,
|
|
||||||
beginTypeToEdit,
|
|
||||||
scrollCellIntoView,
|
|
||||||
selectionCount,
|
|
||||||
clearSelection,
|
|
||||||
deleteSelected,
|
|
||||||
toggleRowSelection,
|
|
||||||
expandRow,
|
|
||||||
addRow: handleAddRowBelow,
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeCell = editingCell ?? focusedCell;
|
|
||||||
const activeDescendantId = activeCell
|
|
||||||
? `base-cell-${activeCell.rowId}-${activeCell.propertyId}`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
||||||
const lastItem = virtualItems[virtualItems.length - 1];
|
const lastItem = virtualItems[virtualItems.length - 1];
|
||||||
@@ -434,6 +244,29 @@ export function GridContainer({
|
|||||||
}
|
}
|
||||||
}, [rows.length]);
|
}, [rows.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (!el || !pageId) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (editingCell) return;
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (!active || !el.contains(active)) return;
|
||||||
|
const tag = active.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && selectionCount > 0) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
void deleteSelected();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener("keydown", handler);
|
||||||
|
return () => el.removeEventListener("keydown", handler);
|
||||||
|
}, [editingCell, selectionCount, clearSelection, deleteSelected, pageId]);
|
||||||
|
|
||||||
const gridTemplateColumns = useMemo(() => {
|
const gridTemplateColumns = useMemo(() => {
|
||||||
const visibleColumns = table.getVisibleLeafColumns();
|
const visibleColumns = table.getVisibleLeafColumns();
|
||||||
@@ -481,7 +314,7 @@ export function GridContainer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={GRID_ROOT_STYLE}>
|
<div role="grid" style={GRID_ROOT_STYLE}>
|
||||||
{aboveBand}
|
{aboveBand}
|
||||||
<div className={classes.stickyBand}>
|
<div className={classes.stickyBand}>
|
||||||
<div
|
<div
|
||||||
@@ -508,13 +341,6 @@ export function GridContainer({
|
|||||||
className={classes.bodyGrid}
|
className={classes.bodyGrid}
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="grid"
|
|
||||||
aria-label={t("Base table")}
|
|
||||||
aria-rowcount={rows.length}
|
|
||||||
aria-colcount={table.getVisibleLeafColumns().length}
|
|
||||||
aria-multiselectable
|
|
||||||
aria-activedescendant={activeDescendantId}
|
|
||||||
onFocus={handleGridFocus}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--base-grid-cols": bodyGridTemplateColumns,
|
"--base-grid-cols": bodyGridTemplateColumns,
|
||||||
|
|||||||
@@ -57,12 +57,9 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
|||||||
const isRowNumber = header.column.id === "__row_number";
|
const isRowNumber = header.column.id === "__row_number";
|
||||||
const isPinned = header.column.getIsPinned();
|
const isPinned = header.column.getIsPinned();
|
||||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||||
const { selectionCount, toggleAll } = useRowSelection(pageId);
|
const { selectionCount } = useRowSelection(pageId);
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
const editable = useBaseEditable();
|
const editable = useBaseEditable();
|
||||||
const isHeaderInteractive = editable && !!property && !isRowNumber;
|
|
||||||
const isRowNumberHeaderInteractive =
|
|
||||||
isRowNumber && editable && loadedRowIds.length > 0;
|
|
||||||
|
|
||||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
||||||
const menuOpened = activePropertyMenu === header.column.id;
|
const menuOpened = activePropertyMenu === header.column.id;
|
||||||
@@ -211,10 +208,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cellRef}
|
ref={cellRef}
|
||||||
role="columnheader"
|
|
||||||
tabIndex={isHeaderInteractive || isRowNumberHeaderInteractive ? 0 : undefined}
|
|
||||||
aria-haspopup={isHeaderInteractive ? "menu" : undefined}
|
|
||||||
aria-label={isRowNumberHeaderInteractive ? t("Select all loaded rows") : undefined}
|
|
||||||
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
|
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
|
||||||
style={{
|
style={{
|
||||||
...(isPinned
|
...(isPinned
|
||||||
@@ -227,16 +220,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
|||||||
resizeIntentRef.current = false;
|
resizeIntentRef.current = false;
|
||||||
}}
|
}}
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isRowNumber) {
|
|
||||||
if (isRowNumberHeaderInteractive) toggleAll(loadedRowIds);
|
|
||||||
} else {
|
|
||||||
handleHeaderClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-dragging={isDragging || undefined}
|
data-dragging={isDragging || undefined}
|
||||||
>
|
>
|
||||||
{isRowNumber ? (
|
{isRowNumber ? (
|
||||||
@@ -286,7 +269,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
|||||||
shadow="md"
|
shadow="md"
|
||||||
width={260}
|
width={260}
|
||||||
trapFocus
|
trapFocus
|
||||||
returnFocus
|
|
||||||
withinPortal
|
withinPortal
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
|
|||||||
@@ -160,15 +160,12 @@ export const GridRow = memo(function GridRow({
|
|||||||
data-index={rowIndex}
|
data-index={rowIndex}
|
||||||
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
|
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
|
||||||
role="row"
|
role="row"
|
||||||
aria-rowindex={rowIndex + 1}
|
|
||||||
aria-selected={isSelected}
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell, colIndex) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<GridCell
|
<GridCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
cell={cell}
|
cell={cell}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
colIndex={colIndex}
|
|
||||||
onCellUpdate={onCellUpdate}
|
onCellUpdate={onCellUpdate}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { memo, useCallback, useMemo } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { Checkbox } from "@mantine/core";
|
import { Checkbox } from "@mantine/core";
|
||||||
import { IconGripVertical } from "@tabler/icons-react";
|
import { IconGripVertical } from "@tabler/icons-react";
|
||||||
import { useAtomValue, useSetAtom, type PrimitiveAtom } from "jotai";
|
|
||||||
import { selectAtom } from "jotai/utils";
|
|
||||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||||
import { focusedCellAtomFamily } from "@/ee/base/atoms/base-atoms";
|
|
||||||
import { FocusedCell } from "@/ee/base/types/base.types";
|
|
||||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||||
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
|
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
|
||||||
import classes from "@/ee/base/styles/grid.module.css";
|
import classes from "@/ee/base/styles/grid.module.css";
|
||||||
@@ -30,38 +26,6 @@ export const RowNumberCell = memo(function RowNumberCell({
|
|||||||
const editable = useBaseEditable();
|
const editable = useBaseEditable();
|
||||||
const getOrderedRowIds = useGridRowOrder();
|
const getOrderedRowIds = useGridRowOrder();
|
||||||
|
|
||||||
const setFocusedCell = useSetAtom(
|
|
||||||
focusedCellAtomFamily(pageId) as PrimitiveAtom<FocusedCell>,
|
|
||||||
);
|
|
||||||
const isFocused = useAtomValue(
|
|
||||||
useMemo(
|
|
||||||
() =>
|
|
||||||
selectAtom(
|
|
||||||
focusedCellAtomFamily(pageId),
|
|
||||||
(fc) => fc?.rowId === rowId && fc?.propertyId === "__row_number",
|
|
||||||
),
|
|
||||||
[pageId, rowId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCellMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
setFocusedCell({ rowId, propertyId: "__row_number" });
|
|
||||||
},
|
|
||||||
[rowId, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCellClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
setFocusedCell({ rowId, propertyId: "__row_number" });
|
|
||||||
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
|
|
||||||
preventScroll: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[rowId, setFocusedCell],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheckboxChange = useCallback(
|
const handleCheckboxChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const nativeEvent = e.nativeEvent as MouseEvent;
|
const nativeEvent = e.nativeEvent as MouseEvent;
|
||||||
@@ -76,16 +40,12 @@ export const RowNumberCell = memo(function RowNumberCell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`base-cell-${rowId}-__row_number`}
|
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
|
||||||
role="gridcell"
|
|
||||||
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${isFocused ? classes.cellFocused : ""}`}
|
|
||||||
style={
|
style={
|
||||||
isPinned
|
isPinned
|
||||||
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
|
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={handleCellClick}
|
|
||||||
onMouseDown={handleCellMouseDown}
|
|
||||||
>
|
>
|
||||||
<div className={classes.rowNumberCellInner}>
|
<div className={classes.rowNumberCellInner}>
|
||||||
{editable && (
|
{editable && (
|
||||||
@@ -100,7 +60,6 @@ export const RowNumberCell = memo(function RowNumberCell({
|
|||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={handleCheckboxChange}
|
onChange={handleCheckboxChange}
|
||||||
aria-label="Select row"
|
aria-label="Select row"
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
|
|||||||
indeterminate={indeterminate}
|
indeterminate={indeterminate}
|
||||||
onChange={() => toggleAll(loadedRowIds)}
|
onChange={() => toggleAll(loadedRowIds)}
|
||||||
aria-label="Select all loaded rows"
|
aria-label="Select all loaded rows"
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
|
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
|
||||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
|
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
|
||||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Stack, Text, Select, Button } from "@mantine/core";
|
import { Stack, Text, Select, Button } from "@mantine/core";
|
||||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||||
@@ -36,9 +36,9 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateStatus = useCallback(() => {
|
const handleCreateStatus = useCallback(() => {
|
||||||
const todoId = generateBaseChoiceId();
|
const todoId = uuid7();
|
||||||
const inProgressId = generateBaseChoiceId();
|
const inProgressId = uuid7();
|
||||||
const completeId = generateBaseChoiceId();
|
const completeId = uuid7();
|
||||||
createProperty.mutate(
|
createProperty.mutate(
|
||||||
{
|
{
|
||||||
pageId,
|
pageId,
|
||||||
@@ -67,14 +67,14 @@ export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptySt
|
|||||||
|
|
||||||
if (!editable) {
|
if (!editable) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
|
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
|
||||||
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
|
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="md" style={{ flex: 1, paddingTop: "15vh" }}>
|
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
|
||||||
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
|
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
|
||||||
{groupableProperties.length > 0 ? (
|
{groupableProperties.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-
|
|||||||
import { Choice } from "@/ee/base/types/base.types";
|
import { Choice } from "@/ee/base/types/base.types";
|
||||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { DefaultValuePicker } from "./default-value-picker";
|
import { DefaultValuePicker } from "./default-value-picker";
|
||||||
|
|
||||||
const CHOICE_COLORS = [
|
const CHOICE_COLORS = [
|
||||||
@@ -52,9 +52,9 @@ const STATUS_CATEGORIES = [
|
|||||||
// Default choices for a new status property, one per category.
|
// Default choices for a new status property, one per category.
|
||||||
export function defaultStatusChoices(): Choice[] {
|
export function defaultStatusChoices(): Choice[] {
|
||||||
return [
|
return [
|
||||||
{ id: generateBaseChoiceId(), name: "Not started", color: "gray", category: "todo" },
|
{ id: uuid7(), name: "Not started", color: "gray", category: "todo" },
|
||||||
{ id: generateBaseChoiceId(), name: "In progress", color: "blue", category: "inProgress" },
|
{ id: uuid7(), name: "In progress", color: "blue", category: "inProgress" },
|
||||||
{ id: generateBaseChoiceId(), name: "Done", color: "green", category: "complete" },
|
{ id: uuid7(), name: "Done", color: "green", category: "complete" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ export function ChoiceEditor({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
|
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
|
||||||
const id = generateBaseChoiceId();
|
const id = uuid7();
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const colorIndex = prev.length % CHOICE_COLORS.length;
|
const colorIndex = prev.length % CHOICE_COLORS.length;
|
||||||
const newChoice: Choice = {
|
const newChoice: Choice = {
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ import {
|
|||||||
TypeOptions,
|
TypeOptions,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
import { PropertyTypePicker } from "./property-type-picker";
|
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||||
import { PropertyOptions } from "./property-options";
|
import { PropertyOptions } from "./property-options";
|
||||||
import {
|
import {
|
||||||
getDescriptor,
|
getDescriptor,
|
||||||
defaultTypeOptionsFor,
|
defaultTypeOptionsFor,
|
||||||
propertyTypes,
|
|
||||||
} from "@/ee/base/property-types/property-type.registry";
|
} from "@/ee/base/property-types/property-type.registry";
|
||||||
import { FormulaEditor } from "../formula/formula-editor";
|
import { FormulaEditor } from "../formula/formula-editor";
|
||||||
import classes from "@/ee/base/styles/grid.module.css";
|
import classes from "@/ee/base/styles/grid.module.css";
|
||||||
@@ -51,10 +50,6 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
|
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
|
||||||
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
|
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [position, setPosition] = useState<"bottom-start" | "top-start">(
|
|
||||||
"bottom-start",
|
|
||||||
);
|
|
||||||
|
|
||||||
const createPropertyMutation = useCreatePropertyMutation();
|
const createPropertyMutation = useCreatePropertyMutation();
|
||||||
|
|
||||||
@@ -98,20 +93,10 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
setTypeOptions({});
|
setTypeOptions({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpen = useCallback(
|
const handleOpen = useCallback(() => {
|
||||||
(event?: React.SyntheticEvent) => {
|
resetState();
|
||||||
resetState();
|
setOpened(true);
|
||||||
const trigger = event?.currentTarget as HTMLElement | undefined;
|
}, [resetState]);
|
||||||
if (trigger) {
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
const spaceAbove = rect.top;
|
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
|
||||||
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
|
|
||||||
}
|
|
||||||
setOpened(true);
|
|
||||||
},
|
|
||||||
[resetState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
// Don't reset state here: resetting mid-close flashes the type picker.
|
// Don't reset state here: resetting mid-close flashes the type picker.
|
||||||
@@ -230,23 +215,11 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
onChange={(o) => {
|
onChange={(o) => {
|
||||||
if (!o) attemptClose();
|
if (!o) attemptClose();
|
||||||
}}
|
}}
|
||||||
position={position}
|
position="bottom-start"
|
||||||
shadow="md"
|
shadow="md"
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape={false}
|
closeOnEscape={false}
|
||||||
withinPortal
|
withinPortal
|
||||||
hideDetached={false}
|
|
||||||
middlewares={{
|
|
||||||
flip: false,
|
|
||||||
shift: true,
|
|
||||||
size: {
|
|
||||||
padding: 8,
|
|
||||||
apply: ({ availableHeight }) => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el) el.style.maxHeight = `${availableHeight}px`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
{renderTarget ? (
|
{renderTarget ? (
|
||||||
@@ -274,7 +247,6 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
maxWidth: "calc(100vw - 32px)",
|
maxWidth: "calc(100vw - 32px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={scrollRef} style={{ overflowY: "auto", overflowX: "hidden" }}>
|
|
||||||
{panel === "typePicker" && (
|
{panel === "typePicker" && (
|
||||||
<Stack gap={0} p={4}>
|
<Stack gap={0} p={4}>
|
||||||
<ScrollArea.Autosize
|
<ScrollArea.Autosize
|
||||||
@@ -409,7 +381,6 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
IBaseProperty,
|
IBaseProperty,
|
||||||
BasePropertyType,
|
BasePropertyType,
|
||||||
TypeOptions,
|
|
||||||
SelectTypeOptions,
|
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
|
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||||
@@ -30,7 +28,7 @@ import {
|
|||||||
useUpdatePropertyMutation,
|
useUpdatePropertyMutation,
|
||||||
useDeletePropertyMutation,
|
useDeletePropertyMutation,
|
||||||
} from "@/ee/base/queries/base-property-query";
|
} from "@/ee/base/queries/base-property-query";
|
||||||
import { PropertyTypePicker } from "./property-type-picker";
|
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||||
import { PropertyOptions } from "./property-options";
|
import { PropertyOptions } from "./property-options";
|
||||||
import {
|
import {
|
||||||
conversionWarning,
|
conversionWarning,
|
||||||
@@ -38,11 +36,7 @@ import {
|
|||||||
NON_USER_TARGET_TYPES,
|
NON_USER_TARGET_TYPES,
|
||||||
} from "./conversion-warning";
|
} from "./conversion-warning";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
|
||||||
isSystemPropertyType,
|
|
||||||
propertyTypes,
|
|
||||||
defaultTypeOptionsFor,
|
|
||||||
} from "@/ee/base/property-types/property-type.registry";
|
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
import classes from "@/ee/base/styles/property.module.css";
|
import classes from "@/ee/base/styles/property.module.css";
|
||||||
|
|
||||||
@@ -64,31 +58,6 @@ type MenuPanel =
|
|||||||
| "confirmDelete"
|
| "confirmDelete"
|
||||||
| "confirmDiscard";
|
| "confirmDiscard";
|
||||||
|
|
||||||
const CHOICE_TYPES = new Set<BasePropertyType>([
|
|
||||||
"select",
|
|
||||||
"multiSelect",
|
|
||||||
"status",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function typeOptionsForConversion(
|
|
||||||
source: IBaseProperty,
|
|
||||||
target: BasePropertyType,
|
|
||||||
): TypeOptions {
|
|
||||||
if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) {
|
|
||||||
return defaultTypeOptionsFor(target);
|
|
||||||
}
|
|
||||||
const opts = source.typeOptions as SelectTypeOptions | undefined;
|
|
||||||
const choices = opts?.choices ?? [];
|
|
||||||
const choiceOrder = opts?.choiceOrder?.length
|
|
||||||
? opts.choiceOrder
|
|
||||||
: choices.map((c) => c.id);
|
|
||||||
const carried: SelectTypeOptions = { choices, choiceOrder };
|
|
||||||
if (target === "status") {
|
|
||||||
carried.defaultValue = choices[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
return carried;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PropertyMenuContent({
|
export function PropertyMenuContent({
|
||||||
property,
|
property,
|
||||||
opened,
|
opened,
|
||||||
@@ -216,10 +185,16 @@ export function PropertyMenuContent({
|
|||||||
propertyId: property.id,
|
propertyId: property.id,
|
||||||
pageId: property.pageId,
|
pageId: property.pageId,
|
||||||
type: pendingTargetType,
|
type: pendingTargetType,
|
||||||
typeOptions: typeOptionsForConversion(property, pendingTargetType),
|
typeOptions: {},
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
}, [pendingTargetType, property, updatePropertyMutation, onClose]);
|
}, [
|
||||||
|
pendingTargetType,
|
||||||
|
property.id,
|
||||||
|
property.pageId,
|
||||||
|
updatePropertyMutation,
|
||||||
|
onClose,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
deletePropertyMutation.mutate({
|
deletePropertyMutation.mutate({
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ function NumberOptions({
|
|||||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||||
data={[
|
data={[
|
||||||
{ value: "plain", label: t("Number") },
|
{ value: "plain", label: t("Number") },
|
||||||
|
{ value: "separators", label: t("Number with separators") },
|
||||||
{ value: "currency", label: t("Currency") },
|
{ value: "currency", label: t("Currency") },
|
||||||
{ value: "percent", label: t("Percent") },
|
{ value: "percent", label: t("Percent") },
|
||||||
{ value: "progress", label: t("Progress") },
|
{ value: "progress", label: t("Progress") },
|
||||||
@@ -366,40 +367,13 @@ function NumberOptions({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Select
|
<NumberInput
|
||||||
size="xs"
|
|
||||||
label={t("Thousands and decimal separators")}
|
|
||||||
allowDeselect={false}
|
|
||||||
checkIconPosition="right"
|
|
||||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
|
||||||
data={[
|
|
||||||
{ value: "none", label: t("None") },
|
|
||||||
{ value: "local", label: t("Local") },
|
|
||||||
{ value: "comma_period", label: t("Comma, period") },
|
|
||||||
{ value: "period_comma", label: t("Period, comma") },
|
|
||||||
{ value: "space_comma", label: t("Space, comma") },
|
|
||||||
{ value: "space_period", label: t("Space, period") },
|
|
||||||
]}
|
|
||||||
value={options.separators ?? "none"}
|
|
||||||
onChange={(val) => update({ separators: val ?? "none" })}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
size="xs"
|
size="xs"
|
||||||
label={t("Decimal places")}
|
label={t("Decimal places")}
|
||||||
allowDeselect={false}
|
min={0}
|
||||||
checkIconPosition="right"
|
max={8}
|
||||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
value={options.precision ?? 0}
|
||||||
data={[
|
onChange={(val) => update({ precision: val })}
|
||||||
{ value: "default", label: t("Default") },
|
|
||||||
...Array.from({ length: 9 }, (_, i) => ({
|
|
||||||
value: String(i),
|
|
||||||
label: String(i),
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
value={options.precision == null ? "default" : String(options.precision)}
|
|
||||||
onChange={(val) =>
|
|
||||||
update({ precision: val == null || val === "default" ? undefined : Number(val) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
||||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||||
import { BasePropertyType } from "@/ee/base/types/base.types";
|
import { BasePropertyType } from "@/ee/base/types/base.types";
|
||||||
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
|
import {
|
||||||
|
PROPERTY_PICKER_ORDER,
|
||||||
|
getDescriptor,
|
||||||
|
} from "@/ee/base/property-types/property-type.registry";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import classes from "@/ee/base/styles/cells.module.css";
|
import classes from "@/ee/base/styles/cells.module.css";
|
||||||
|
|
||||||
|
const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
|
||||||
|
const d = getDescriptor(type)!;
|
||||||
|
return { type, icon: d.icon, labelKey: d.labelKey };
|
||||||
|
});
|
||||||
|
|
||||||
type PropertyTypePickerProps = {
|
type PropertyTypePickerProps = {
|
||||||
onSelect: (type: BasePropertyType) => void;
|
onSelect: (type: BasePropertyType) => void;
|
||||||
currentType?: BasePropertyType;
|
currentType?: BasePropertyType;
|
||||||
@@ -69,3 +77,5 @@ export function PropertyTypePicker({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { propertyTypes };
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export function FieldChoice({ property, value, readOnly, onChange }: FieldProps)
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape={false}
|
closeOnEscape={false}
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<FieldShell
|
<FieldShell
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
|
|||||||
trapFocus
|
trapFocus
|
||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
closeOnEscape
|
closeOnEscape
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<FieldShell
|
<FieldShell
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
||||||
import {
|
import { formatNumber } from "@/ee/base/components/cells/cell-number";
|
||||||
formatNumber,
|
|
||||||
parseNumberDraft,
|
|
||||||
sanitizeNumberInput,
|
|
||||||
} from "@/ee/base/components/cells/cell-number";
|
|
||||||
import { FieldProps, FieldShell } from "./detail-field";
|
import { FieldProps, FieldShell } from "./detail-field";
|
||||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||||
|
|
||||||
const toDraft = (value: unknown) =>
|
const toDraft = (value: unknown) =>
|
||||||
typeof value === "number" ? String(value) : "";
|
typeof value === "number" ? String(value) : "";
|
||||||
|
|
||||||
|
const parse = (draft: string) => {
|
||||||
|
const parsed = draft === "" ? null : Number(draft);
|
||||||
|
return parsed != null && isNaN(parsed) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
||||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||||
const numValue = typeof value === "number" ? value : null;
|
const numValue = typeof value === "number" ? value : null;
|
||||||
@@ -41,7 +42,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
|||||||
setDraft(toDraft(value));
|
setDraft(toDraft(value));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
|
if (parse(draft) !== numValue) onChange(parse(draft));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,17 +62,6 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
|
|||||||
setDraft(v);
|
setDraft(v);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPaste={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const el = e.currentTarget;
|
|
||||||
const start = el.selectionStart ?? draft.length;
|
|
||||||
const end = el.selectionEnd ?? draft.length;
|
|
||||||
setDraft(
|
|
||||||
draft.slice(0, start) +
|
|
||||||
sanitizeNumberInput(e.clipboardData.getData("text")) +
|
|
||||||
draft.slice(end),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export function PropertyRow({
|
|||||||
withinPortal
|
withinPortal
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
closeOnEscape={false}
|
closeOnEscape={false}
|
||||||
hideDetached={false}
|
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -217,7 +217,6 @@ export function RowDetailModal({
|
|||||||
radius="md"
|
radius="md"
|
||||||
title={null}
|
title={null}
|
||||||
classNames={{ content: classes.modalContent }}
|
classNames={{ content: classes.modalContent }}
|
||||||
removeScrollProps={{ noIsolation: true }}
|
|
||||||
>
|
>
|
||||||
{row ? (
|
{row ? (
|
||||||
<>
|
<>
|
||||||
@@ -293,7 +292,6 @@ export function RowDetailModal({
|
|||||||
row={row}
|
row={row}
|
||||||
primaryProperty={primaryProperty}
|
primaryProperty={primaryProperty}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onClose={onClose}
|
|
||||||
onCommit={(value) => {
|
onCommit={(value) => {
|
||||||
if (!primaryProperty) return;
|
if (!primaryProperty) return;
|
||||||
updateRowMutation.mutate({
|
updateRowMutation.mutate({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||||
import { timeAgo } from "@/lib/time.ts";
|
import { timeAgo } from "@/lib/time.ts";
|
||||||
@@ -9,7 +9,6 @@ type RowDetailTitleProps = {
|
|||||||
primaryProperty: IBaseProperty | undefined;
|
primaryProperty: IBaseProperty | undefined;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onCommit: (value: string) => void;
|
onCommit: (value: string) => void;
|
||||||
onClose: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RowDetailTitle({
|
export function RowDetailTitle({
|
||||||
@@ -17,28 +16,35 @@ export function RowDetailTitle({
|
|||||||
primaryProperty,
|
primaryProperty,
|
||||||
canEdit,
|
canEdit,
|
||||||
onCommit,
|
onCommit,
|
||||||
onClose,
|
|
||||||
}: RowDetailTitleProps) {
|
}: RowDetailTitleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initial = primaryProperty
|
const initial = primaryProperty
|
||||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const [value, setValue] = useState(initial);
|
const [value, setValue] = useState(initial);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const didAutofocusRef = useRef(false);
|
||||||
|
|
||||||
// Re-sync when the row changes underneath us (navigation or remote edit).
|
// Re-sync when the row changes underneath us (navigation or remote edit).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(initial);
|
setValue(initial);
|
||||||
}, [initial]);
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (didAutofocusRef.current || !canEdit || initial) return;
|
||||||
|
didAutofocusRef.current = true;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [canEdit, initial]);
|
||||||
|
|
||||||
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
className={classes.titleInput}
|
className={classes.titleInput}
|
||||||
{...(!initial ? { "data-autofocus": true } : {})}
|
|
||||||
placeholder={t("Untitled")}
|
placeholder={t("Untitled")}
|
||||||
aria-label={primaryProperty?.name ?? t("Untitled")}
|
aria-label={primaryProperty?.name ?? t("Untitled")}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -51,10 +57,6 @@ export function RowDetailTitle({
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.currentTarget as HTMLInputElement).blur();
|
(e.currentTarget as HTMLInputElement).blur();
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
(e.currentTarget as HTMLInputElement).blur();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
} from "@/ee/base/property-types/property-type.registry";
|
} from "@/ee/base/property-types/property-type.registry";
|
||||||
import { FilterPersonInput } from "./filter-person-input";
|
import { FilterPersonInput } from "./filter-person-input";
|
||||||
import { FilterDateInput } from "./filter-date-input";
|
import { FilterDateInput } from "./filter-date-input";
|
||||||
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
|
|
||||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||||
|
|
||||||
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
|
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
|
||||||
@@ -192,7 +191,6 @@ export function ViewFilterConfigPopover({
|
|||||||
children,
|
children,
|
||||||
}: ViewFilterConfigProps) {
|
}: ViewFilterConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useEscapeClose(opened, onClose);
|
|
||||||
|
|
||||||
const propertyOptions = properties.map((p) => ({
|
const propertyOptions = properties.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { useMemo, useCallback } from "react";
|
|||||||
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
|
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
|
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
|
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
|
|
||||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ export function ViewPropertyVisibility({
|
|||||||
children,
|
children,
|
||||||
}: ViewPropertyVisibilityProps) {
|
}: ViewPropertyVisibilityProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useEscapeClose(opened, onClose);
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return table
|
return table
|
||||||
@@ -124,9 +122,6 @@ export function ViewPropertyVisibility({
|
|||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
key={col.id}
|
key={col.id}
|
||||||
role="switch"
|
|
||||||
aria-checked={isVisible}
|
|
||||||
aria-disabled={!canHide || undefined}
|
|
||||||
className={cellClasses.menuItem}
|
className={cellClasses.menuItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (canHide) {
|
if (canHide) {
|
||||||
@@ -145,8 +140,6 @@ export function ViewPropertyVisibility({
|
|||||||
size="xs"
|
size="xs"
|
||||||
checked={isVisible}
|
checked={isVisible}
|
||||||
disabled={!canHide}
|
disabled={!canHide}
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden
|
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
// Clicking the track synthesizes a second click on the hidden input which bubbles
|
// Clicking the track synthesizes a second click on the hidden input which bubbles
|
||||||
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
|
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type ViewRendererProps = {
|
|||||||
isFetchingNextPage: boolean;
|
isFetchingNextPage: boolean;
|
||||||
onFetchNextPage: () => void;
|
onFetchNextPage: () => void;
|
||||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||||
onAddRow: (afterRowId?: string, focusPropertyId?: string) => void;
|
onAddRow: () => void;
|
||||||
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
||||||
onResizeEnd: () => void;
|
onResizeEnd: () => void;
|
||||||
onRowReorder: (
|
onRowReorder: (
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
ViewSortConfig,
|
ViewSortConfig,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeClose } from "@/ee/base/hooks/use-escape-close";
|
|
||||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||||
|
|
||||||
type ViewSortConfigProps = {
|
type ViewSortConfigProps = {
|
||||||
@@ -36,7 +35,6 @@ export function ViewSortConfigPopover({
|
|||||||
children,
|
children,
|
||||||
}: ViewSortConfigProps) {
|
}: ViewSortConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useEscapeClose(opened, onClose);
|
|
||||||
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
|
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -288,35 +288,15 @@ function ViewTab({
|
|||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div
|
<TextInput
|
||||||
style={{
|
size="xs"
|
||||||
display: "inline-flex",
|
w={120}
|
||||||
alignItems: "center",
|
value={editingName}
|
||||||
padding: "1px 10px",
|
onChange={(e) => onRenameChange(e.currentTarget.value)}
|
||||||
border: "1px solid var(--mantine-color-default-border)",
|
onBlur={onRenameCommit}
|
||||||
borderRadius: "var(--mantine-radius-xl)",
|
onKeyDown={onRenameKeyDown}
|
||||||
}}
|
autoFocus
|
||||||
>
|
/>
|
||||||
<TextInput
|
|
||||||
variant="unstyled"
|
|
||||||
size="xs"
|
|
||||||
value={editingName}
|
|
||||||
onChange={(e) => onRenameChange(e.currentTarget.value)}
|
|
||||||
onBlur={onRenameCommit}
|
|
||||||
onKeyDown={onRenameKeyDown}
|
|
||||||
autoFocus
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
height: "auto",
|
|
||||||
minHeight: 0,
|
|
||||||
padding: 0,
|
|
||||||
width: 100,
|
|
||||||
fontSize: "var(--mantine-font-size-sm)",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useStore, type PrimitiveAtom } from "jotai";
|
|
||||||
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
|
|
||||||
|
|
||||||
export type UseEditableTextCellParams = {
|
export type UseEditableTextCellParams = {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -11,8 +9,6 @@ export type UseEditableTextCellParams = {
|
|||||||
toDraft: (value: unknown) => string;
|
toDraft: (value: unknown) => string;
|
||||||
/** draft string -> the value passed to onCommit */
|
/** draft string -> the value passed to onCommit */
|
||||||
parse: (draft: string) => unknown;
|
parse: (draft: string) => unknown;
|
||||||
rowId?: string;
|
|
||||||
propertyId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditableTextCell = {
|
export type EditableTextCell = {
|
||||||
@@ -30,8 +26,6 @@ export function useEditableTextCell({
|
|||||||
onCancel,
|
onCancel,
|
||||||
toDraft,
|
toDraft,
|
||||||
parse,
|
parse,
|
||||||
rowId,
|
|
||||||
propertyId,
|
|
||||||
}: UseEditableTextCellParams): EditableTextCell {
|
}: UseEditableTextCellParams): EditableTextCell {
|
||||||
const [draft, setDraft] = useState(() => toDraft(value));
|
const [draft, setDraft] = useState(() => toDraft(value));
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -39,35 +33,18 @@ export function useEditableTextCell({
|
|||||||
const wasEditingRef = useRef(false);
|
const wasEditingRef = useRef(false);
|
||||||
const toDraftRef = useRef(toDraft);
|
const toDraftRef = useRef(toDraft);
|
||||||
toDraftRef.current = toDraft;
|
toDraftRef.current = toDraft;
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && !wasEditingRef.current) {
|
if (isEditing && !wasEditingRef.current) {
|
||||||
committedRef.current = false;
|
committedRef.current = false;
|
||||||
const pending = store.get(pendingTypeInsertAtom);
|
setDraft(toDraftRef.current(value));
|
||||||
const seeded =
|
requestAnimationFrame(() => {
|
||||||
pending != null &&
|
inputRef.current?.focus();
|
||||||
pending.rowId === rowId &&
|
inputRef.current?.select();
|
||||||
pending.propertyId === propertyId;
|
});
|
||||||
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
|
|
||||||
if (seeded) {
|
|
||||||
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
|
|
||||||
}
|
|
||||||
setDraft(nextDraft);
|
|
||||||
const el = inputRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.value = nextDraft;
|
|
||||||
el.focus({ preventScroll: true });
|
|
||||||
try {
|
|
||||||
el.setSelectionRange(nextDraft.length, nextDraft.length);
|
|
||||||
} catch {
|
|
||||||
// email/number inputs reject setSelectionRange
|
|
||||||
}
|
|
||||||
el.scrollLeft = el.scrollWidth;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
wasEditingRef.current = isEditing;
|
wasEditingRef.current = isEditing;
|
||||||
}, [isEditing, value, rowId, propertyId, store]);
|
}, [isEditing, value]);
|
||||||
|
|
||||||
const commitOnce = useCallback(
|
const commitOnce = useCallback(
|
||||||
(val: unknown) => {
|
(val: unknown) => {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export function useEscapeClose(opened: boolean, onClose: () => void) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) return;
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [opened, onClose]);
|
|
||||||
}
|
|
||||||
@@ -1,317 +1,120 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import {
|
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
|
||||||
IBaseRow,
|
|
||||||
IBaseProperty,
|
|
||||||
EditingCell,
|
|
||||||
FocusedCell,
|
|
||||||
CellCoord,
|
|
||||||
} from "@/ee/base/types/base.types";
|
|
||||||
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
|
|
||||||
|
|
||||||
type UseGridKeyboardNavOptions = {
|
type UseGridKeyboardNavOptions = {
|
||||||
table: Table<IBaseRow>;
|
table: Table<IBaseRow>;
|
||||||
properties: IBaseProperty[];
|
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
focusedCell: FocusedCell;
|
|
||||||
setFocusedCell: (cell: FocusedCell) => void;
|
|
||||||
editingCell: EditingCell;
|
editingCell: EditingCell;
|
||||||
setEditingCell: (cell: EditingCell) => void;
|
setEditingCell: (cell: EditingCell) => void;
|
||||||
openEditor: (coord: CellCoord) => void;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
clearCell: (coord: CellCoord) => void;
|
|
||||||
beginTypeToEdit: (coord: CellCoord, char: string) => void;
|
|
||||||
scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void;
|
|
||||||
selectionCount: number;
|
|
||||||
clearSelection: () => void;
|
|
||||||
deleteSelected: () => void | Promise<void>;
|
|
||||||
toggleRowSelection: (rowId: string) => void;
|
|
||||||
expandRow: (rowId: string) => void;
|
|
||||||
addRow: (afterRowId: string, focusPropertyId: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPrintableKey = (e: KeyboardEvent) =>
|
|
||||||
e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
|
|
||||||
|
|
||||||
const isTextEntry = (el: Element | null) =>
|
|
||||||
!!el &&
|
|
||||||
(el.tagName === "INPUT" ||
|
|
||||||
el.tagName === "TEXTAREA" ||
|
|
||||||
(el as HTMLElement).isContentEditable);
|
|
||||||
|
|
||||||
export function useGridKeyboardNav({
|
export function useGridKeyboardNav({
|
||||||
table,
|
table,
|
||||||
properties,
|
|
||||||
containerRef,
|
|
||||||
focusedCell,
|
|
||||||
setFocusedCell,
|
|
||||||
editingCell,
|
editingCell,
|
||||||
setEditingCell,
|
setEditingCell,
|
||||||
openEditor,
|
containerRef,
|
||||||
clearCell,
|
|
||||||
beginTypeToEdit,
|
|
||||||
scrollCellIntoView,
|
|
||||||
selectionCount,
|
|
||||||
clearSelection,
|
|
||||||
deleteSelected,
|
|
||||||
toggleRowSelection,
|
|
||||||
expandRow,
|
|
||||||
addRow,
|
|
||||||
}: UseGridKeyboardNavOptions) {
|
}: UseGridKeyboardNavOptions) {
|
||||||
const getColIds = useCallback(
|
const getNavigableColumns = useCallback(() => {
|
||||||
() =>
|
return table
|
||||||
table
|
.getVisibleLeafColumns()
|
||||||
.getVisibleLeafColumns()
|
.filter((col) => col.id !== "__row_number")
|
||||||
.filter((col) => col.id !== "__row_number")
|
.map((col) => col.id);
|
||||||
.map((col) => col.id),
|
}, [table]);
|
||||||
[table],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getNavColIds = useCallback(
|
const getRowIds = useCallback(() => {
|
||||||
() => table.getVisibleLeafColumns().map((col) => col.id),
|
return table.getRowModel().rows.map((row) => row.id);
|
||||||
[table],
|
}, [table]);
|
||||||
);
|
|
||||||
|
|
||||||
const getRowIds = useCallback(
|
const navigate = useCallback(
|
||||||
() => table.getRowModel().rows.map((row) => row.id),
|
(rowDelta: number, colDelta: number) => {
|
||||||
[table],
|
if (!editingCell) return;
|
||||||
);
|
|
||||||
|
|
||||||
const propertyType = useCallback(
|
const columns = getNavigableColumns();
|
||||||
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
|
const rowIds = getRowIds();
|
||||||
[properties],
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryPropertyId = useMemo(
|
const currentColIndex = columns.indexOf(editingCell.propertyId);
|
||||||
() => properties.find((p) => p.isPrimary)?.id,
|
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
|
||||||
[properties],
|
|
||||||
);
|
|
||||||
|
|
||||||
const goEditing = useCallback(
|
if (currentColIndex === -1 || currentRowIndex === -1) return;
|
||||||
(next: CellCoord) => {
|
|
||||||
|
let nextColIndex = currentColIndex + colDelta;
|
||||||
|
let nextRowIndex = currentRowIndex + rowDelta;
|
||||||
|
|
||||||
|
if (nextColIndex < 0) {
|
||||||
|
nextColIndex = columns.length - 1;
|
||||||
|
nextRowIndex -= 1;
|
||||||
|
} else if (nextColIndex >= columns.length) {
|
||||||
|
nextColIndex = 0;
|
||||||
|
nextRowIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
|
||||||
|
|
||||||
|
// Blur fires onBlur->commit before React unmounts the input
|
||||||
(document.activeElement as HTMLElement | null)?.blur();
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
setEditingCell(next);
|
|
||||||
setFocusedCell(next);
|
|
||||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
|
||||||
},
|
|
||||||
[setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const goFocused = useCallback(
|
setEditingCell({
|
||||||
(next: CellCoord) => {
|
rowId: rowIds[nextRowIndex],
|
||||||
setFocusedCell(next);
|
propertyId: columns[nextColIndex],
|
||||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
});
|
||||||
},
|
},
|
||||||
[setFocusedCell, scrollCellIntoView, getRowIds],
|
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (editingCell) {
|
if (!editingCell) return;
|
||||||
const inInput = isTextEntry(e.target as Element);
|
|
||||||
switch (e.key) {
|
|
||||||
case "ArrowUp":
|
|
||||||
case "ArrowDown":
|
|
||||||
case "ArrowLeft":
|
|
||||||
case "ArrowRight": {
|
|
||||||
if (inInput) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const d =
|
|
||||||
e.key === "ArrowUp"
|
|
||||||
? [-1, 0]
|
|
||||||
: e.key === "ArrowDown"
|
|
||||||
? [1, 0]
|
|
||||||
: e.key === "ArrowLeft"
|
|
||||||
? [0, -1]
|
|
||||||
: [0, 1];
|
|
||||||
const next = computeNextCell(
|
|
||||||
getRowIds(),
|
|
||||||
getColIds(),
|
|
||||||
editingCell,
|
|
||||||
d[0],
|
|
||||||
d[1],
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (next) goEditing(next);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Tab": {
|
|
||||||
e.preventDefault();
|
|
||||||
const next = computeNextCell(
|
|
||||||
getRowIds(),
|
|
||||||
getColIds(),
|
|
||||||
editingCell,
|
|
||||||
0,
|
|
||||||
e.shiftKey ? -1 : 1,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (next) goEditing(next);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Enter": {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey && editingCell.propertyId === primaryPropertyId) {
|
|
||||||
(document.activeElement as HTMLElement | null)?.blur();
|
|
||||||
setEditingCell(null);
|
|
||||||
addRow(editingCell.rowId, editingCell.propertyId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const next = computeNextCell(
|
|
||||||
getRowIds(),
|
|
||||||
getColIds(),
|
|
||||||
editingCell,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
(document.activeElement as HTMLElement | null)?.blur();
|
|
||||||
setEditingCell(null);
|
|
||||||
if (next) goFocused(next);
|
|
||||||
else setFocusedCell(editingCell);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Escape": {
|
|
||||||
e.preventDefault();
|
|
||||||
setEditingCell(null);
|
|
||||||
setFocusedCell(editingCell);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.target !== containerRef.current) return;
|
const target = e.target as HTMLElement;
|
||||||
|
const isInputActive =
|
||||||
if (isTextEntry(document.activeElement)) return;
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
if (e.key === "Escape") {
|
target.isContentEditable;
|
||||||
if (selectionCount > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
clearSelection();
|
|
||||||
} else if (focusedCell) {
|
|
||||||
e.preventDefault();
|
|
||||||
setFocusedCell(null);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === "Delete" || e.key === "Backspace") {
|
|
||||||
if (selectionCount > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
void deleteSelected();
|
|
||||||
} else if (focusedCell) {
|
|
||||||
e.preventDefault();
|
|
||||||
clearCell(focusedCell);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!focusedCell) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
if (!isInputActive) {
|
||||||
{
|
e.preventDefault();
|
||||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
|
navigate(-1, 0);
|
||||||
if (next) goFocused(next);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
e.preventDefault();
|
if (!isInputActive) {
|
||||||
{
|
e.preventDefault();
|
||||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
|
navigate(1, 0);
|
||||||
if (next) goFocused(next);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
e.preventDefault();
|
if (!isInputActive) {
|
||||||
{
|
e.preventDefault();
|
||||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
|
navigate(0, -1);
|
||||||
if (next) goFocused(next);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
|
if (!isInputActive) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(0, 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
{
|
navigate(0, e.shiftKey ? -1 : 1);
|
||||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
|
|
||||||
if (next) goFocused(next);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "Tab": {
|
case "Escape":
|
||||||
const next = computeNextCell(
|
|
||||||
getRowIds(),
|
|
||||||
getNavColIds(),
|
|
||||||
focusedCell,
|
|
||||||
0,
|
|
||||||
e.shiftKey ? -1 : 1,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (next) {
|
|
||||||
e.preventDefault();
|
|
||||||
goFocused(next);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Enter":
|
|
||||||
case "F2":
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (
|
setEditingCell(null);
|
||||||
e.key === "Enter" &&
|
|
||||||
e.shiftKey &&
|
|
||||||
focusedCell.propertyId === primaryPropertyId
|
|
||||||
) {
|
|
||||||
addRow(focusedCell.rowId, focusedCell.propertyId);
|
|
||||||
} else if (focusedCell.propertyId === "__row_number") {
|
|
||||||
toggleRowSelection(focusedCell.rowId);
|
|
||||||
} else {
|
|
||||||
openEditor(focusedCell);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default: {
|
|
||||||
if (e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (focusedCell.propertyId === "__row_number") {
|
|
||||||
toggleRowSelection(focusedCell.rowId);
|
|
||||||
} else if (propertyType(focusedCell.propertyId) === "checkbox") {
|
|
||||||
openEditor(focusedCell);
|
|
||||||
} else {
|
|
||||||
expandRow(focusedCell.rowId);
|
|
||||||
}
|
|
||||||
} else if (isPrintableKey(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
beginTypeToEdit(focusedCell, e.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[editingCell, navigate, setEditingCell],
|
||||||
containerRef,
|
|
||||||
editingCell,
|
|
||||||
focusedCell,
|
|
||||||
getRowIds,
|
|
||||||
getColIds,
|
|
||||||
getNavColIds,
|
|
||||||
goEditing,
|
|
||||||
goFocused,
|
|
||||||
setEditingCell,
|
|
||||||
setFocusedCell,
|
|
||||||
openEditor,
|
|
||||||
clearCell,
|
|
||||||
beginTypeToEdit,
|
|
||||||
propertyType,
|
|
||||||
selectionCount,
|
|
||||||
clearSelection,
|
|
||||||
deleteSelected,
|
|
||||||
toggleRowSelection,
|
|
||||||
expandRow,
|
|
||||||
primaryPropertyId,
|
|
||||||
addRow,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!el) return;
|
if (!container) return;
|
||||||
el.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
container.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [containerRef, handleKeyDown]);
|
}, [containerRef, handleKeyDown]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,15 +38,7 @@ export function useHorizontalScrollSync<
|
|||||||
body.scrollLeft += e.deltaY;
|
body.scrollLeft += e.deltaY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onHeaderScroll = () => {
|
|
||||||
if (rafId !== 0) return;
|
|
||||||
if (body.scrollLeft !== header.scrollLeft) {
|
|
||||||
body.scrollLeft = header.scrollLeft;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
body.addEventListener("scroll", onBodyScroll, { passive: true });
|
body.addEventListener("scroll", onBodyScroll, { passive: true });
|
||||||
header.addEventListener("scroll", onHeaderScroll, { passive: true });
|
|
||||||
header.addEventListener("wheel", onHeaderWheel, { passive: false });
|
header.addEventListener("wheel", onHeaderWheel, { passive: false });
|
||||||
|
|
||||||
// Initial sync in case the body is already scrolled when the hook mounts.
|
// Initial sync in case the body is already scrolled when the hook mounts.
|
||||||
@@ -54,7 +46,6 @@ export function useHorizontalScrollSync<
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
body.removeEventListener("scroll", onBodyScroll);
|
body.removeEventListener("scroll", onBodyScroll);
|
||||||
header.removeEventListener("scroll", onHeaderScroll);
|
|
||||||
header.removeEventListener("wheel", onHeaderWheel);
|
header.removeEventListener("wheel", onHeaderWheel);
|
||||||
if (rafId !== 0) cancelAnimationFrame(rafId);
|
if (rafId !== 0) cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function BasePage() {
|
|||||||
{base.icon ? `${base.icon} ` : ""}{base.name}
|
{base.icon ? `${base.icon} ` : ""}{base.name}
|
||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
<BaseView pageId={pageId} editable={hasBases && (base?.permissions?.canEdit ?? false)} />
|
<BaseView pageId={pageId} editable={hasBases && (base?.canEdit ?? false)} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export type CellComponentProps = {
|
|||||||
onCommit: (value: unknown) => void;
|
onCommit: (value: unknown) => void;
|
||||||
onValueChange: (value: unknown) => void;
|
onValueChange: (value: unknown) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onTabNavigate?: (shiftKey: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterInputKind =
|
export type FilterInputKind =
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export const PROPERTY_TYPE_REGISTRY: Record<
|
|||||||
filterInput: "number",
|
filterInput: "number",
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
hasOptions: true,
|
hasOptions: true,
|
||||||
defaultTypeOptions: () => ({ separators: "local" }),
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
type: "select",
|
type: "select",
|
||||||
@@ -97,11 +96,7 @@ export const PROPERTY_TYPE_REGISTRY: Record<
|
|||||||
hasOptions: true,
|
hasOptions: true,
|
||||||
defaultTypeOptions: () => {
|
defaultTypeOptions: () => {
|
||||||
const choices = defaultStatusChoices();
|
const choices = defaultStatusChoices();
|
||||||
return {
|
return { choices, choiceOrder: choices.map((c) => c.id) };
|
||||||
choices,
|
|
||||||
choiceOrder: choices.map((c) => c.id),
|
|
||||||
defaultValue: choices[0].id,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
multiSelect: {
|
multiSelect: {
|
||||||
@@ -249,11 +244,6 @@ export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
|
|||||||
"createdAt", "lastEditedAt", "lastEditedBy",
|
"createdAt", "lastEditedAt", "lastEditedBy",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
|
|
||||||
const d = getDescriptor(type)!;
|
|
||||||
return { type, icon: d.icon, labelKey: d.labelKey };
|
|
||||||
});
|
|
||||||
|
|
||||||
export function systemAccessorFor(type: string) {
|
export function systemAccessorFor(type: string) {
|
||||||
return getDescriptor(type)?.systemAccessor;
|
return getDescriptor(type)?.systemAccessor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getApiErrorMessage } from "@/lib/api-error";
|
|
||||||
import { IPagination } from "@/lib/types";
|
import { IPagination } from "@/lib/types";
|
||||||
|
|
||||||
export function useCreatePropertyMutation() {
|
export function useCreatePropertyMutation() {
|
||||||
@@ -37,9 +36,9 @@ export function useCreatePropertyMutation() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to create property")),
|
message: t("Failed to create property"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -70,9 +69,9 @@ export function useUpdatePropertyMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to update property")),
|
message: t("Failed to update property"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -115,9 +114,9 @@ export function useDeletePropertyMutation() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to delete property")),
|
message: t("Failed to delete property"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -155,7 +154,7 @@ export function useReorderPropertyMutation() {
|
|||||||
|
|
||||||
return { previous };
|
return { previous };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (_, variables, context) => {
|
||||||
if (context?.previous) {
|
if (context?.previous) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["bases", variables.pageId],
|
["bases", variables.pageId],
|
||||||
@@ -163,7 +162,7 @@ export function useReorderPropertyMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to reorder property")),
|
message: t("Failed to reorder property"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ import {
|
|||||||
CreateBaseInput,
|
CreateBaseInput,
|
||||||
UpdateBaseInput,
|
UpdateBaseInput,
|
||||||
} from "@/ee/base/types/base.types";
|
} from "@/ee/base/types/base.types";
|
||||||
import { IPage } from "@/features/page/types/page.types";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getApiErrorMessage } from "@/lib/api-error";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
@@ -46,9 +44,9 @@ export function useCreateBaseMutation() {
|
|||||||
queryKey: ["bases", "list", data.spaceId],
|
queryKey: ["bases", "list", data.spaceId],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to create base")),
|
message: t("Failed to create base"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -63,10 +61,6 @@ export function useConvertPageToBaseMutation() {
|
|||||||
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
|
return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
|
||||||
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
|
mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
|
||||||
onSuccess: (base) => {
|
onSuccess: (base) => {
|
||||||
const markAsBase = (old?: IPage) => (old ? { ...old, isBase: true } : old);
|
|
||||||
queryClient.setQueryData<IPage>(["pages", base.id], markAsBase);
|
|
||||||
queryClient.setQueryData<IPage>(["pages", base.slugId], markAsBase);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
queryClient.invalidateQueries({ queryKey: ["pages"] });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["root-sidebar-pages", base.spaceId],
|
queryKey: ["root-sidebar-pages", base.spaceId],
|
||||||
@@ -80,12 +74,12 @@ export function useConvertPageToBaseMutation() {
|
|||||||
spaceId: base.spaceId,
|
spaceId: base.spaceId,
|
||||||
entity: ["pages"],
|
entity: ["pages"],
|
||||||
id: base.id,
|
id: base.id,
|
||||||
payload: { isBase: true, slugId: base.slugId },
|
payload: { isBase: true },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to create base")),
|
message: t("Failed to create base"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -102,9 +96,9 @@ export function useUpdateBaseMutation() {
|
|||||||
return { ...old, ...data };
|
return { ...old, ...data };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to update base")),
|
message: t("Failed to update base"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -122,9 +116,9 @@ export function useDeleteBaseMutation() {
|
|||||||
});
|
});
|
||||||
notifications.show({ message: t("Base deleted") });
|
notifications.show({ message: t("Base deleted") });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to delete base")),
|
message: t("Failed to delete base"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getApiErrorMessage } from "@/lib/api-error";
|
|
||||||
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
|
import { useHydrateReferences } from "@/ee/base/reference/reference-store";
|
||||||
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
|
import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
|
||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
@@ -147,15 +146,12 @@ export function useCreateRowMutation() {
|
|||||||
);
|
);
|
||||||
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
|
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
|
||||||
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
|
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
|
||||||
queryClient.invalidateQueries({
|
invalidateBaseRows(newRow.pageId);
|
||||||
queryKey: ["base-rows", newRow.pageId],
|
|
||||||
refetchType: "none",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to create row")),
|
message: t("Failed to create row"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -225,7 +221,7 @@ export function useUpdateRowMutation() {
|
|||||||
|
|
||||||
return { snapshots };
|
return { snapshots };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (_, variables, context) => {
|
||||||
if (context?.snapshots) {
|
if (context?.snapshots) {
|
||||||
for (const [key, data] of context.snapshots) {
|
for (const [key, data] of context.snapshots) {
|
||||||
queryClient.setQueryData(key, data);
|
queryClient.setQueryData(key, data);
|
||||||
@@ -235,7 +231,7 @@ export function useUpdateRowMutation() {
|
|||||||
queryKey: ["base-row", variables.pageId, variables.rowId],
|
queryKey: ["base-row", variables.pageId, variables.rowId],
|
||||||
});
|
});
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to update row")),
|
message: t("Failed to update row"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -309,14 +305,14 @@ export function useDeleteRowMutation() {
|
|||||||
|
|
||||||
return { snapshots };
|
return { snapshots };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (_, variables, context) => {
|
||||||
if (context?.snapshots) {
|
if (context?.snapshots) {
|
||||||
for (const [key, data] of context.snapshots) {
|
for (const [key, data] of context.snapshots) {
|
||||||
queryClient.setQueryData(key, data);
|
queryClient.setQueryData(key, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to delete row")),
|
message: t("Failed to delete row"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -353,14 +349,14 @@ export function useDeleteRowsMutation() {
|
|||||||
|
|
||||||
return { snapshots };
|
return { snapshots };
|
||||||
},
|
},
|
||||||
onError: (error, __, context) => {
|
onError: (_, __, context) => {
|
||||||
if (context?.snapshots) {
|
if (context?.snapshots) {
|
||||||
for (const [key, data] of context.snapshots) {
|
for (const [key, data] of context.snapshots) {
|
||||||
queryClient.setQueryData(key, data);
|
queryClient.setQueryData(key, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to delete rows")),
|
message: t("Failed to delete rows"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -403,14 +399,14 @@ export function useReorderRowMutation() {
|
|||||||
|
|
||||||
return { snapshots };
|
return { snapshots };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (_, variables, context) => {
|
||||||
if (context?.snapshots) {
|
if (context?.snapshots) {
|
||||||
for (const [key, data] of context.snapshots) {
|
for (const [key, data] of context.snapshots) {
|
||||||
queryClient.setQueryData(key, data);
|
queryClient.setQueryData(key, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to reorder row")),
|
message: t("Failed to reorder row"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -508,14 +504,14 @@ export function useKanbanMoveCardMutation() {
|
|||||||
|
|
||||||
return { snapshots };
|
return { snapshots };
|
||||||
},
|
},
|
||||||
onError: (error, __, context) => {
|
onError: (_, __, context) => {
|
||||||
if (context?.snapshots) {
|
if (context?.snapshots) {
|
||||||
for (const [key, data] of context.snapshots) {
|
for (const [key, data] of context.snapshots) {
|
||||||
queryClient.setQueryData(key, data);
|
queryClient.setQueryData(key, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to move card")),
|
message: t("Failed to move card"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -555,14 +551,10 @@ export function useKanbanCreateCardMutation() {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
queryClient.setQueryData<IBaseRow>(
|
|
||||||
["base-row", newRow.pageId, newRow.id],
|
|
||||||
newRow,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to add card")),
|
message: t("Failed to add card"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ function applyConfigPatch(
|
|||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getApiErrorMessage } from "@/lib/api-error";
|
|
||||||
|
|
||||||
export function useCreateViewMutation() {
|
export function useCreateViewMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -46,9 +45,9 @@ export function useCreateViewMutation() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to create view")),
|
message: t("Failed to create view"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -100,7 +99,7 @@ export function useUpdateViewMutation() {
|
|||||||
|
|
||||||
return { previous };
|
return { previous };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (_, variables, context) => {
|
||||||
if (context?.previous) {
|
if (context?.previous) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["bases", variables.pageId],
|
["bases", variables.pageId],
|
||||||
@@ -108,7 +107,7 @@ export function useUpdateViewMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to update view")),
|
message: t("Failed to update view"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -145,9 +144,9 @@ export function useDeleteViewMutation() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: getApiErrorMessage(error, t("Failed to delete view")),
|
message: t("Failed to delete view"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,11 +36,6 @@
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bodyGrid:focus,
|
|
||||||
.bodyGrid:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bodyGrid::-webkit-scrollbar {
|
.bodyGrid::-webkit-scrollbar {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
@@ -184,13 +179,11 @@
|
|||||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.row:hover .cell {
|
||||||
.row:hover .cell {
|
background-color: light-dark(
|
||||||
background-color: light-dark(
|
var(--mantine-color-gray-0),
|
||||||
var(--mantine-color-gray-0),
|
var(--mantine-color-dark-7)
|
||||||
var(--mantine-color-dark-7)
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
@@ -198,7 +191,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
touch-action: manipulation;
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
font-size: var(--mantine-font-size-sm);
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||||
background-color: light-dark(
|
background-color: light-dark(
|
||||||
@@ -230,13 +222,11 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.row:hover .cellPinned {
|
||||||
.row:hover .cellPinned {
|
background-color: light-dark(
|
||||||
background-color: light-dark(
|
var(--mantine-color-gray-0),
|
||||||
var(--mantine-color-gray-0),
|
var(--mantine-color-dark-7)
|
||||||
var(--mantine-color-dark-7)
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cellEditing {
|
.cellEditing {
|
||||||
@@ -246,26 +236,6 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bodyGrid:focus .cellFocused {
|
|
||||||
outline: 2px solid var(--mantine-color-blue-5);
|
|
||||||
outline-offset: -2px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.cellFocused {
|
|
||||||
outline: 2px solid var(--mantine-color-blue-5);
|
|
||||||
outline-offset: -2px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerCell:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-color-blue-5);
|
|
||||||
outline-offset: -2px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cellContent {
|
.cellContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -483,7 +453,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
touch-action: manipulation;
|
|
||||||
transition: opacity 80ms ease, color 80ms ease;
|
transition: opacity 80ms ease, color 80ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,28 +462,17 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
.rowExpandButton:hover {
|
||||||
.rowExpandButton {
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
opacity: 1;
|
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.row:hover .rowNumberIndex {
|
||||||
.rowExpandButton:hover {
|
display: none;
|
||||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
|
||||||
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.row:hover .rowNumberCheckbox,
|
||||||
@media (hover: hover) {
|
.row:hover .rowNumberDragHandle {
|
||||||
.row:hover .rowNumberIndex {
|
display: inline-flex;
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.row:hover .rowNumberCheckbox,
|
|
||||||
.row:hover .rowNumberDragHandle {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowSelected .rowNumberIndex {
|
.rowSelected .rowNumberIndex {
|
||||||
@@ -523,29 +481,13 @@
|
|||||||
.rowSelected .rowNumberCheckbox {
|
.rowSelected .rowNumberCheckbox {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.bodyGrid:focus .cellFocused .rowNumberIndex {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.bodyGrid:focus .cellFocused .rowNumberCheckbox {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
@media (hover: none) {
|
|
||||||
.cellFocused .rowNumberIndex {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.cellFocused .rowNumberCheckbox {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rowSelected .cell {
|
.rowSelected .cell {
|
||||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.row.rowSelected:hover .cell,
|
||||||
.row.rowSelected:hover .cell,
|
.row.rowSelected:hover .cellPinned {
|
||||||
.row.rowSelected:hover .cellPinned {
|
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
|
||||||
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowNumberHeaderInner {
|
.rowNumberHeaderInner {
|
||||||
@@ -575,13 +517,6 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerCell:focus-visible .rowNumberHeaderHash {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.headerCell:focus-visible .rowNumberHeaderCheckbox {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectionActionBarWrapper {
|
.selectionActionBarWrapper {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
@@ -41,17 +41,8 @@ export type SelectTypeOptions = {
|
|||||||
defaultValue?: string | string[] | null;
|
defaultValue?: string | string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NumberSeparatorStyle =
|
|
||||||
| 'none'
|
|
||||||
| 'local'
|
|
||||||
| 'comma_period'
|
|
||||||
| 'period_comma'
|
|
||||||
| 'space_comma'
|
|
||||||
| 'space_period';
|
|
||||||
|
|
||||||
export type NumberTypeOptions = {
|
export type NumberTypeOptions = {
|
||||||
format?: 'plain' | 'currency' | 'percent' | 'progress';
|
format?: 'plain' | 'separators' | 'currency' | 'percent' | 'progress';
|
||||||
separators?: NumberSeparatorStyle;
|
|
||||||
precision?: number;
|
precision?: number;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
currencySymbol?: string;
|
currencySymbol?: string;
|
||||||
@@ -235,7 +226,6 @@ export type IBaseView = {
|
|||||||
|
|
||||||
export type IBase = {
|
export type IBase = {
|
||||||
id: string;
|
id: string;
|
||||||
slugId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -247,21 +237,15 @@ export type IBase = {
|
|||||||
views: IBaseView[];
|
views: IBaseView[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
permissions?: {
|
/** Effective edit permission for the current user (page-restrictions included). */
|
||||||
canEdit: boolean;
|
canEdit?: boolean;
|
||||||
hasRestriction: boolean;
|
|
||||||
};
|
|
||||||
baseSchemaVersion: number;
|
baseSchemaVersion: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CellCoord = {
|
export type EditingCell = {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
propertyId: string;
|
propertyId: string;
|
||||||
};
|
} | null;
|
||||||
|
|
||||||
export type EditingCell = CellCoord | null;
|
|
||||||
|
|
||||||
export type FocusedCell = CellCoord | null;
|
|
||||||
|
|
||||||
export type CreateBaseInput = {
|
export type CreateBaseInput = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { customAlphabet } from "nanoid";
|
|
||||||
|
|
||||||
const baseIdSuffix = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 9);
|
|
||||||
|
|
||||||
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { CellCoord } from "@/ee/base/types/base.types";
|
|
||||||
|
|
||||||
export function computeNextCell(
|
|
||||||
rowIds: string[],
|
|
||||||
colIds: string[],
|
|
||||||
current: CellCoord,
|
|
||||||
rowDelta: number,
|
|
||||||
colDelta: number,
|
|
||||||
wrap: boolean,
|
|
||||||
): CellCoord | null {
|
|
||||||
const colIndex = colIds.indexOf(current.propertyId);
|
|
||||||
const rowIndex = rowIds.indexOf(current.rowId);
|
|
||||||
if (colIndex === -1 || rowIndex === -1) return null;
|
|
||||||
|
|
||||||
let nextCol = colIndex + colDelta;
|
|
||||||
let nextRow = rowIndex + rowDelta;
|
|
||||||
|
|
||||||
if (wrap) {
|
|
||||||
if (nextCol < 0) {
|
|
||||||
nextCol = colIds.length - 1;
|
|
||||||
nextRow -= 1;
|
|
||||||
} else if (nextCol >= colIds.length) {
|
|
||||||
nextCol = 0;
|
|
||||||
nextRow += 1;
|
|
||||||
}
|
|
||||||
} else if (nextCol < 0 || nextCol >= colIds.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextRow < 0 || nextRow >= rowIds.length) return null;
|
|
||||||
|
|
||||||
return { rowId: rowIds[nextRow], propertyId: colIds[nextCol] };
|
|
||||||
}
|
|
||||||
@@ -103,7 +103,7 @@ export default function BillingPlans() {
|
|||||||
label="Team size"
|
label="Team size"
|
||||||
description="Select the number of users"
|
description="Select the number of users"
|
||||||
value={selectedTierValue}
|
value={selectedTierValue}
|
||||||
onChange={(value) => setSelectedTierValue(value)}
|
onChange={setSelectedTierValue}
|
||||||
data={selectData}
|
data={selectData}
|
||||||
w={250}
|
w={250}
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
|
|||||||
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
|
|
||||||
interface ResolveCommentProps {
|
interface ResolveCommentProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -32,7 +31,7 @@ function ResolveComment({
|
|||||||
resolved: !isResolved,
|
resolved: !isResolved,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isEditorReady(editor)) {
|
if (editor) {
|
||||||
editor.commands.setCommentResolved(commentId, !isResolved);
|
editor.commands.setCommentResolved(commentId, !isResolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,5 @@ export const Feature = {
|
|||||||
SHARING_CONTROLS: 'sharing:controls',
|
SHARING_CONTROLS: 'sharing:controls',
|
||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
PERSONAL_SPACES: 'spaces:personal',
|
|
||||||
DOCX_EXPORT: 'export:docx',
|
|
||||||
BASES: 'bases',
|
BASES: 'bases',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ const enterpriseFeatures = [
|
|||||||
"Resolve Comments",
|
"Resolve Comments",
|
||||||
"Confluence Import",
|
"Confluence Import",
|
||||||
"PDF & DOCX Import",
|
"PDF & DOCX Import",
|
||||||
"Bases",
|
|
||||||
"Kanban",
|
|
||||||
"Templates",
|
"Templates",
|
||||||
"Personal Spaces"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OssDetails() {
|
export default function OssDetails() {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function MfaSetupModal({
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
<Collapse expanded={manualEntryOpen}>
|
<Collapse in={manualEntryOpen}>
|
||||||
<Alert
|
<Alert
|
||||||
icon={<IconAlertCircle size={20} />}
|
icon={<IconAlertCircle size={20} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export default function PdfRenderPage() {
|
|||||||
title={data.title}
|
title={data.title}
|
||||||
content={data.content}
|
content={data.content}
|
||||||
pageId={data.pageId}
|
pageId={data.pageId}
|
||||||
printMode
|
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
|
||||||
import { z } from "zod/v4";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
|
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().trim().min(2).max(100),
|
|
||||||
});
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const currentUser = useAtomValue(currentUserAtom);
|
|
||||||
const createMutation = useCreatePersonalSpaceMutation();
|
|
||||||
|
|
||||||
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
validate: zod4Resolver(formSchema),
|
|
||||||
initialValues: {
|
|
||||||
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: FormValues) => {
|
|
||||||
try {
|
|
||||||
const createdSpace = await createMutation.mutateAsync({
|
|
||||||
name: values.name,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
navigate(getSpaceUrl(createdSpace.slug));
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t("Create personal space")}
|
|
||||||
closeButtonProps={{ "aria-label": t("Close") }}
|
|
||||||
>
|
|
||||||
<Divider size="xs" mb="md" />
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
withAsterisk
|
|
||||||
data-autofocus
|
|
||||||
label={t("Space name")}
|
|
||||||
variant="filled"
|
|
||||||
errorProps={{ role: "alert" }}
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button type="submit" loading={createMutation.isPending}>
|
|
||||||
{t("Create")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
|
||||||
import { Feature } from "@/ee/features";
|
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
|
||||||
|
|
||||||
export default function PersonalSpacesSetting() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Allow personal spaces")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Members can create their own personal space.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PersonalSpacesToggle />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PersonalSpacesToggle() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
|
||||||
const [checked, setChecked] = useState(
|
|
||||||
workspace?.settings?.spaces?.allowPersonal === true,
|
|
||||||
);
|
|
||||||
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
|
||||||
const upgradeLabel = useUpgradeLabel();
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
const updatedWorkspace = await updateWorkspace({
|
|
||||||
allowPersonalSpaces: value,
|
|
||||||
});
|
|
||||||
setChecked(value);
|
|
||||||
setWorkspace(updatedWorkspace);
|
|
||||||
} catch (err) {
|
|
||||||
notifications.show({
|
|
||||||
message: err?.response?.data?.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!hasPersonalSpaces}
|
|
||||||
aria-label={t("Toggle allow personal spaces")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { ISpace } from "@/features/space/types/space.types";
|
|
||||||
import {
|
|
||||||
createPersonalSpace,
|
|
||||||
getPersonalSpace,
|
|
||||||
} from "@/ee/personal-space/services/personal-space-service";
|
|
||||||
|
|
||||||
export function usePersonalSpaceQuery(
|
|
||||||
enabled: boolean,
|
|
||||||
): UseQueryResult<ISpace | null, Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["personal-space"],
|
|
||||||
queryFn: () => getPersonalSpace(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePersonalSpaceMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<ISpace, Error, { name?: string }>({
|
|
||||||
mutationFn: (data) => createPersonalSpace(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["spaces"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import { ISpace } from "@/features/space/types/space.types";
|
|
||||||
|
|
||||||
export async function getPersonalSpace(): Promise<ISpace | null> {
|
|
||||||
const req = await api.post<ISpace | null>("/personal-space/info", {});
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPersonalSpace(data: {
|
|
||||||
name?: string;
|
|
||||||
}): Promise<ISpace> {
|
|
||||||
const req = await api.post<ISpace>("/personal-space/create", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
@@ -105,7 +105,7 @@ export default function TemplateEditor() {
|
|||||||
|
|
||||||
// Load template data into editor
|
// Load template data into editor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingTemplate && editor && !editor.isDestroyed) {
|
if (existingTemplate && editor) {
|
||||||
loadedRef.current = false;
|
loadedRef.current = false;
|
||||||
setTitle(existingTemplate.title || "");
|
setTitle(existingTemplate.title || "");
|
||||||
setIcon(existingTemplate.icon || null);
|
setIcon(existingTemplate.icon || null);
|
||||||
@@ -383,8 +383,7 @@ export default function TemplateEditor() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (editor && !editor.isDestroyed)
|
editor?.commands.focus("start");
|
||||||
editor.commands.focus("start");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
|||||||
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
import { useCreateCommentMutation } from "@/features/comment/queries/comment-query";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useEditor } from "@tiptap/react";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -49,14 +48,11 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
setReadOnlyCommentData(null);
|
setReadOnlyCommentData(null);
|
||||||
} else {
|
} else {
|
||||||
setShowCommentPopup(false);
|
setShowCommentPopup(false);
|
||||||
if (isEditorReady(editor)) {
|
editor.chain().focus().unsetCommentDecoration().run();
|
||||||
editor.chain().focus().unsetCommentDecoration().run();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedText = () => {
|
const getSelectedText = () => {
|
||||||
if (!isEditorReady(editor)) return "";
|
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
return editor.state.doc.textBetween(from, to);
|
return editor.state.doc.textBetween(from, to);
|
||||||
};
|
};
|
||||||
@@ -78,28 +74,24 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
|
|
||||||
const createdComment =
|
const createdComment =
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentMutation.mutateAsync(commentData);
|
||||||
if (isEditorReady(editor)) {
|
editor
|
||||||
editor
|
.chain()
|
||||||
.chain()
|
.setComment(createdComment.id)
|
||||||
.setComment(createdComment.id)
|
.unsetCommentDecoration()
|
||||||
.unsetCommentDecoration()
|
.run();
|
||||||
.run();
|
|
||||||
editor.commands.setTextSelection({
|
|
||||||
from: editor.view.state.selection.from,
|
|
||||||
to: editor.view.state.selection.from,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setActiveCommentId(createdComment.id);
|
setActiveCommentId(createdComment.id);
|
||||||
|
|
||||||
|
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||||
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||||
const commentElement = document.querySelector(selector);
|
const commentElement = document.querySelector(selector);
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
if (isEditorReady(editor)) {
|
editor.view.dispatch(
|
||||||
editor.view.dispatch(editor.state.tr.scrollIntoView());
|
editor.state.tr.scrollIntoView()
|
||||||
}
|
);
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -112,24 +112,22 @@ const CommentEditor = forwardRef(
|
|||||||
// websocket on another browser). Skip for editable editors to avoid
|
// websocket on another browser). Skip for editable editors to avoid
|
||||||
// resetting the cursor position on every keystroke.
|
// resetting the cursor position on every keystroke.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editable && commentEditor && !commentEditor.isDestroyed && defaultContent) {
|
if (!editable && commentEditor && defaultContent) {
|
||||||
commentEditor.commands.setContent(defaultContent);
|
commentEditor.commands.setContent(defaultContent);
|
||||||
}
|
}
|
||||||
}, [defaultContent, editable, commentEditor]);
|
}, [defaultContent, editable, commentEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (autofocus && commentEditor && !commentEditor.isDestroyed) {
|
if (autofocus) {
|
||||||
commentEditor.commands.focus("end");
|
commentEditor?.commands.focus("end");
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
}, [commentEditor, autofocus]);
|
}, [commentEditor, autofocus]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
clearContent: () => {
|
clearContent: () => {
|
||||||
if (commentEditor && !commentEditor.isDestroyed) {
|
commentEditor.commands.clearContent();
|
||||||
commentEditor.commands.clearContent();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useAtom, useAtomValue } from "jotai";
|
|||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import { isEditorReady } from "@docmost/editor-ext";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
@@ -76,9 +75,7 @@ function CommentListItem({
|
|||||||
async function handleDeleteComment() {
|
async function handleDeleteComment() {
|
||||||
try {
|
try {
|
||||||
await deleteCommentMutation.mutateAsync(comment.id);
|
await deleteCommentMutation.mutateAsync(comment.id);
|
||||||
if (isEditorReady(editor)) {
|
editor?.commands.unsetComment(comment.id);
|
||||||
editor.commands.unsetComment(comment.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete comment:", error);
|
console.error("Failed to delete comment:", error);
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,7 @@ function CommentListItem({
|
|||||||
resolved: !isResolved,
|
resolved: !isResolved,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isEditorReady(editor)) {
|
if (editor) {
|
||||||
editor.commands.setCommentResolved(comment.id, !isResolved);
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
||||||
import { ActionIcon, Box, Menu, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { BaseView } from "@/ee/base/components/base-view";
|
import { BaseView } from "@/ee/base/components/base-view";
|
||||||
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
|
||||||
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
import { useBaseQuery } from "@/ee/base/queries/base-query";
|
||||||
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
import { pinOffsetWatcher } from "@docmost/editor-ext";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import { IconDots, IconTable, IconX } from "@tabler/icons-react";
|
import { IconTable } from "@tabler/icons-react";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||||
import classes from "./base-embed.module.css";
|
import classes from "./base-embed.module.css";
|
||||||
|
|
||||||
@@ -50,13 +49,11 @@ function applyExtension(wrapper: HTMLDivElement) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
export function BaseEmbedView({ node, editor }: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const pageId = node.attrs.pageId as string | null;
|
const pageId = node.attrs.pageId as string | null;
|
||||||
const pendingKey = node.attrs.pendingKey as string | null;
|
const pendingKey = node.attrs.pendingKey as string | null;
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasBases = useHasFeature(Feature.BASES);
|
const hasBases = useHasFeature(Feature.BASES);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
// Suppress the query while the slash command awaits the server-assigned
|
// Suppress the query while the slash command awaits the server-assigned
|
||||||
// pageId; useBaseQuery would otherwise fire with an empty key.
|
// pageId; useBaseQuery would otherwise fire with an empty key.
|
||||||
const { data: base, isLoading, isError } = useBaseQuery(
|
const { data: base, isLoading, isError } = useBaseQuery(
|
||||||
@@ -98,8 +95,6 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
|||||||
// mounts) is reserved only for the skeleton/loading/table states.
|
// mounts) is reserved only for the skeleton/loading/table states.
|
||||||
const isCompact = !pendingKey && (!pageId || isError);
|
const isCompact = !pendingKey && (!pageId || isError);
|
||||||
|
|
||||||
const showControls = editor.isEditable && !pendingKey;
|
|
||||||
|
|
||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
if (pendingKey) {
|
if (pendingKey) {
|
||||||
// Slash command inserted the embed and is awaiting the server's
|
// Slash command inserted the embed and is awaiting the server's
|
||||||
@@ -124,7 +119,7 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
|||||||
} else if (isError) {
|
} else if (isError) {
|
||||||
content = (
|
content = (
|
||||||
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
|
<Box p="md" bg="gray.0" style={{ borderRadius: 8 }}>
|
||||||
<Text c="dimmed">You don't have access to this base.</Text>
|
<Text c="dimmed">You don't have access to this database.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -132,43 +127,13 @@ export function BaseEmbedView({ node, editor, deleteNode }: NodeViewProps) {
|
|||||||
<BaseView
|
<BaseView
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
embedded
|
embedded
|
||||||
editable={hasBases && editor.isEditable && (base?.permissions?.canEdit ?? false)}
|
editable={hasBases && editor.isEditable && (base?.canEdit ?? false)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper className={classes.handleGutter}>
|
||||||
className={classes.handleGutter}
|
|
||||||
data-menu-open={menuOpen ? "true" : "false"}
|
|
||||||
>
|
|
||||||
{showControls && (
|
|
||||||
<div
|
|
||||||
className={classes.controls}
|
|
||||||
contentEditable={false}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Menu position="bottom-end" withinPortal onChange={setMenuOpen}>
|
|
||||||
<Menu.Target>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("Base options")}
|
|
||||||
>
|
|
||||||
<IconDots size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconX size={14} />}
|
|
||||||
onClick={() => deleteNode()}
|
|
||||||
>
|
|
||||||
{t("Remove from page")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div data-drag-preview hidden className={classes.dragPreview}>
|
<div data-drag-preview hidden className={classes.dragPreview}>
|
||||||
<IconTable size={16} />
|
<IconTable size={16} />
|
||||||
<span>{page?.title?.trim() || "Untitled base"}</span>
|
<span>{page?.title?.trim() || "Untitled base"}</span>
|
||||||
|
|||||||
@@ -1,40 +1,8 @@
|
|||||||
.handleGutter {
|
.handleGutter {
|
||||||
position: relative;
|
|
||||||
margin-left: -1.5rem;
|
margin-left: -1.5rem;
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 4px);
|
|
||||||
right: 0;
|
|
||||||
z-index: 20;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.handleGutter:hover .controls,
|
|
||||||
.handleGutter[data-menu-open="true"] .controls {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.controls {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 48em) {
|
@media (max-width: 48em) {
|
||||||
.handleGutter {
|
.handleGutter {
|
||||||
margin-left: -1rem;
|
margin-left: -1rem;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user