mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 10:26:25 +08:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
InputBase,
|
||||
Input,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DateFilterValue,
|
||||
FilterOperator,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import {
|
||||
DATE_ANCHOR_PRESETS,
|
||||
DATE_RANGE_PRESETS,
|
||||
ANCHOR_VALUES,
|
||||
RANGE_VALUES,
|
||||
} from "./relative-date-presets";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type FilterDateInputProps = {
|
||||
op: FilterOperator;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
type Mode = "exact" | "relative";
|
||||
|
||||
const ANCHOR_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DATE_ANCHOR_PRESETS.map((p) => [p.value, p.labelKey]),
|
||||
);
|
||||
const RANGE_LABEL: Record<string, string> = Object.fromEntries(
|
||||
DATE_RANGE_PRESETS.map((p) => [p.value, p.labelKey]),
|
||||
);
|
||||
|
||||
function asDateValue(value: unknown): DateFilterValue | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return value as DateFilterValue;
|
||||
}
|
||||
|
||||
function toISODate(d: string | null): string | null {
|
||||
if (!d) return null;
|
||||
// Already a date-only ISO string (Mantine v8 emits these) — pass through to
|
||||
// avoid a UTC-parse + local-getter round-trip that shifts the day west of UTC.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function FilterDateInput({ op, value, onChange }: FilterDateInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const current = asDateValue(value);
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [localMode, setLocalMode] = useState<Mode>("exact");
|
||||
|
||||
const exactDate = current?.mode === "exact" ? toISODate(current.date) : null;
|
||||
const anchor =
|
||||
current?.mode === "relative" && ANCHOR_VALUES.has(current.preset)
|
||||
? current.preset
|
||||
: null;
|
||||
const range =
|
||||
current?.mode === "range" && RANGE_VALUES.has(current.preset)
|
||||
? current.preset
|
||||
: null;
|
||||
|
||||
const valueMode: Mode | null =
|
||||
current?.mode === "relative"
|
||||
? "relative"
|
||||
: current?.mode === "exact"
|
||||
? "exact"
|
||||
: null;
|
||||
const mode: Mode = valueMode ?? localMode;
|
||||
|
||||
let triggerLabel: string | null = null;
|
||||
if (op === "isWithin") triggerLabel = range ? t(RANGE_LABEL[range]) : null;
|
||||
else if (exactDate) triggerLabel = exactDate;
|
||||
else if (anchor) triggerLabel = t(ANCHOR_LABEL[anchor]);
|
||||
|
||||
// Consume Escape locally so the outer filter popover (bubble handler) keeps
|
||||
// the panel open and only this picker closes.
|
||||
const handleEscape = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpened(false);
|
||||
}
|
||||
};
|
||||
|
||||
const presetRow = (
|
||||
selected: boolean,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
key: string,
|
||||
) => (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
selected && cellClasses.selectOptionActive,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={cellClasses.personOptionName}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width={op === "isWithin" ? 200 : "auto"}
|
||||
withinPortal={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<Popover.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
pointer
|
||||
w={170}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={handleEscape}
|
||||
>
|
||||
{triggerLabel ?? <Input.Placeholder>{t("Select")}</Input.Placeholder>}
|
||||
</InputBase>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={op === "isWithin" ? 0 : "xs"} onKeyDown={handleEscape}>
|
||||
{op === "isWithin" ? (
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{DATE_RANGE_PRESETS.map((p) =>
|
||||
presetRow(
|
||||
range === p.value,
|
||||
t(p.labelKey),
|
||||
() => {
|
||||
onChange({ mode: "range", preset: p.value });
|
||||
setOpened(false);
|
||||
},
|
||||
p.value,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
size="xs"
|
||||
mb="xs"
|
||||
value={mode}
|
||||
onChange={(m) => {
|
||||
setLocalMode(m as Mode);
|
||||
onChange(undefined);
|
||||
}}
|
||||
data={[
|
||||
{ value: "exact", label: t("Date") },
|
||||
{ value: "relative", label: t("Relative") },
|
||||
]}
|
||||
/>
|
||||
{mode === "exact" ? (
|
||||
<DatePicker
|
||||
value={exactDate}
|
||||
onChange={(d) => {
|
||||
const iso = toISODate(d);
|
||||
onChange(iso ? { mode: "exact", date: iso } : undefined);
|
||||
setOpened(false);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{DATE_ANCHOR_PRESETS.map((p) =>
|
||||
presetRow(
|
||||
anchor === p.value,
|
||||
t(p.labelKey),
|
||||
() => {
|
||||
onChange({ mode: "relative", preset: p.value });
|
||||
setOpened(false);
|
||||
},
|
||||
p.value,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Popover, InputBase, Input } from "@mantine/core";
|
||||
import { IconX, IconChevronDown } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
usePersonSearch,
|
||||
type PersonSuggestion,
|
||||
} from "@/ee/base/hooks/use-person-search";
|
||||
import {
|
||||
useReferenceStore,
|
||||
useHydrateUsers,
|
||||
} from "@/ee/base/reference/reference-store";
|
||||
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type FilterPersonInputProps = {
|
||||
pageId: string;
|
||||
multiple: boolean;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
placeholder: string;
|
||||
label?: string;
|
||||
w?: number | string;
|
||||
portalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
function toIds(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => !!v);
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function FilterPersonInput({
|
||||
pageId,
|
||||
multiple,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
w,
|
||||
portalTarget,
|
||||
}: FilterPersonInputProps) {
|
||||
const ids = toIds(value);
|
||||
const selectedSet = new Set(ids);
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const store = useReferenceStore(pageId);
|
||||
const hydrateUsers = useHydrateUsers(pageId);
|
||||
const suggestions = usePersonSearch(search, opened);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) requestAnimationFrame(() => searchRef.current?.focus());
|
||||
else setSearch("");
|
||||
}, [opened]);
|
||||
|
||||
const filtered: PersonSuggestion[] = multiple
|
||||
? suggestions.filter((s) => !selectedSet.has(s.id))
|
||||
: suggestions;
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(filtered.length, [search, opened]);
|
||||
|
||||
const emit = useCallback(
|
||||
(nextIds: string[]) => {
|
||||
if (multiple) onChange(nextIds.length > 0 ? nextIds : undefined);
|
||||
else onChange(nextIds[0] ?? undefined);
|
||||
},
|
||||
[multiple, onChange],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
const picked = suggestions.find((s) => s.id === id);
|
||||
if (picked)
|
||||
hydrateUsers([
|
||||
{ id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
|
||||
]);
|
||||
if (multiple) {
|
||||
emit(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
|
||||
} else {
|
||||
emit([id]);
|
||||
setOpened(false);
|
||||
}
|
||||
setSearch("");
|
||||
},
|
||||
[suggestions, hydrateUsers, multiple, ids, emit],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => emit(ids.filter((x) => x !== id)),
|
||||
[emit, ids],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpened(false);
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex < 0 || activeIndex >= filtered.length) return;
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[activeIndex].id);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && search === "" && ids.length > 0) {
|
||||
e.preventDefault();
|
||||
handleRemove(ids[ids.length - 1]);
|
||||
}
|
||||
},
|
||||
[handleNavKey, activeIndex, filtered, handleSelect, search, ids, handleRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width={260}
|
||||
withinPortal={!!portalTarget}
|
||||
portalProps={{ target: portalTarget ?? undefined }}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
>
|
||||
<Popover.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
pointer
|
||||
multiline
|
||||
w={w ?? 170}
|
||||
label={label}
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
{ids.length === 0 ? (
|
||||
<Input.Placeholder>{placeholder}</Input.Placeholder>
|
||||
) : (
|
||||
<span className={cellClasses.filterTriggerChips}>
|
||||
{ids.map((id) => {
|
||||
const user = store.users[id];
|
||||
const name = user?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<span key={id} className={cellClasses.filterTriggerChip}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={16}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.filterTriggerChipName}>
|
||||
{name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</InputBase>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<div className={cellClasses.personTagArea}>
|
||||
{multiple &&
|
||||
ids.map((id) => {
|
||||
const user = store.users[id];
|
||||
const name = user?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<span key={id} className={cellClasses.personTag}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={18}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.personTagName}>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cellClasses.personTagRemove}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(id);
|
||||
}}
|
||||
>
|
||||
<IconX size={10} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={cellClasses.personTagInput}
|
||||
placeholder="Find a user..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className={cellClasses.personDropdownDivider} />
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filtered.map((member, idx) => (
|
||||
<div
|
||||
key={member.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
selectedSet.has(member.id) && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onClick={() => handleSelect(member.id)}
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={member.avatarUrl ?? ""}
|
||||
name={member.name ?? ""}
|
||||
size={24}
|
||||
radius="xl"
|
||||
/>
|
||||
<div className={cellClasses.personOptionText}>
|
||||
<span className={cellClasses.personOptionName}>
|
||||
{member.name ?? ""}
|
||||
</span>
|
||||
{member.email && (
|
||||
<span className={cellClasses.personOptionEmail}>
|
||||
{member.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className={cellClasses.personDropdownHint}>No users found</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
DateFilterAnchor,
|
||||
DateFilterRange,
|
||||
} from "@/ee/base/types/base.types";
|
||||
|
||||
export const DATE_ANCHOR_PRESETS: { value: DateFilterAnchor; labelKey: string }[] =
|
||||
[
|
||||
{ value: "today", labelKey: "Today" },
|
||||
{ value: "tomorrow", labelKey: "Tomorrow" },
|
||||
{ value: "yesterday", labelKey: "Yesterday" },
|
||||
{ value: "oneWeekAgo", labelKey: "One week ago" },
|
||||
{ value: "oneWeekFromNow", labelKey: "One week from now" },
|
||||
{ value: "oneMonthAgo", labelKey: "One month ago" },
|
||||
{ value: "oneMonthFromNow", labelKey: "One month from now" },
|
||||
];
|
||||
|
||||
export const DATE_RANGE_PRESETS: { value: DateFilterRange; labelKey: string }[] =
|
||||
[
|
||||
{ value: "pastWeek", labelKey: "Past week" },
|
||||
{ value: "pastMonth", labelKey: "Past month" },
|
||||
{ value: "pastYear", labelKey: "Past year" },
|
||||
{ value: "thisWeek", labelKey: "This week" },
|
||||
{ value: "thisMonth", labelKey: "This month" },
|
||||
{ value: "thisYear", labelKey: "This year" },
|
||||
{ value: "nextWeek", labelKey: "Next week" },
|
||||
{ value: "nextMonth", labelKey: "Next month" },
|
||||
{ value: "nextYear", labelKey: "Next year" },
|
||||
];
|
||||
|
||||
export const ANCHOR_VALUES = new Set<string>(
|
||||
DATE_ANCHOR_PRESETS.map((p) => p.value),
|
||||
);
|
||||
export const RANGE_VALUES = new Set<string>(
|
||||
DATE_RANGE_PRESETS.map((p) => p.value),
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBase } from "@/ee/base/types/base.types";
|
||||
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
|
||||
type Panel = "types" | "groupBy";
|
||||
|
||||
type ViewCreateMenuProps = {
|
||||
base: IBase;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [panel, setPanel] = useState<Panel>("types");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
const [, setActiveViewId] = useAtom(
|
||||
activeViewIdAtomFamily(pageId),
|
||||
) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
const groupable = base.properties.filter(
|
||||
(p) => p.type === "select" || p.type === "status",
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpened(false);
|
||||
setPanel("types");
|
||||
}, []);
|
||||
|
||||
const submitView = useCallback(
|
||||
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
|
||||
createViewMutation.mutate(
|
||||
{ pageId, ...input },
|
||||
{ onSuccess: (created) => setActiveViewId(created.id) },
|
||||
);
|
||||
close();
|
||||
},
|
||||
[pageId, createViewMutation, setActiveViewId, close],
|
||||
);
|
||||
|
||||
const handleCreateTable = useCallback(() => {
|
||||
submitView({ name: t("Table"), type: "table" });
|
||||
}, [submitView, t]);
|
||||
|
||||
const handleBoardClick = useCallback(() => {
|
||||
if (groupable.length <= 1) {
|
||||
const config =
|
||||
groupable.length === 1
|
||||
? { groupByPropertyId: groupable[0].id }
|
||||
: undefined;
|
||||
submitView({ name: t("Kanban"), type: "kanban", config });
|
||||
} else {
|
||||
setPanel("groupBy");
|
||||
}
|
||||
}, [groupable, submitView, t]);
|
||||
|
||||
const handleGroupByPick = useCallback(
|
||||
(propertyId: string) => {
|
||||
submitView({
|
||||
name: t("Kanban"),
|
||||
type: "kanban",
|
||||
config: { groupByPropertyId: propertyId },
|
||||
});
|
||||
},
|
||||
[submitView, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dropdownRef.current
|
||||
?.querySelector<HTMLElement>("[data-menu-item]:not([data-disabled])")
|
||||
?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [panel]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
setOpened(o);
|
||||
if (!o) setPanel("types");
|
||||
}}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={200}
|
||||
withinPortal
|
||||
closeOnItemClick={false}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Add view")}>
|
||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add view")}>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown ref={dropdownRef}>
|
||||
{panel === "types" && (
|
||||
<>
|
||||
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
|
||||
{t("Table")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
|
||||
{t("Kanban")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{panel === "groupBy" && (
|
||||
<>
|
||||
<Menu.Item leftSection={<IconArrowLeft size={14} />} onClick={() => setPanel("types")}>
|
||||
{t("Group by")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{groupable.map((p) => {
|
||||
const Icon = getDescriptor(p.type)?.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={p.id}
|
||||
leftSection={Icon ? <Icon size={14} /> : undefined}
|
||||
onClick={() => handleGroupByPick(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
FilterCondition,
|
||||
FilterOperator,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getDescriptor,
|
||||
DEFAULT_FILTER_OPERATORS,
|
||||
} from "@/ee/base/property-types/property-type.registry";
|
||||
import { FilterPersonInput } from "./filter-person-input";
|
||||
import { FilterDateInput } from "./filter-date-input";
|
||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||
|
||||
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
|
||||
{ value: "eq", labelKey: "Is" },
|
||||
{ value: "neq", labelKey: "Is not" },
|
||||
{ value: "contains", labelKey: "Contains" },
|
||||
{ value: "ncontains", labelKey: "Doesn't contain" },
|
||||
{ value: "any", labelKey: "Is any of" },
|
||||
{ value: "none", labelKey: "Is none of" },
|
||||
{ value: "before", labelKey: "Is before" },
|
||||
{ value: "after", labelKey: "Is after" },
|
||||
{ value: "onOrBefore", labelKey: "Is on or before" },
|
||||
{ value: "onOrAfter", labelKey: "Is on or after" },
|
||||
{ value: "isWithin", labelKey: "Is within" },
|
||||
{ value: "gt", labelKey: "Greater than" },
|
||||
{ value: "lt", labelKey: "Less than" },
|
||||
{ value: "isEmpty", labelKey: "Is empty" },
|
||||
{ value: "isNotEmpty", labelKey: "Is not empty" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
|
||||
// Two operators share a value control only if they share a value class.
|
||||
// Switching across classes (e.g. eq→any, exact-date→isWithin) must reset the
|
||||
// stored value so a stale shape isn't sent to the engine.
|
||||
function valueClass(op: FilterOperator, inputKind: string): string {
|
||||
if (NO_VALUE_OPERATORS.includes(op)) return "none";
|
||||
if (inputKind === "person") {
|
||||
return op === "any" || op === "none" ? "personMulti" : "personSingle";
|
||||
}
|
||||
if (inputKind === "date") {
|
||||
return op === "isWithin" ? "dateRange" : "dateInstant";
|
||||
}
|
||||
return "scalar";
|
||||
}
|
||||
|
||||
function inputKindForProperty(property: IBaseProperty | undefined): string {
|
||||
return getDescriptor(property?.type ?? "")?.filterInput ?? "text";
|
||||
}
|
||||
|
||||
function getOperatorsForType(type: string): FilterOperator[] {
|
||||
return (getDescriptor(type)?.filterOperators ??
|
||||
DEFAULT_FILTER_OPERATORS) as FilterOperator[];
|
||||
}
|
||||
|
||||
function FilterValueInput({
|
||||
condition,
|
||||
property,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
condition: FilterCondition;
|
||||
property: IBaseProperty | undefined;
|
||||
onChange: (value: unknown) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (!property) {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const kind = getDescriptor(property.type)?.filterInput ?? "text";
|
||||
|
||||
if (kind === "person") {
|
||||
return (
|
||||
<FilterPersonInput
|
||||
pageId={property.pageId}
|
||||
multiple={condition.op === "any" || condition.op === "none"}
|
||||
value={condition.value}
|
||||
onChange={onChange}
|
||||
placeholder={t("Select")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "date") {
|
||||
return (
|
||||
<FilterDateInput
|
||||
op={condition.op}
|
||||
value={condition.value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "choices") {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={choiceOptions}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={120}
|
||||
placeholder={t("Select")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "number") {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "boolean") {
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={[
|
||||
{ value: "true", label: t("True") },
|
||||
{ value: "false", label: t("False") },
|
||||
]}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
conditions: FilterCondition[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (conditions: FilterCondition[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFilterConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
conditions,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
}: ViewFilterConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const propertyOptions = properties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const [draft, setDraft] = useState<FilterCondition | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) setDraft(null);
|
||||
}, [opened]);
|
||||
|
||||
const handleStartDraft = useCallback(() => {
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
const validOperators = getOperatorsForType(firstProperty.type);
|
||||
const defaultOperator = validOperators.includes("contains")
|
||||
? ("contains" as FilterOperator)
|
||||
: validOperators[0];
|
||||
setDraft({ propertyId: firstProperty.id, op: defaultOperator });
|
||||
}, [properties]);
|
||||
|
||||
const handleSaveDraft = useCallback(() => {
|
||||
if (!draft) return;
|
||||
onChange([...conditions, draft]);
|
||||
setDraft(null);
|
||||
}, [draft, conditions, onChange]);
|
||||
|
||||
const handleCancelDraft = useCallback(() => {
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleDraftPropertyChange = useCallback(
|
||||
(propertyId: string | null) => {
|
||||
if (!propertyId || !draft) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
if (!newProperty) {
|
||||
setDraft({ ...draft, propertyId });
|
||||
return;
|
||||
}
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(draft.op);
|
||||
const sameKind =
|
||||
inputKindForProperty(
|
||||
properties.find((p) => p.id === draft.propertyId),
|
||||
) === inputKindForProperty(newProperty);
|
||||
setDraft({
|
||||
...draft,
|
||||
propertyId,
|
||||
op: currentOperatorValid ? draft.op : validOperators[0],
|
||||
value: currentOperatorValid && sameKind ? draft.value : undefined,
|
||||
});
|
||||
},
|
||||
[draft, properties],
|
||||
);
|
||||
|
||||
const handleDraftOperatorChange = useCallback(
|
||||
(operator: string | null) => {
|
||||
if (!operator || !draft) return;
|
||||
const op = operator as FilterOperator;
|
||||
const kind = inputKindForProperty(
|
||||
properties.find((p) => p.id === draft.propertyId),
|
||||
);
|
||||
const keep = valueClass(draft.op, kind) === valueClass(op, kind);
|
||||
setDraft({ ...draft, op, value: keep ? draft.value : undefined });
|
||||
},
|
||||
[draft, properties],
|
||||
);
|
||||
|
||||
const handleDraftValueChange = useCallback(
|
||||
(value: unknown) => {
|
||||
if (!draft) return;
|
||||
setDraft({ ...draft, value });
|
||||
},
|
||||
[draft],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(conditions.filter((_, i) => i !== index));
|
||||
},
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
onChange(
|
||||
conditions.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
if (newProperty) {
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(f.op);
|
||||
const sameKind =
|
||||
inputKindForProperty(
|
||||
properties.find((p) => p.id === f.propertyId),
|
||||
) === inputKindForProperty(newProperty);
|
||||
return {
|
||||
...f,
|
||||
propertyId,
|
||||
op: currentOperatorValid ? f.op : validOperators[0],
|
||||
value: currentOperatorValid && sameKind ? f.value : undefined,
|
||||
};
|
||||
}
|
||||
return { ...f, propertyId };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[conditions, properties, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(index: number, operator: string | null) => {
|
||||
if (!operator) return;
|
||||
const op = operator as FilterOperator;
|
||||
onChange(
|
||||
conditions.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
const kind = inputKindForProperty(
|
||||
properties.find((p) => p.id === f.propertyId),
|
||||
);
|
||||
const keep = valueClass(f.op, kind) === valueClass(op, kind);
|
||||
return { ...f, op, value: keep ? f.value : undefined };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[conditions, properties, onChange],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(index: number, value: unknown) => {
|
||||
onChange(
|
||||
conditions.map((f, i) => (i === index ? { ...f, value } : f)),
|
||||
);
|
||||
},
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={520}
|
||||
trapFocus
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
onKeyDown={(e) => {
|
||||
// Mantine's built-in closeOnEscape uses a capture-phase handler that
|
||||
// would fire before a nested picker can consume Escape, closing the
|
||||
// whole panel. Handle it on bubble instead so an open inner picker
|
||||
// (which preventDefaults Escape) keeps the panel open.
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Filter by")}
|
||||
</Text>
|
||||
|
||||
{conditions.length === 0 && !draft && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No filters applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{conditions.map((condition, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
|
||||
const property = properties.find(
|
||||
(p) => p.id === condition.propertyId,
|
||||
);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={propertyOptions}
|
||||
searchable
|
||||
openOnFocus={false}
|
||||
nothingFoundMessage={t("No match")}
|
||||
value={condition.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={operatorOptions}
|
||||
searchable
|
||||
openOnFocus={false}
|
||||
nothingFoundMessage={t("No match")}
|
||||
value={condition.op}
|
||||
onChange={(val) => handleOperatorChange(index, val)}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
condition={condition}
|
||||
property={property}
|
||||
onChange={(val) => handleValueChange(index, val)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
|
||||
{draft && (() => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
|
||||
const property = properties.find((p) => p.id === draft.propertyId);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({ value: op.value, label: t(op.labelKey) }));
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={propertyOptions}
|
||||
searchable
|
||||
openOnFocus={false}
|
||||
nothingFoundMessage={t("No match")}
|
||||
value={draft.propertyId}
|
||||
onChange={handleDraftPropertyChange}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={operatorOptions}
|
||||
searchable
|
||||
openOnFocus={false}
|
||||
nothingFoundMessage={t("No match")}
|
||||
value={draft.op}
|
||||
onChange={handleDraftOperatorChange}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
condition={draft}
|
||||
property={property}
|
||||
onChange={handleDraftValueChange}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={handleCancelDraft}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleSaveDraft}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!draft && (
|
||||
<UnstyledButton
|
||||
onClick={handleStartDraft}
|
||||
className={viewClasses.addActionButton}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{t("Add filter")}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||
|
||||
type ViewPropertyVisibilityProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
onPersist: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewPropertyVisibility({
|
||||
opened,
|
||||
onClose,
|
||||
table,
|
||||
properties,
|
||||
onPersist,
|
||||
children,
|
||||
}: ViewPropertyVisibilityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return table
|
||||
.getAllLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number");
|
||||
}, [table, properties]);
|
||||
|
||||
const allVisible = columns.every((col) => col.getIsVisible());
|
||||
const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(columnId: string, visible: boolean) => {
|
||||
const col = table.getColumn(columnId);
|
||||
if (!col) return;
|
||||
col.toggleVisibility(visible);
|
||||
onPersist();
|
||||
},
|
||||
[table, onPersist],
|
||||
);
|
||||
|
||||
const handleShowAll = useCallback(() => {
|
||||
columns.forEach((col) => {
|
||||
if (col.getCanHide()) {
|
||||
col.toggleVisibility(true);
|
||||
}
|
||||
});
|
||||
onPersist();
|
||||
}, [columns, onPersist]);
|
||||
|
||||
const handleHideAll = useCallback(() => {
|
||||
columns.forEach((col) => {
|
||||
if (col.getCanHide()) {
|
||||
col.toggleVisibility(false);
|
||||
}
|
||||
});
|
||||
onPersist();
|
||||
}, [columns, onPersist]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" px={4} py={2}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Properties")}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
<UnstyledButton
|
||||
onClick={handleShowAll}
|
||||
disabled={allVisible}
|
||||
style={{ opacity: allVisible ? 0.4 : 1 }}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t("Show all")}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={handleHideAll}
|
||||
disabled={noneVisible}
|
||||
style={{ opacity: noneVisible ? 0.4 : 1 }}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t("Hide all")}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap={0}>
|
||||
{columns.map((col) => {
|
||||
const property = col.columnDef.meta?.property as IBaseProperty | undefined;
|
||||
if (!property) return null;
|
||||
|
||||
const canHide = col.getCanHide();
|
||||
const isVisible = col.getIsVisible();
|
||||
const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
|
||||
const TypeIcon = typeConfig?.icon;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={col.id}
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
if (canHide) {
|
||||
handleToggle(col.id, !isVisible);
|
||||
}
|
||||
}}
|
||||
style={{ opacity: canHide ? 1 : 0.5 }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
|
||||
<Text size="sm" className={viewClasses.fieldNameText}>
|
||||
{property.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={isVisible}
|
||||
disabled={!canHide}
|
||||
onChange={() => {}}
|
||||
// Clicking the track synthesizes a second click on the hidden input which bubbles
|
||||
// to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
|
||||
// synthetic input click so handleToggle fires exactly once.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
FilterGroup,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { BaseTable } from "@/ee/base/components/base-table";
|
||||
import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
|
||||
|
||||
type ViewRendererProps = {
|
||||
base: IBase;
|
||||
rows: IBaseRow[];
|
||||
effectiveView: IBaseView | undefined;
|
||||
table: Table<IBaseRow>;
|
||||
pageId: string;
|
||||
embedded?: boolean;
|
||||
editable: boolean;
|
||||
isFiltered: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onFetchNextPage: () => void;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow: () => void;
|
||||
onColumnReorder: (columnId: string, finishIndex: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
onRowReorder: (
|
||||
rowId: string,
|
||||
targetRowId: string,
|
||||
dropPosition: "above" | "below",
|
||||
) => void;
|
||||
persistViewConfig: () => void;
|
||||
scrollportRef: React.RefObject<HTMLDivElement>;
|
||||
aboveBand?: React.ReactNode;
|
||||
kanbanFilter?: FilterGroup | undefined;
|
||||
};
|
||||
|
||||
export function ViewRenderer(props: ViewRendererProps) {
|
||||
const viewType = props.effectiveView?.type ?? "table";
|
||||
|
||||
if (viewType === "kanban") {
|
||||
return (
|
||||
<BaseKanban
|
||||
base={props.base}
|
||||
view={props.effectiveView!}
|
||||
pageId={props.pageId}
|
||||
embedded={props.embedded}
|
||||
editable={props.editable}
|
||||
viewFilter={props.kanbanFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === "table") {
|
||||
return <BaseTable {...props} />;
|
||||
}
|
||||
|
||||
return <BaseTable {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
ViewSortConfig,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import viewClasses from "@/ee/base/styles/views.module.css";
|
||||
|
||||
type ViewSortConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
sorts: ViewSortConfig[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (sorts: ViewSortConfig[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewSortConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
sorts,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
}: ViewSortConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) setDraft(null);
|
||||
}, [opened]);
|
||||
|
||||
// Page props sort by raw UUID; hide until title-based sort is supported.
|
||||
const sortableProperties = properties.filter((p) => p.type !== "page");
|
||||
|
||||
const propertyOptions = sortableProperties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const directionOptions = [
|
||||
{ value: "asc", label: t("Ascending") },
|
||||
{ value: "desc", label: t("Descending") },
|
||||
];
|
||||
|
||||
const handleStartDraft = useCallback(() => {
|
||||
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
||||
const available = sortableProperties.find((p) => !usedIds.has(p.id));
|
||||
if (!available) return;
|
||||
setDraft({ propertyId: available.id, direction: "asc" });
|
||||
}, [sorts, sortableProperties]);
|
||||
|
||||
const handleSaveDraft = useCallback(() => {
|
||||
if (!draft) return;
|
||||
onChange([...sorts, draft]);
|
||||
setDraft(null);
|
||||
}, [draft, sorts, onChange]);
|
||||
|
||||
const handleCancelDraft = useCallback(() => {
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(sorts.filter((_, i) => i !== index));
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
onChange(
|
||||
sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
|
||||
);
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const handleDirectionChange = useCallback(
|
||||
(index: number, direction: string | null) => {
|
||||
if (!direction) return;
|
||||
onChange(
|
||||
sorts.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, direction: direction as "asc" | "desc" }
|
||||
: s,
|
||||
),
|
||||
);
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const canAddMore =
|
||||
sortableProperties.length > sorts.length + (draft ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={340}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Sort by")}
|
||||
</Text>
|
||||
|
||||
{sorts.length === 0 && !draft && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No sorts applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sorts.map((sort, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={propertyOptions}
|
||||
value={sort.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={directionOptions}
|
||||
value={sort.direction}
|
||||
onChange={(val) => handleDirectionChange(index, val)}
|
||||
w={110}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{draft && (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={propertyOptions}
|
||||
value={draft.propertyId}
|
||||
onChange={(val) =>
|
||||
val && setDraft({ ...draft, propertyId: val })
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={directionOptions}
|
||||
value={draft.direction}
|
||||
onChange={(val) =>
|
||||
val &&
|
||||
setDraft({
|
||||
...draft,
|
||||
direction: val as "asc" | "desc",
|
||||
})
|
||||
}
|
||||
w={110}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={handleCancelDraft}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleSaveDraft}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!draft && canAddMore && (
|
||||
<UnstyledButton
|
||||
onClick={handleStartDraft}
|
||||
className={viewClasses.addActionButton}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{t("Add sort")}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
TextInput,
|
||||
Popover,
|
||||
Stack,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconTable,
|
||||
IconLink,
|
||||
IconLayoutKanban,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { IBase, IBaseView } from "@/ee/base/types/base.types";
|
||||
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
|
||||
import {
|
||||
useUpdateViewMutation,
|
||||
useDeleteViewMutation,
|
||||
} from "@/ee/base/queries/base-view-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||
|
||||
const VIEW_DRAG_TYPE = "base-view";
|
||||
|
||||
type ViewTabsProps = {
|
||||
views: IBaseView[];
|
||||
activeViewId: string | undefined;
|
||||
pageId: string;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
base?: IBase;
|
||||
canAddView?: boolean;
|
||||
/** Standalone base-page link for a view, used by "Copy link to view". */
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
};
|
||||
|
||||
export function ViewTabs({
|
||||
views,
|
||||
activeViewId,
|
||||
pageId,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
base,
|
||||
canAddView,
|
||||
getViewShareUrl,
|
||||
}: ViewTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const editable = useBaseEditable();
|
||||
const [editingViewId, setEditingViewId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const deleteViewMutation = useDeleteViewMutation();
|
||||
|
||||
const orderedViews = useMemo(
|
||||
() =>
|
||||
[...views].sort((a, b) =>
|
||||
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||
),
|
||||
[views],
|
||||
);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(sourceId: string, targetId: string, edge: Edge) => {
|
||||
if (sourceId === targetId) return;
|
||||
const remaining = orderedViews.filter((v) => v.id !== sourceId);
|
||||
const targetIndex = remaining.findIndex((v) => v.id === targetId);
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
let lowerPos: string | null = null;
|
||||
let upperPos: string | null = null;
|
||||
if (edge === "left") {
|
||||
lowerPos =
|
||||
targetIndex > 0 ? remaining[targetIndex - 1]?.position : null;
|
||||
upperPos = remaining[targetIndex]?.position ?? null;
|
||||
} else {
|
||||
lowerPos = remaining[targetIndex]?.position ?? null;
|
||||
upperPos =
|
||||
targetIndex < remaining.length - 1
|
||||
? remaining[targetIndex + 1]?.position
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
const position =
|
||||
lowerPos && upperPos && lowerPos === upperPos
|
||||
? generateJitteredKeyBetween(lowerPos, null)
|
||||
: generateJitteredKeyBetween(lowerPos, upperPos);
|
||||
updateViewMutation.mutate({ viewId: sourceId, pageId, position });
|
||||
} catch {
|
||||
// Position computation failed; skip the reorder.
|
||||
}
|
||||
},
|
||||
[orderedViews, pageId, updateViewMutation],
|
||||
);
|
||||
|
||||
const handleRenameStart = useCallback(
|
||||
(view: IBaseView) => {
|
||||
setEditingViewId(view.id);
|
||||
setEditingName(view.name);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenameCommit = useCallback(() => {
|
||||
if (!editingViewId) return;
|
||||
const trimmed = editingName.trim();
|
||||
const view = views.find((v) => v.id === editingViewId);
|
||||
if (trimmed && view && trimmed !== view.name) {
|
||||
updateViewMutation.mutate({
|
||||
viewId: editingViewId,
|
||||
pageId,
|
||||
name: trimmed,
|
||||
});
|
||||
}
|
||||
setEditingViewId(null);
|
||||
}, [editingViewId, editingName, views, pageId, updateViewMutation]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameCommit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditingViewId(null);
|
||||
}
|
||||
},
|
||||
[handleRenameCommit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(viewId: string) => {
|
||||
if (orderedViews.length <= 1) return;
|
||||
deleteViewMutation.mutate({ viewId, pageId });
|
||||
if (viewId === activeViewId) {
|
||||
const remaining = orderedViews.filter((v) => v.id !== viewId);
|
||||
onViewChange(remaining[0].id);
|
||||
}
|
||||
},
|
||||
[orderedViews, pageId, activeViewId, deleteViewMutation, onViewChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{orderedViews.map((view) => (
|
||||
<ViewTab
|
||||
key={view.id}
|
||||
view={view}
|
||||
isActive={view.id === activeViewId}
|
||||
isEditing={view.id === editingViewId}
|
||||
editingName={editingName}
|
||||
canDelete={orderedViews.length > 1}
|
||||
reorderEnabled={editable && orderedViews.length > 1}
|
||||
onReorder={handleReorder}
|
||||
onClick={() => onViewChange(view.id)}
|
||||
onRenameStart={() => handleRenameStart(view)}
|
||||
onRenameChange={setEditingName}
|
||||
onRenameCommit={handleRenameCommit}
|
||||
onRenameKeyDown={handleRenameKeyDown}
|
||||
onDelete={() => handleDelete(view.id)}
|
||||
getViewShareUrl={getViewShareUrl}
|
||||
/>
|
||||
))}
|
||||
{canAddView && base && (
|
||||
<ViewCreateMenu base={base} pageId={pageId} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewTab({
|
||||
view,
|
||||
isActive,
|
||||
isEditing,
|
||||
editingName,
|
||||
canDelete,
|
||||
reorderEnabled,
|
||||
onReorder,
|
||||
onClick,
|
||||
onRenameStart,
|
||||
onRenameChange,
|
||||
onRenameCommit,
|
||||
onRenameKeyDown,
|
||||
onDelete,
|
||||
getViewShareUrl,
|
||||
}: {
|
||||
view: IBaseView;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
editingName: string;
|
||||
canDelete: boolean;
|
||||
reorderEnabled: boolean;
|
||||
onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
|
||||
onClick: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameChange: (name: string) => void;
|
||||
onRenameCommit: () => void;
|
||||
onRenameKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onDelete: () => void;
|
||||
getViewShareUrl?: (viewId: string) => string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const editable = useBaseEditable();
|
||||
const tabRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const onReorderRef = useRef(onReorder);
|
||||
useLayoutEffect(() => {
|
||||
onReorderRef.current = onReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabRef.current;
|
||||
if (!el || !reorderEnabled || isEditing) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({ type: VIEW_DRAG_TYPE, viewId: view.id }),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === VIEW_DRAG_TYPE &&
|
||||
source.data.viewId !== view.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ viewId: view.id },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onReorderRef.current(source.data.viewId as string, view.id, edge);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [view.id, reorderEnabled, isEditing]);
|
||||
|
||||
const handleTabClick = useCallback(() => {
|
||||
if (isActive) {
|
||||
setMenuOpened((o) => !o);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}, [isActive, onClick]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
setMenuOpened(false);
|
||||
const url = getViewShareUrl?.(view.id);
|
||||
if (!url) return;
|
||||
void navigator.clipboard.writeText(url);
|
||||
notifications.show({ message: t("Link copied to clipboard") });
|
||||
}, [getViewShareUrl, view.id, t]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={120}
|
||||
value={editingName}
|
||||
onChange={(e) => onRenameChange(e.currentTarget.value)}
|
||||
onBlur={onRenameCommit}
|
||||
onKeyDown={onRenameKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tabRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={180}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={handleTabClick}
|
||||
style={{
|
||||
padding: "2px 10px",
|
||||
borderRadius: "var(--mantine-radius-xl)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
backgroundColor: isActive
|
||||
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{view.type === "kanban" ? (
|
||||
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
|
||||
) : (
|
||||
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
|
||||
)}
|
||||
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
|
||||
{view.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Stack gap={0}>
|
||||
{editable && (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onRenameStart();
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconPencil size={14} />
|
||||
<Text size="sm">{t("Rename")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{getViewShareUrl && (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconLink size={14} />
|
||||
<Text size="sm">{t("Copy link to view")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{editable && canDelete && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onDelete();
|
||||
}}
|
||||
style={{ color: "var(--mantine-color-red-6)" }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconTrash size={14} />
|
||||
<Text size="sm">{t("Delete view")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user