feat: bases - WIP

This commit is contained in:
Philipinho
2026-03-08 00:56:24 +00:00
parent 0aeaa43112
commit 94ee1e80fb
83 changed files with 9243 additions and 38 deletions
@@ -0,0 +1,198 @@
import { useCallback, useEffect, useMemo } from "react";
import { Loader, Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { arrayMove } from "@dnd-kit/sortable";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import {
useBaseRowsQuery,
flattenRows,
} from "@/features/base/queries/base-row-query";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import classes from "@/features/base/styles/grid.module.css";
type BaseTableProps = {
baseId: string;
};
export function BaseTable({ baseId }: BaseTableProps) {
const { t } = useTranslation();
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBaseRowsQuery(baseId);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createPropertyMutation = useCreatePropertyMutation();
const createViewMutation = useCreateViewMutation();
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
const views = base?.views ?? [];
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
return flat.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
}, [rowsData]);
const { table, persistViewConfig } = useBaseTable(base, rows, activeView);
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
baseId,
cells: { [propertyId]: value },
});
},
[baseId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ baseId });
}, [baseId, createRowMutation]);
const handleAddColumn = useCallback(() => {
createPropertyMutation.mutate({
baseId,
name: t("New property"),
type: "text",
});
}, [baseId, createPropertyMutation, t]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
baseId,
name: t("New view"),
type: "table",
});
}, [baseId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(activeId: string, overId: string) => {
const currentOrder = table.getState().columnOrder;
const oldIndex = currentOrder.indexOf(activeId);
const newIndex = currentOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
table.setColumnOrder(newOrder);
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos = targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos = targetIndex < remainingRows.length - 1 ? remainingRows[targetIndex + 1]?.position : null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRowMutation.mutate({
rowId,
baseId,
position: newPosition,
});
} catch {
// Position computation failed — skip silently
}
},
[rows, baseId, reorderRowMutation],
);
if (baseLoading || rowsLoading) {
return (
<div className={classes.loadingOverlay}>
<Loader size="md" />
</div>
);
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<BaseToolbar
base={base}
activeView={activeView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
/>
<GridContainer
table={table}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onAddColumn={handleAddColumn}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
/>
</div>
);
}
@@ -0,0 +1,214 @@
import { useState, useCallback, useMemo } from "react";
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
import { Table } from "@tanstack/react-table";
import {
IconSortAscending,
IconFilter,
IconEye,
} from "@tabler/icons-react";
import {
IBase,
IBaseRow,
IBaseView,
ViewSortConfig,
ViewFilterConfig,
} from "@/features/base/types/base.types";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import { ViewTabs } from "@/features/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config";
import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type BaseToolbarProps = {
base: IBase;
activeView: IBaseView | undefined;
views: IBaseView[];
table: Table<IBaseRow>;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
onPersistViewConfig: () => void;
};
export function BaseToolbar({
base,
activeView,
views,
table,
onViewChange,
onAddView,
onPersistViewConfig,
}: BaseToolbarProps) {
const { t } = useTranslation();
const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false);
const [fieldsOpened, setFieldsOpened] = useState(false);
const openToolbar = useCallback((panel: "sort" | "filter" | "fields") => {
setSortOpened(panel === "sort" ? (v) => !v : false);
setFilterOpened(panel === "filter" ? (v) => !v : false);
setFieldsOpened(panel === "fields" ? (v) => !v : false);
}, []);
const updateViewMutation = useUpdateViewMutation();
const sorts = activeView?.config?.sorts ?? [];
const filters = activeView?.config?.filters ?? [];
const hiddenFieldCount = useMemo(() => {
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table.getState().columnVisibility]);
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
if (!activeView) return;
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config: { ...activeView.config, sorts: newSorts },
});
},
[activeView, base.id, updateViewMutation],
);
const handleFiltersChange = useCallback(
(newFilters: ViewFilterConfig[]) => {
if (!activeView) return;
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config: { ...activeView.config, filters: newFilters },
});
},
[activeView, base.id, updateViewMutation],
);
return (
<div className={classes.toolbar}>
<ViewTabs
views={views}
activeViewId={activeView?.id}
baseId={base.id}
onViewChange={onViewChange}
onAddView={onAddView}
/>
<div className={classes.toolbarRight}>
<ViewFilterConfigPopover
opened={filterOpened}
onClose={() => setFilterOpened(false)}
filters={filters}
properties={base.properties}
onChange={handleFiltersChange}
>
<Tooltip label={t("Filter")}>
<ActionIcon
variant="subtle"
size="sm"
color={filters.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("filter")}
>
<IconFilter size={16} />
{filters.length > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{filters.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFilterConfigPopover>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
<ViewFieldVisibility
opened={fieldsOpened}
onClose={() => setFieldsOpened(false)}
table={table}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide fields")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenFieldCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("fields")}
>
<IconEye size={16} />
{hiddenFieldCount > 0 && (
<Badge
size="xs"
circle
color="blue"
style={{
position: "absolute",
top: -2,
right: -2,
padding: 0,
width: 14,
height: 14,
minWidth: 14,
fontSize: 9,
}}
>
{hiddenFieldCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewFieldVisibility>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useCallback } from "react";
import { Checkbox } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCheckboxProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellCheckbox({
value,
onCommit,
}: CellCheckboxProps) {
const checked = value === true;
const handleChange = useCallback(() => {
onCommit(!checked);
}, [checked, onCommit]);
return (
<div className={cellClasses.checkboxCell} onClick={handleChange}>
<Checkbox
checked={checked}
onChange={() => {}}
size="xs"
tabIndex={-1}
styles={{ input: { cursor: "pointer", pointerEvents: "none" } }}
/>
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellCreatedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,141 @@
import { useCallback } from "react";
import { Popover } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import {
IBaseProperty,
DateTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellDateProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatDateDisplay(
dateStr: string | null | undefined,
options: DateTypeOptions | undefined,
): string {
if (!dateStr) return "";
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "";
const months = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
let result = `${month} ${day}, ${year}`;
if (options?.includeTime) {
if (options.timeFormat === "24h") {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes}`;
} else {
let hours = date.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
result += ` ${hours}:${minutes} ${ampm}`;
}
}
return result;
} catch {
return "";
}
}
function toISODateString(dateStr: string | null): string | null {
if (!dateStr) return null;
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch {
return null;
}
}
export function CellDate({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellDateProps) {
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
const dateStr = typeof value === "string" ? value : null;
const pickerValue = toISODateString(dateStr);
const handleChange = useCallback(
(selected: string | null) => {
if (selected) {
const date = new Date(selected);
onCommit(date.toISOString());
} else {
onCommit(null);
}
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width="auto"
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
</div>
</Popover.Target>
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
<DatePicker
value={pickerValue}
onChange={handleChange}
size="sm"
/>
</Popover.Dropdown>
</Popover>
);
}
if (!dateStr) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.dateValue}>
{formatDateDisplay(dateStr, typeOptions)}
</span>
);
}
@@ -0,0 +1,90 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellEmailProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellEmail({
value,
isEditing,
onCommit,
onCancel,
}: CellEmailProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="email"
className={cellClasses.cellInput}
value={draft}
placeholder="email@example.com"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -0,0 +1,47 @@
import { IconPaperclip } from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type FileValue = {
id: string;
name: string;
url?: string;
size?: number;
};
type CellFileProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellFile({
value,
}: CellFileProps) {
const files = Array.isArray(value) ? (value as FileValue[]) : [];
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
const MAX_VISIBLE = 2;
const visible = files.slice(0, MAX_VISIBLE);
const overflow = files.length - MAX_VISIBLE;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
<IconPaperclip size={12} />
{file.name}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellLastEditedAtProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(value);
if (!formatted) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={cellClasses.dateValue}>{formatted}</span>;
}
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import { Group } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellLastEditedByProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellLastEditedBy({ value }: CellLastEditedByProps) {
const userId = typeof value === "string" ? value : null;
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const user = useMemo(() => {
if (!userId || !membersData?.items) return null;
return membersData.items.find((u) => u.id === userId) ?? null;
}, [userId, membersData?.items]);
if (!userId) {
return <span className={cellClasses.emptyValue} />;
}
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar
avatarUrl={user?.avatarUrl ?? ""}
name={user?.name ?? ""}
size={20}
radius="xl"
/>
{user?.name && (
<span
style={{
fontSize: "var(--mantine-font-size-sm)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.name}
</span>
)}
</Group>
);
}
@@ -0,0 +1,152 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellMultiSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellMultiSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellMultiSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedSet = new Set(selectedIds);
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: choices;
const handleToggle = useCallback(
(choice: Choice) => {
const newIds = selectedSet.has(choice.id)
? selectedIds.filter((id) => id !== choice.id)
: [...selectedIds, choice.id];
onCommit(newIds);
},
[selectedIds, selectedSet, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 3;
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice) => (
<div
key={choice.id}
className={`${cellClasses.selectOption} ${
selectedSet.has(choice.id)
? cellClasses.selectOptionActive
: ""
}`}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (selectedChoices.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
return <BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />;
}
function BadgeList({
choices,
maxVisible,
}: {
choices: Choice[];
maxVisible: number;
}) {
const visible = choices.slice(0, maxVisible);
const overflow = choices.length - maxVisible;
return (
<div className={cellClasses.badgeGroup}>
{visible.map((choice) => (
<span
key={choice.id}
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState, useRef, useEffect, useCallback } from "react";
import {
IBaseProperty,
NumberTypeOptions,
} from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellNumberProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function formatNumber(
val: number | null | undefined,
options: NumberTypeOptions | undefined,
): string {
if (val == null) return "";
const precision = options?.precision ?? 0;
const format = options?.format ?? "plain";
switch (format) {
case "currency":
return `${options?.currencySymbol ?? "$"}${val.toFixed(precision)}`;
case "percent":
return `${val.toFixed(precision)}%`;
case "progress":
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
default:
return precision > 0 ? val.toFixed(precision) : String(val);
}
}
export function CellNumber({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellNumberProps) {
const numValue = typeof value === "number" ? value : null;
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const [draft, setDraft] = useState(numValue != null ? String(numValue) : "");
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(numValue != null ? String(numValue) : "");
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, numValue]);
const parseDraft = useCallback(() => {
const parsed = draft === "" ? null : Number(draft);
return parsed != null && isNaN(parsed) ? null : parsed;
}, [draft]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(parseDraft());
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[parseDraft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(parseDraft());
}, [parseDraft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
inputMode="decimal"
className={cellClasses.cellInput}
style={{ textAlign: "right" }}
value={draft}
onChange={(e) => {
const v = e.target.value;
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
setDraft(v);
}
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (numValue == null) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span className={cellClasses.numberValue}>
{formatNumber(numValue, typeOptions)}
</span>
);
}
@@ -0,0 +1,46 @@
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellPersonProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
function getInitials(id: string): string {
return id.substring(0, 2).toUpperCase();
}
export function CellPerson({
value,
}: CellPersonProps) {
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
const MAX_VISIBLE = 4;
const visible = personIds.slice(0, MAX_VISIBLE);
const overflow = personIds.length - MAX_VISIBLE;
return (
<div className={cellClasses.personGroup}>
{visible.map((id) => (
<div key={id} className={cellClasses.personAvatar}>
{getInitials(id)}
</div>
))}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div>
);
}
@@ -0,0 +1,133 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellSelectProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellSelect({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellSelectProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const filteredChoices = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredChoices.map((choice) => (
<div
key={choice.id}
className={`${cellClasses.selectOption} ${
choice.id === selectedId ? cellClasses.selectOptionActive : ""
}`}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -0,0 +1,170 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellStatusProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
type CategoryGroup = {
label: string;
choices: Choice[];
};
const categoryLabels: Record<string, string> = {
todo: "To Do",
inProgress: "In Progress",
complete: "Complete",
};
export function CellStatus({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellStatusProps) {
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const selectedChoice = choices.find((c) => c.id === selectedId);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
const groups = useMemo(() => {
const filtered = search
? choices.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()),
)
: choices;
const grouped: Record<string, Choice[]> = {};
for (const choice of filtered) {
const cat = choice.category ?? "todo";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(choice);
}
const result: CategoryGroup[] = [];
for (const key of ["todo", "inProgress", "complete"]) {
if (grouped[key]?.length) {
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
}
}
return result;
}, [choices, search]);
const handleSelect = useCallback(
(choice: Choice) => {
onCommit(choice.id === selectedId ? null : choice.id);
},
[selectedId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={220}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
{selectedChoice ? (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
) : (
<span className={cellClasses.emptyValue} />
)}
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{groups.map((group) => (
<div key={group.label}>
<div className={cellClasses.selectCategoryLabel}>
{group.label}
</div>
{group.choices.map((choice) => (
<div
key={choice.id}
className={`${cellClasses.selectOption} ${
choice.id === selectedId
? cellClasses.selectOptionActive
: ""
}`}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
))}
</div>
))}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (!selectedChoice) {
return <span className={cellClasses.emptyValue} />;
}
return (
<span
className={cellClasses.badge}
style={choiceColor(selectedChoice.color)}
>
{selectedChoice.name}
</span>
);
}
@@ -0,0 +1,82 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import gridClasses from "@/features/base/styles/grid.module.css";
type CellTextProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellText({
value,
isEditing,
onCommit,
onCancel,
}: CellTextProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
className={cellClasses.cellInput}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return <span className={gridClasses.cellContent}>{displayValue}</span>;
}
@@ -0,0 +1,92 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellUrlProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
export function CellUrl({
value,
isEditing,
onCommit,
onCancel,
}: CellUrlProps) {
const displayValue = typeof value === "string" ? value : "";
const [draft, setDraft] = useState(displayValue);
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
useEffect(() => {
if (isEditing) {
committedRef.current = false;
setDraft(displayValue);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
}, [isEditing, displayValue]);
const commitOnce = useCallback(
(val: unknown) => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(val);
},
[onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commitOnce(draft || null);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[draft, commitOnce, onCancel],
);
const handleBlur = useCallback(() => {
commitOnce(draft || null);
}, [draft, commitOnce]);
if (isEditing) {
return (
<input
ref={inputRef}
type="url"
className={cellClasses.cellInput}
value={draft}
placeholder="https://..."
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
);
}
if (!displayValue) {
return <span className={cellClasses.emptyValue} />;
}
return (
<a
className={cellClasses.urlLink}
href={displayValue}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{displayValue}
</a>
);
}
@@ -0,0 +1,25 @@
import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1971c2", textDark: "#74c0fc" },
cyan: { bg: "#c3fae8", bgDark: "#1a3a3a", text: "#0c8599", textDark: "#66d9e8" },
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#087f5b", textDark: "#63e6be" },
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#2b8a3e", textDark: "#69db7c" },
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#5c940d", textDark: "#a9e34b" },
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#e67700", textDark: "#ffd43b" },
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#d9480f", textDark: "#ffa94d" },
};
export function choiceColor(color: string): CSSProperties {
const c = colorMap[color] ?? colorMap.gray;
return {
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
color: `light-dark(${c.text}, ${c.textDark})`,
};
}
@@ -0,0 +1,26 @@
import { memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/grid.module.css";
type AddRowButtonProps = {
onClick?: () => void;
};
export const AddRowButton = memo(function AddRowButton({
onClick,
}: AddRowButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addRowButton}
onClick={onClick}
role="button"
tabIndex={0}
>
<IconPlus size={14} />
<span>{t("New row")}</span>
</div>
);
});
@@ -0,0 +1,148 @@
import { memo, useCallback } from "react";
import { Cell } from "@tanstack/react-table";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import { CellText } from "@/features/base/components/cells/cell-text";
import { CellNumber } from "@/features/base/components/cells/cell-number";
import { CellSelect } from "@/features/base/components/cells/cell-select";
import { CellStatus } from "@/features/base/components/cells/cell-status";
import { CellMultiSelect } from "@/features/base/components/cells/cell-multi-select";
import { CellDate } from "@/features/base/components/cells/cell-date";
import { CellCheckbox } from "@/features/base/components/cells/cell-checkbox";
import { CellUrl } from "@/features/base/components/cells/cell-url";
import { CellEmail } from "@/features/base/components/cells/cell-email";
import { CellPerson } from "@/features/base/components/cells/cell-person";
import { CellFile } from "@/features/base/components/cells/cell-file";
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
import classes from "@/features/base/styles/grid.module.css";
type CellComponentProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
const cellComponents: Record<
string,
React.ComponentType<CellComponentProps>
> = {
text: CellText,
number: CellNumber,
select: CellSelect,
status: CellStatus,
multiSelect: CellMultiSelect,
date: CellDate,
checkbox: CellCheckbox,
url: CellUrl,
email: CellEmail,
person: CellPerson,
file: CellFile,
createdAt: CellCreatedAt,
lastEditedAt: CellLastEditedAt,
lastEditedBy: CellLastEditedBy,
};
type RowDragProps = {
draggable: boolean;
onDragStart: (e: React.DragEvent) => void;
};
type GridCellProps = {
cell: Cell<IBaseRow, unknown>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
rowDragProps?: RowDragProps;
};
export const GridCell = memo(function GridCell({
cell,
rowIndex,
onCellUpdate,
rowDragProps,
}: GridCellProps) {
const property = cell.column.columnDef.meta?.property;
const isRowNumber = cell.column.id === "__row_number";
const isPinned = cell.column.getIsPinned();
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const rowId = cell.row.id;
const isEditing =
editingCell?.rowId === rowId &&
editingCell?.propertyId === property?.id;
const handleDoubleClick = useCallback(() => {
if (!property || isRowNumber) return;
if (property.type === "checkbox") return;
if (isSystemPropertyType(property.type)) return;
setEditingCell({ rowId, propertyId: property.id });
}, [property, isRowNumber, rowId, setEditingCell]);
const handleCommit = useCallback(
(value: unknown) => {
if (!property) return;
const currentValue = cell.getValue();
const hasChanged = value !== currentValue
&& !(value === "" && (currentValue === null || currentValue === undefined))
&& !(value === null && (currentValue === null || currentValue === undefined));
if (hasChanged) {
onCellUpdate(rowId, property.id, value);
}
setEditingCell(null);
},
[property, rowId, cell, onCellUpdate, setEditingCell],
);
const handleCancel = useCallback(() => {
setEditingCell(null);
}, [setEditingCell]);
if (isRowNumber) {
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${rowDragProps ? classes.rowNumberDraggable : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
}}
draggable={rowDragProps?.draggable}
onDragStart={rowDragProps?.onDragStart}
>
{rowIndex + 1}
</div>
);
}
if (!property) return null;
const CellComponent = cellComponents[property.type];
if (!CellComponent) return null;
const value = cell.getValue();
return (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
}}
onDoubleClick={handleDoubleClick}
>
<CellComponent
value={value}
property={property}
rowId={rowId}
isEditing={isEditing}
onCommit={handleCommit}
onCancel={handleCancel}
/>
</div>
);
});
@@ -0,0 +1,239 @@
import { useRef, useMemo, useCallback, useEffect } from "react";
import { Table } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36;
const OVERSCAN = 10;
type GridContainerProps = {
table: Table<IBaseRow>;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void;
onAddColumn?: () => void;
onColumnReorder?: (columnId: string, overColumnId: string) => void;
onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
};
export function GridContainer({
table,
onCellUpdate,
onAddRow,
onAddColumn,
onColumnReorder,
onResizeEnd,
onRowReorder,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
}: GridContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
propertyMenuDirtyRef.current = propertyMenuDirty;
const closeRequestCounterRef = useRef(0);
useEffect(() => {
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(`.${classes.headerCell}`)) return;
if (target.closest("[role=\"dialog\"]")) return;
if (target.closest("[role=\"listbox\"]")) return;
if (target.closest("[data-mantine-shared-portal-node]")) return;
if (target.closest(`.${classes.cellEditing}`)) return;
if (propertyMenuDirtyRef.current) {
closeRequestCounterRef.current += 1;
setCloseRequest(closeRequestCounterRef.current);
} else {
setActivePropertyMenu(null);
}
setEditingCell(null);
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
useColumnResize(table, onResizeEnd ?? (() => {}));
useGridKeyboardNav({
table,
editingCell,
setEditingCell,
containerRef: scrollRef,
});
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
});
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index >= rows.length - OVERSCAN * 2) {
onFetchNextPage();
}
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
const gridTemplateColumns = useMemo(() => {
const visibleColumns = table.getVisibleLeafColumns();
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
}, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
const totalHeight = virtualizer.getTotalSize();
const paddingTop =
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
const paddingBottom =
virtualItems.length > 0
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
: 0;
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, position: "above" | "below") => {
onRowReorder?.(rowId, targetRowId, position);
},
[onRowReorder],
);
const {
dragState: rowDragState,
handleDragStart: handleRowDragStart,
handleDragOver: handleRowDragOver,
handleDragEnd: handleRowDragEnd,
handleDragLeave: handleRowDragLeave,
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
const handleAddRow = useCallback(() => {
onAddRow?.();
}, [onAddRow]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor),
);
const sortableColumnIds = useMemo(() => {
return table
.getVisibleLeafColumns()
.filter((col) => col.id !== "__row_number")
.map((col) => col.id);
}, [table, table.getState().columnOrder]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onColumnReorder?.(active.id as string, over.id as string);
},
[onColumnReorder],
);
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<div
className={classes.gridWrapper}
ref={scrollRef}
tabIndex={0}
>
<div
className={classes.grid}
style={{ gridTemplateColumns }}
role="grid"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
<GridHeader table={table} onAddColumn={onAddColumn} />
</SortableContext>
{paddingTop > 0 && (
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
)}
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<GridRow
key={row.id}
row={row}
rowIndex={virtualRow.index}
onCellUpdate={onCellUpdate}
dragHandlers={
onRowReorder
? {
onDragStart: handleRowDragStart,
onDragOver: handleRowDragOver,
onDragEnd: handleRowDragEnd,
onDragLeave: handleRowDragLeave,
isDragging: rowDragState.dragRowId === row.id,
isDropTarget: rowDragState.dropTargetRowId === row.id,
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
}
: undefined
}
/>
);
})}
{paddingBottom > 0 && (
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
)}
<AddRowButton onClick={handleAddRow} />
</div>
</div>
</DndContext>
);
}
@@ -0,0 +1,189 @@
import { memo, useCallback, useRef } from "react";
import { Header, flexRender } from "@tanstack/react-table";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Popover } from "@mantine/core";
import { useAtom } from "jotai";
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { activePropertyMenuAtom, propertyMenuDirtyAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
} from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
import classes from "@/features/base/styles/grid.module.css";
const typeIcons: Record<string, typeof IconLetterT> = {
text: IconLetterT,
number: IconHash,
select: IconCircleDot,
status: IconProgressCheck,
multiSelect: IconTags,
date: IconCalendar,
person: IconUser,
file: IconPaperclip,
checkbox: IconCheckbox,
url: IconLink,
email: IconMail,
createdAt: IconClockPlus,
lastEditedAt: IconClockEdit,
lastEditedBy: IconUserEdit,
};
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
};
export const GridHeaderCell = memo(function GridHeaderCell({
header,
}: GridHeaderCellProps) {
const property = header.column.columnDef.meta?.property as
| IBaseProperty
| undefined;
const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned();
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
const menuOpened = activePropertyMenu === header.column.id;
const cellRef = useRef<HTMLDivElement>(null);
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || isPinned === "left";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: header.column.id,
disabled: isSortableDisabled,
});
const combinedRef = useCallback(
(node: HTMLDivElement | null) => {
setNodeRef(node);
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[setNodeRef],
);
const handleHeaderClick = useCallback(() => {
setEditingCell(null);
if (!isRowNumber && property && !isDragging) {
if (propertyMenuDirty && !menuOpened) return;
setActivePropertyMenu(menuOpened ? null : header.column.id);
}
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
const handleMenuClose = useCallback(() => {
setActivePropertyMenu(null);
}, [setActivePropertyMenu]);
const TypeIcon = property ? typeIcons[property.type] : undefined;
const sortableStyle = transform
? {
transform: CSS.Transform.toString({
...transform,
scaleX: 1,
scaleY: 1,
}),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}
: {};
return (
<div
ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle,
}}
onClick={handleHeaderClick}
{...(isSortableDisabled ? {} : attributes)}
{...(isSortableDisabled ? {} : listeners)}
>
{isRowNumber ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<div className={classes.headerCellContent}>
{TypeIcon && (
<TypeIcon size={14} className={classes.headerTypeIcon} />
)}
<span className={classes.headerCellName}>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
)}
{header.column.getCanResize() && (
<div
className={`${classes.resizeHandle} ${
header.column.getIsResizing() ? classes.resizeHandleActive : ""
}`}
onMouseDown={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onTouchStart={(e) => {
e.stopPropagation();
header.getResizeHandler()(e);
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
)}
{property && !isRowNumber && (
<Popover
opened={menuOpened}
onClose={handleMenuClose}
position="bottom-start"
shadow="md"
width={260}
withinPortal
closeOnClickOutside={false}
>
<Popover.Target>
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
</Popover.Target>
<Popover.Dropdown
p={0}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<PropertyMenuContent
property={property}
opened={menuOpened}
onClose={handleMenuClose}
onDirtyChange={handleDirtyChange}
/>
</Popover.Dropdown>
</Popover>
)}
</div>
);
});
@@ -0,0 +1,40 @@
import { memo, useCallback } from "react";
import { Table } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
import { GridHeaderCell } from "./grid-header-cell";
import { IconPlus } from "@tabler/icons-react";
import classes from "@/features/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
onAddColumn?: () => void;
};
export const GridHeader = memo(function GridHeader({
table,
onAddColumn,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
const handleAddColumn = useCallback(() => {
onAddColumn?.();
}, [onAddColumn]);
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell key={header.id} header={header} />
))}
{onAddColumn && (
<div
className={classes.addColumnButton}
onClick={handleAddColumn}
role="button"
tabIndex={0}
>
<IconPlus size={16} />
</div>
)}
</div>
);
});
@@ -0,0 +1,84 @@
import { memo, useCallback } from "react";
import { Row } from "@tanstack/react-table";
import { IBaseRow } from "@/features/base/types/base.types";
import { GridCell } from "./grid-cell";
import classes from "@/features/base/styles/grid.module.css";
type RowDragHandlers = {
onDragStart: (rowId: string) => void;
onDragOver: (rowId: string, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragLeave: () => void;
isDragging: boolean;
isDropTarget: boolean;
dropPosition: "above" | "below" | null;
};
type GridRowProps = {
row: Row<IBaseRow>;
rowIndex: number;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
dragHandlers?: RowDragHandlers;
};
export const GridRow = memo(function GridRow({
row,
rowIndex,
onCellUpdate,
dragHandlers,
}: GridRowProps) {
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", row.id);
dragHandlers?.onDragStart(row.id);
},
[row.id, dragHandlers],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
dragHandlers?.onDragOver(row.id, e);
},
[row.id, dragHandlers],
);
const dropIndicatorClass = dragHandlers?.isDropTarget
? dragHandlers.dropPosition === "above"
? classes.rowDropAbove
: classes.rowDropBelow
: "";
return (
<div
className={`${classes.row} ${dragHandlers?.isDragging ? classes.rowDragging : ""} ${dropIndicatorClass}`}
role="row"
onDragOver={handleDragOver}
onDrop={(e) => {
e.preventDefault();
dragHandlers?.onDragEnd();
}}
onDragLeave={dragHandlers?.onDragLeave}
>
{row.getVisibleCells().map((cell) => {
const isRowNumber = cell.column.id === "__row_number";
return (
<GridCell
key={cell.id}
cell={cell}
rowIndex={rowIndex}
onCellUpdate={onCellUpdate}
rowDragProps={
isRowNumber && dragHandlers
? {
draggable: true,
onDragStart: handleDragStart,
}
: undefined
}
/>
);
})}
</div>
);
});
@@ -0,0 +1,528 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import {
TextInput,
Group,
Stack,
Text,
Button,
Popover,
SimpleGrid,
UnstyledButton,
CloseButton,
Divider,
} from "@mantine/core";
import {
IconPlus,
IconGripVertical,
IconArrowsSort,
} from "@tabler/icons-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { Choice } from "@/features/base/types/base.types";
import { choiceColor } from "@/features/base/components/cells/choice-color";
import { useTranslation } from "react-i18next";
import { v7 as uuid7 } from "uuid";
const CHOICE_COLORS = [
"gray", "red", "pink", "grape", "violet", "indigo",
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
];
const STATUS_CATEGORIES = [
{ value: "todo", label: "To Do" },
{ value: "inProgress", label: "In Progress" },
{ value: "complete", label: "Complete" },
] as const;
type ChoiceEditorProps = {
initialChoices: Choice[];
onSave: (choices: Choice[]) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
showCategories?: boolean;
};
export function ChoiceEditor({
initialChoices,
onSave,
onClose,
onDirtyChange,
showCategories = false,
}: ChoiceEditorProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Choice[]>(initialChoices);
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
useEffect(() => {
setDraft(initialChoices);
}, [initialChoices]);
const isDirty = useMemo(() => {
if (draft.length !== initialChoices.length) return true;
return draft.some((d, i) => {
const o = initialChoices[i];
return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
});
}, [draft, initialChoices]);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const hasEmptyNames = draft.some((c) => !c.name.trim());
const handleRename = useCallback((choiceId: string, name: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
}, []);
const handleColorChange = useCallback((choiceId: string, color: string) => {
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
}, []);
const handleRemove = useCallback((choiceId: string) => {
setDraft((prev) => prev.filter((c) => c.id !== choiceId));
}, []);
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
const id = uuid7();
setDraft((prev) => {
const colorIndex = prev.length % CHOICE_COLORS.length;
const newChoice: Choice = {
id,
name: "",
color: CHOICE_COLORS[colorIndex],
...(category ? { category } : {}),
};
return [...prev, newChoice];
});
setFocusChoiceId(id);
}, []);
const handleAlphabetize = useCallback(() => {
setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
}, []);
const handleSave = useCallback(() => {
const cleaned = draft.filter((c) => c.name.trim());
onSave(cleaned);
onClose();
}, [draft, onSave, onClose]);
const handleCancel = useCallback(() => {
setDraft(initialChoices);
onDirtyChange?.(false);
onClose();
}, [initialChoices, onDirtyChange, onClose]);
const handleReorder = useCallback((activeId: string, overId: string) => {
setDraft((prev) => {
const oldIndex = prev.findIndex((c) => c.id === activeId);
const newIndex = prev.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleCategoryReorder = useCallback(
(category: string, activeId: string, overId: string) => {
setDraft((prev) => {
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
const oldIndex = catChoices.findIndex((c) => c.id === activeId);
const newIndex = catChoices.findIndex((c) => c.id === overId);
if (oldIndex === -1 || newIndex === -1) return prev;
const reordered = arrayMove(catChoices, oldIndex, newIndex);
const result: Choice[] = [];
for (const cat of ["todo", "inProgress", "complete"]) {
if (cat === category) {
result.push(...reordered);
} else {
result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
}
}
return result;
});
},
[],
);
return (
<Stack gap="xs">
<Group justify="space-between">
<Text size="xs" fw={600}>
{t("Options")}
</Text>
<UnstyledButton onClick={handleAlphabetize} style={{ display: "flex", alignItems: "center", gap: 4 }}>
<IconArrowsSort size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Alphabetize")}</Text>
</UnstyledButton>
</Group>
{showCategories ? (
<StatusChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onCategoryReorder={handleCategoryReorder}
/>
) : (
<FlatChoiceList
draft={draft}
focusChoiceId={focusChoiceId}
onFocused={() => setFocusChoiceId(null)}
onRename={handleRename}
onColorChange={handleColorChange}
onRemove={handleRemove}
onAdd={handleAdd}
onReorder={handleReorder}
/>
)}
<Divider />
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancel}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
{t("Save")}
</Button>
</Group>
</Stack>
);
}
function FlatChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: () => void;
onReorder: (activeId: string, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(active.id as string, over.id as string);
},
[onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{draft.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd()}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function StatusChoiceList({
draft,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onCategoryReorder,
}: {
draft: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onCategoryReorder: (category: string, activeId: string, overId: string) => void;
}) {
const grouped = useMemo(() => {
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
for (const choice of draft) {
const cat = choice.category ?? "todo";
(groups[cat] ?? groups.todo).push(choice);
}
return groups;
}, [draft]);
return (
<Stack gap="sm">
{STATUS_CATEGORIES.map(({ value: category, label }) => (
<CategorySection
key={category}
category={category as "todo" | "inProgress" | "complete"}
label={label}
choices={grouped[category] ?? []}
focusChoiceId={focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
onAdd={onAdd}
onReorder={onCategoryReorder}
/>
))}
</Stack>
);
}
function CategorySection({
category,
label,
choices,
focusChoiceId,
onFocused,
onRename,
onColorChange,
onRemove,
onAdd,
onReorder,
}: {
category: "todo" | "inProgress" | "complete";
label: string;
choices: Choice[];
focusChoiceId: string | null;
onFocused: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
onAdd: (category: "todo" | "inProgress" | "complete") => void;
onReorder: (category: string, activeId: string, overId: string) => void;
}) {
const { t } = useTranslation();
const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
onReorder(category, active.id as string, over.id as string);
},
[category, onReorder],
);
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t(label)}
</Text>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
{choices.map((choice) => (
<SortableChoiceRow
key={choice.id}
choice={choice}
autoFocus={choice.id === focusChoiceId}
onFocused={onFocused}
onRename={onRename}
onColorChange={onColorChange}
onRemove={onRemove}
/>
))}
</SortableContext>
</DndContext>
<UnstyledButton
onClick={() => onAdd(category)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
>
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
<Text size="xs" c="dimmed">{t("Add option")}</Text>
</UnstyledButton>
</Stack>
);
}
function SortableChoiceRow({
choice,
autoFocus,
onFocused,
onRename,
onColorChange,
onRemove,
}: {
choice: Choice;
autoFocus?: boolean;
onFocused?: () => void;
onRename: (id: string, name: string) => void;
onColorChange: (id: string, color: string) => void;
onRemove: (id: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: choice.id });
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus();
onFocused?.();
}
}, [autoFocus, onFocused]);
const style = {
transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const hasError = !choice.name.trim();
return (
<Group ref={setNodeRef} style={style} gap={6} wrap="nowrap" align="center">
<div
{...attributes}
{...listeners}
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<ColorDot color={choice.color} onChange={(c) => onColorChange(choice.id, c)} />
<TextInput
ref={inputRef}
size="xs"
value={choice.name}
onChange={(e) => onRename(choice.id, e.currentTarget.value)}
style={{ flex: 1 }}
error={hasError}
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
/>
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
</Group>
);
}
function ColorDot({
color,
onChange,
}: {
color: string;
onChange: (color: string) => void;
}) {
const [opened, setOpened] = useState(false);
const colors = choiceColor(color);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" shadow="sm" withinPortal>
<Popover.Target>
<UnstyledButton
onClick={() => setOpened((o) => !o)}
style={{
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: colors.backgroundColor as string,
border: `2px solid ${colors.color as string}`,
flexShrink: 0,
}}
/>
</Popover.Target>
<Popover.Dropdown p={8}>
<SimpleGrid cols={5} spacing={6}>
{CHOICE_COLORS.map((c) => {
const dotColors = choiceColor(c);
return (
<UnstyledButton
key={c}
onClick={() => {
onChange(c);
setOpened(false);
}}
style={{
width: 24,
height: 24,
borderRadius: "50%",
backgroundColor: dotColors.backgroundColor as string,
border: c === color
? `2px solid ${dotColors.color as string}`
: "2px solid transparent",
}}
/>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,443 @@
import { useState, useCallback, useRef, useEffect } from "react";
import {
UnstyledButton,
TextInput,
Button,
Stack,
Text,
Group,
ActionIcon,
Divider,
ScrollArea,
} from "@mantine/core";
import {
IconTrash,
IconPencil,
IconChevronRight,
IconTransform,
IconSettings,
} from "@tabler/icons-react";
import {
IBaseProperty,
BasePropertyType,
} from "@/features/base/types/base.types";
import { useAtom } from "jotai";
import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
import {
useUpdatePropertyMutation,
useDeletePropertyMutation,
} from "@/features/base/queries/base-property-query";
import { PropertyTypePicker } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import { useTranslation } from "react-i18next";
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
import cellClasses from "@/features/base/styles/cells.module.css";
type PropertyMenuContentProps = {
property: IBaseProperty;
opened: boolean;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
};
type MenuPanel = "main" | "rename" | "changeType" | "options" | "confirmDelete" | "confirmDiscard";
export function PropertyMenuContent({
property,
opened,
onClose,
onDirtyChange,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
const [panel, setPanel] = useState<MenuPanel>("main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef<HTMLInputElement>(null);
const [optionsDirty, setOptionsDirty] = useState(false);
const pendingActionRef = useRef<"back" | "close" | null>(null);
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
const closeRequestRef = useRef(closeRequest);
const updatePropertyMutation = useUpdatePropertyMutation();
const deletePropertyMutation = useDeletePropertyMutation();
useEffect(() => {
if (opened) {
setPanel("main");
setRenameValue(property.name);
setOptionsDirty(false);
}
}, [opened, property.name]);
useEffect(() => {
if (panel === "rename") {
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [panel]);
const handleOptionsDirtyChange = useCallback(
(dirty: boolean) => {
setOptionsDirty(dirty);
onDirtyChange?.(dirty);
},
[onDirtyChange],
);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== property.name) {
updatePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
name: trimmed,
});
}
}, [renameValue, property, updatePropertyMutation]);
const handleRenameAndClose = useCallback(() => {
commitRename();
onClose();
}, [commitRename, onClose]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
handleRenameAndClose();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
},
[handleRenameAndClose, onClose],
);
const handleTypeChange = useCallback(
(type: BasePropertyType) => {
if (type !== property.type) {
updatePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
type,
});
}
onClose();
},
[property, updatePropertyMutation, onClose],
);
const handleOptionsUpdate = useCallback(
(typeOptions: Record<string, unknown>) => {
updatePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
typeOptions,
});
setOptionsDirty(false);
},
[property, updatePropertyMutation],
);
const handleDelete = useCallback(() => {
deletePropertyMutation.mutate({
propertyId: property.id,
baseId: property.baseId,
});
onClose();
}, [property, deletePropertyMutation, onClose]);
const handleOptionsBack = useCallback(() => {
if (optionsDirty) {
pendingActionRef.current = "back";
setPanel("confirmDiscard");
} else {
setPanel("main");
}
}, [optionsDirty]);
const requestClose = useCallback(() => {
if (panel === "options" && optionsDirty) {
pendingActionRef.current = "close";
setPanel("confirmDiscard");
} else {
onClose();
}
}, [panel, optionsDirty, onClose]);
useEffect(() => {
if (closeRequest !== closeRequestRef.current) {
closeRequestRef.current = closeRequest;
if (opened) {
requestClose();
}
}
}, [closeRequest, opened, requestClose]);
const handleConfirmDiscard = useCallback(() => {
setOptionsDirty(false);
onDirtyChange?.(false);
const action = pendingActionRef.current;
pendingActionRef.current = null;
if (action === "back") {
setPanel("main");
} else {
onClose();
}
}, [onClose, onDirtyChange]);
const handleCancelDiscard = useCallback(() => {
pendingActionRef.current = null;
setPanel("options");
}, []);
return (
<>
{panel === "main" && (
<MainPanel
property={property}
onRename={() => setPanel("rename")}
onChangeType={() => setPanel("changeType")}
onOptions={() => setPanel("options")}
onDelete={() => setPanel("confirmDelete")}
/>
)}
{panel === "rename" && (
<Stack gap="xs" p="sm">
<Text size="xs" fw={600} c="dimmed">
{t("Rename property")}
</Text>
<TextInput
ref={renameInputRef}
size="xs"
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
/>
</Stack>
)}
{panel === "changeType" && (
<TypePanel
currentType={property.type}
onSelect={handleTypeChange}
onBack={() => setPanel("main")}
/>
)}
{(panel === "options" || panel === "confirmDiscard") && (
<Stack gap="xs" p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={handleOptionsBack}
>
<IconChevronRight
size={14}
style={{ transform: "rotate(180deg)" }}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Property options")}
</Text>
</Group>
<ScrollArea.Autosize mah={400} scrollbarSize={6} offsetScrollbars>
<PropertyOptions
property={property}
onUpdate={handleOptionsUpdate}
onClose={onClose}
onDirtyChange={handleOptionsDirtyChange}
/>
</ScrollArea.Autosize>
</Stack>
)}
{panel === "confirmDelete" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Delete property")}
</Text>
<Text size="xs" c="dimmed">
{t("Are you sure you want to delete")} <b>{property.name}</b>?{" "}
{t("All data in this column will be lost.")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={() => setPanel("main")}
>
{t("Cancel")}
</Button>
<Button
color="red"
size="xs"
onClick={handleDelete}
>
{t("Delete")}
</Button>
</Group>
</Stack>
)}
{panel === "confirmDiscard" && (
<Stack gap="xs" p="sm">
<Text size="sm" fw={600}>
{t("Unsaved changes")}
</Text>
<Text size="xs" c="dimmed">
{t("You have unsaved changes. Do you want to discard them?")}
</Text>
<Group gap="xs" justify="flex-end">
<Button
variant="default"
size="xs"
onClick={handleCancelDiscard}
>
{t("Keep editing")}
</Button>
<Button
color="red"
size="xs"
onClick={handleConfirmDiscard}
>
{t("Discard")}
</Button>
</Group>
</Stack>
)}
</>
);
}
// Expose requestClose for use by parent (grid-header-cell)
PropertyMenuContent.displayName = "PropertyMenuContent";
function MenuItem({
icon,
label,
rightIcon,
color,
onClick,
}: {
icon: React.ReactNode;
label: string;
rightIcon?: React.ReactNode;
color?: string;
onClick: () => void;
}) {
return (
<UnstyledButton
className={cellClasses.menuItem}
onClick={onClick}
style={{ color: color ? `var(--mantine-color-${color}-6)` : undefined }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{icon}
<Text size="sm">{label}</Text>
</Group>
{rightIcon}
</UnstyledButton>
);
}
function MainPanel({
property,
onRename,
onChangeType,
onOptions,
onDelete,
}: {
property: IBaseProperty;
onRename: () => void;
onChangeType: () => void;
onOptions: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const isSystem = isSystemPropertyType(property.type);
const hasOptions =
!isSystem &&
(property.type === "select" ||
property.type === "multiSelect" ||
property.type === "status" ||
property.type === "number" ||
property.type === "date");
return (
<Stack gap={0} p={4}>
<MenuItem
icon={<IconPencil size={14} />}
label={t("Rename")}
onClick={onRename}
/>
{!isSystem && (
<MenuItem
icon={<IconTransform size={14} />}
label={t("Change type")}
rightIcon={<IconChevronRight size={14} />}
onClick={onChangeType}
/>
)}
{hasOptions && (
<MenuItem
icon={<IconSettings size={14} />}
label={t("Options")}
rightIcon={<IconChevronRight size={14} />}
onClick={onOptions}
/>
)}
{!property.isPrimary && (
<>
<Divider my={4} />
<MenuItem
icon={<IconTrash size={14} />}
label={t("Delete property")}
color="red"
onClick={onDelete}
/>
</>
)}
</Stack>
);
}
function TypePanel({
currentType,
onSelect,
onBack,
}: {
currentType: BasePropertyType;
onSelect: (type: BasePropertyType) => void;
onBack: () => void;
}) {
const { t } = useTranslation();
return (
<Stack gap={0} p={4}>
<Group gap="xs" px="sm" py={6}>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={onBack}
>
<IconChevronRight
size={14}
style={{ transform: "rotate(180deg)" }}
/>
</ActionIcon>
<Text size="xs" fw={600} c="dimmed">
{t("Change type")}
</Text>
</Group>
<ScrollArea.Autosize mah={300}>
<PropertyTypePicker
onSelect={onSelect}
currentType={currentType}
/>
</ScrollArea.Autosize>
</Stack>
);
}
@@ -0,0 +1,217 @@
import { useCallback } from "react";
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
Choice,
} from "@/features/base/types/base.types";
import { ChoiceEditor } from "./choice-editor";
import { useTranslation } from "react-i18next";
type PropertyOptionsProps = {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
};
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }: PropertyOptionsProps) {
const { t } = useTranslation();
switch (property.type) {
case "select":
case "multiSelect":
return (
<SelectOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
/>
);
case "status":
return (
<StatusOptions
property={property}
onUpdate={onUpdate}
onClose={onClose}
onDirtyChange={onDirtyChange}
/>
);
case "number":
return (
<NumberOptions
property={property}
onUpdate={onUpdate}
/>
);
case "date":
return (
<DateOptions
property={property}
onUpdate={onUpdate}
/>
);
default:
return (
<Text size="xs" c="dimmed">
{t("No options for this property type")}
</Text>
);
}
}
function SelectOptions({
property,
onUpdate,
onClose,
onDirtyChange,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories={false}
/>
);
}
function StatusOptions({
property,
onUpdate,
onClose,
onDirtyChange,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
onClose: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const options = property.typeOptions as SelectTypeOptions | undefined;
const choices = options?.choices ?? [];
const handleSave = useCallback(
(newChoices: Choice[]) => {
onUpdate({
...property.typeOptions,
choices: newChoices,
choiceOrder: newChoices.map((c) => c.id),
});
},
[property.typeOptions, onUpdate],
);
return (
<ChoiceEditor
initialChoices={choices}
onSave={handleSave}
onClose={onClose}
onDirtyChange={onDirtyChange}
showCategories
/>
);
}
function NumberOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as NumberTypeOptions | undefined;
return (
<Stack gap="xs">
<Select
size="xs"
label={t("Format")}
data={[
{ value: "plain", label: t("Number") },
{ value: "currency", label: t("Currency") },
{ value: "percent", label: t("Percent") },
{ value: "progress", label: t("Progress") },
]}
value={options?.format ?? "plain"}
onChange={(val) =>
onUpdate({ ...property.typeOptions, format: val })
}
/>
<NumberInput
size="xs"
label={t("Decimal places")}
min={0}
max={8}
value={options?.precision ?? 0}
onChange={(val) =>
onUpdate({ ...property.typeOptions, precision: val })
}
/>
</Stack>
);
}
function DateOptions({
property,
onUpdate,
}: {
property: IBaseProperty;
onUpdate: (typeOptions: Record<string, unknown>) => void;
}) {
const { t } = useTranslation();
const options = property.typeOptions as DateTypeOptions | undefined;
return (
<Stack gap="xs">
<Switch
size="xs"
label={t("Include time")}
checked={options?.includeTime ?? false}
onChange={(e) =>
onUpdate({
...property.typeOptions,
includeTime: e.currentTarget.checked,
})
}
/>
{options?.includeTime && (
<Select
size="xs"
label={t("Time format")}
data={[
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
]}
value={options?.timeFormat ?? "12h"}
onChange={(val) =>
onUpdate({ ...property.typeOptions, timeFormat: val })
}
/>
)}
</Stack>
);
}
@@ -0,0 +1,83 @@
import { UnstyledButton, Group, Text } from "@mantine/core";
import {
IconLetterT,
IconHash,
IconCircleDot,
IconProgressCheck,
IconTags,
IconCalendar,
IconUser,
IconPaperclip,
IconCheckbox,
IconLink,
IconMail,
IconClockPlus,
IconClockEdit,
IconUserEdit,
IconCheck,
} from "@tabler/icons-react";
import { BasePropertyType } from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
import classes from "@/features/base/styles/cells.module.css";
const propertyTypes: {
type: BasePropertyType;
icon: typeof IconLetterT;
labelKey: string;
}[] = [
{ type: "text", icon: IconLetterT, labelKey: "Text" },
{ type: "number", icon: IconHash, labelKey: "Number" },
{ type: "select", icon: IconCircleDot, labelKey: "Select" },
{ type: "status", icon: IconProgressCheck, labelKey: "Status" },
{ type: "multiSelect", icon: IconTags, labelKey: "Multi-select" },
{ type: "date", icon: IconCalendar, labelKey: "Date" },
{ type: "person", icon: IconUser, labelKey: "Person" },
{ type: "file", icon: IconPaperclip, labelKey: "File" },
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
{ type: "url", icon: IconLink, labelKey: "URL" },
{ type: "email", icon: IconMail, labelKey: "Email" },
{ type: "createdAt", icon: IconClockPlus, labelKey: "Created at" },
{ type: "lastEditedAt", icon: IconClockEdit, labelKey: "Last edited at" },
{ type: "lastEditedBy", icon: IconUserEdit, labelKey: "Last edited by" },
];
type PropertyTypePickerProps = {
onSelect: (type: BasePropertyType) => void;
currentType?: BasePropertyType;
excludeTypes?: Set<BasePropertyType>;
};
export function PropertyTypePicker({
onSelect,
currentType,
excludeTypes,
}: PropertyTypePickerProps) {
const { t } = useTranslation();
const types = excludeTypes
? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
: propertyTypes;
return (
<>
{types.map(({ type, icon: Icon, labelKey }) => (
<UnstyledButton
key={type}
className={classes.menuItem}
onClick={() => onSelect(type)}
style={{
fontWeight: type === currentType ? 600 : 400,
}}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Icon size={14} />
<Text size="sm">{t(labelKey)}</Text>
</Group>
{type === currentType && <IconCheck size={14} />}
</UnstyledButton>
))}
</>
);
}
export { propertyTypes };
@@ -0,0 +1,146 @@
import { useMemo, useCallback } from "react";
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import { Table } from "@tanstack/react-table";
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
import { propertyTypes } from "@/features/base/components/property/property-type-picker";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewFieldVisibilityProps = {
opened: boolean;
onClose: () => void;
table: Table<IBaseRow>;
onPersist: () => void;
children: React.ReactNode;
};
export function ViewFieldVisibility({
opened,
onClose,
table,
onPersist,
children,
}: ViewFieldVisibilityProps) {
const { t } = useTranslation();
const columns = useMemo(() => {
return table
.getAllLeafColumns()
.filter((col) => col.id !== "__row_number");
}, [table]);
const allVisible = columns.every((col) => col.getIsVisible());
const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
const handleToggle = useCallback(
(columnId: string, visible: boolean) => {
const col = table.getColumn(columnId);
if (!col) return;
col.toggleVisibility(visible);
onPersist();
},
[table, onPersist],
);
const handleShowAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(true);
}
});
onPersist();
}, [columns, onPersist]);
const handleHideAll = useCallback(() => {
columns.forEach((col) => {
if (col.getCanHide()) {
col.toggleVisibility(false);
}
});
onPersist();
}, [columns, onPersist]);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={4}>
<Group justify="space-between" px={4} py={2}>
<Text size="xs" fw={600} c="dimmed">
{t("Fields")}
</Text>
<Group gap={8}>
<UnstyledButton
onClick={handleShowAll}
disabled={allVisible}
style={{ opacity: allVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Show all")}
</Text>
</UnstyledButton>
<UnstyledButton
onClick={handleHideAll}
disabled={noneVisible}
style={{ opacity: noneVisible ? 0.4 : 1 }}
>
<Text size="xs" c="blue">
{t("Hide all")}
</Text>
</UnstyledButton>
</Group>
</Group>
<Divider />
<Stack gap={0}>
{columns.map((col) => {
const property = col.columnDef.meta?.property as IBaseProperty | undefined;
if (!property) return null;
const canHide = col.getCanHide();
const isVisible = col.getIsVisible();
const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
const TypeIcon = typeConfig?.icon;
return (
<UnstyledButton
key={col.id}
className={cellClasses.menuItem}
onClick={() => {
if (canHide) {
handleToggle(col.id, !isVisible);
}
}}
style={{ opacity: canHide ? 1 : 0.5 }}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
disabled={!canHide}
onChange={() => {}}
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,204 @@
import { useCallback } from "react";
import {
Popover,
Stack,
Group,
Select,
TextInput,
ActionIcon,
Text,
UnstyledButton,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
IBaseProperty,
ViewFilterConfig,
ViewFilterOperator,
} from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
{ value: "equals", labelKey: "Equals" },
{ value: "notEquals", labelKey: "Not equals" },
{ value: "contains", labelKey: "Contains" },
{ value: "notContains", labelKey: "Not contains" },
{ value: "isEmpty", labelKey: "Is empty" },
{ value: "isNotEmpty", labelKey: "Is not empty" },
{ value: "greaterThan", labelKey: "Greater than" },
{ value: "lessThan", labelKey: "Less than" },
{ value: "before", labelKey: "Before" },
{ value: "after", labelKey: "After" },
];
const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
type ViewFilterConfigProps = {
opened: boolean;
onClose: () => void;
filters: ViewFilterConfig[];
properties: IBaseProperty[];
onChange: (filters: ViewFilterConfig[]) => void;
children: React.ReactNode;
};
export function ViewFilterConfigPopover({
opened,
onClose,
filters,
properties,
onChange,
children,
}: ViewFilterConfigProps) {
const { t } = useTranslation();
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const operatorOptions = OPERATORS.map((op) => ({
value: op.value,
label: t(op.labelKey),
}));
const handleAdd = useCallback(() => {
const firstProperty = properties[0];
if (!firstProperty) return;
onChange([
...filters,
{ propertyId: firstProperty.id, operator: "contains" },
]);
}, [filters, properties, onChange]);
const handleRemove = useCallback(
(index: number) => {
onChange(filters.filter((_, i) => i !== index));
},
[filters, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
onChange(
filters.map((f, i) => (i === index ? { ...f, propertyId } : f)),
);
},
[filters, onChange],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as ViewFilterOperator;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
onChange(
filters.map((f, i) =>
i === index
? {
...f,
operator: op,
value: needsValue ? f.value : undefined,
}
: f,
),
);
},
[filters, onChange],
);
const handleValueChange = useCallback(
(index: number, value: string) => {
onChange(
filters.map((f, i) =>
i === index ? { ...f, value: value || undefined } : f,
),
);
},
[filters, onChange],
);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={440}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Filter by")}
</Text>
{filters.length === 0 && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{filters.map((filter, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
return (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={filter.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
value={filter.operator}
onChange={(val) => handleOperatorChange(index, val)}
w={130}
/>
{needsValue && (
<TextInput
size="xs"
placeholder={t("Value")}
value={(filter.value as string) ?? ""}
onChange={(e) =>
handleValueChange(index, e.currentTarget.value)
}
w={100}
/>
)}
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
);
})}
<UnstyledButton
onClick={handleAdd}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add filter")}
</UnstyledButton>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,153 @@
import { useCallback } from "react";
import {
Popover,
Stack,
Group,
Select,
ActionIcon,
Text,
UnstyledButton,
} from "@mantine/core";
import { IconPlus, IconTrash, IconSortAscending } from "@tabler/icons-react";
import {
IBaseProperty,
ViewSortConfig,
} from "@/features/base/types/base.types";
import { useTranslation } from "react-i18next";
type ViewSortConfigProps = {
opened: boolean;
onClose: () => void;
sorts: ViewSortConfig[];
properties: IBaseProperty[];
onChange: (sorts: ViewSortConfig[]) => void;
children: React.ReactNode;
};
export function ViewSortConfigPopover({
opened,
onClose,
sorts,
properties,
onChange,
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const directionOptions = [
{ value: "asc", label: t("Ascending") },
{ value: "desc", label: t("Descending") },
];
const handleAdd = useCallback(() => {
const usedIds = new Set(sorts.map((s) => s.propertyId));
const available = properties.find((p) => !usedIds.has(p.id));
if (!available) return;
onChange([...sorts, { propertyId: available.id, direction: "asc" }]);
}, [sorts, properties, onChange]);
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],
);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sort by")}
</Text>
{sorts.length === 0 && (
<Text size="xs" c="dimmed">
{t("No sorts applied")}
</Text>
)}
{sorts.map((sort, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={sort.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={directionOptions}
value={sort.direction}
onChange={(val) => handleDirectionChange(index, val)}
w={110}
/>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
<UnstyledButton
onClick={handleAdd}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add sort")}
</UnstyledButton>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,237 @@
import { useState, useCallback } from "react";
import {
Group,
UnstyledButton,
Text,
ActionIcon,
Tooltip,
TextInput,
Popover,
Stack,
Divider,
} from "@mantine/core";
import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
import { IBaseView } from "@/features/base/types/base.types";
import {
useUpdateViewMutation,
useDeleteViewMutation,
} from "@/features/base/queries/base-view-query";
import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewTabsProps = {
views: IBaseView[];
activeViewId: string | undefined;
baseId: string;
onViewChange: (viewId: string) => void;
onAddView?: () => void;
};
export function ViewTabs({
views,
activeViewId,
baseId,
onViewChange,
onAddView,
}: ViewTabsProps) {
const { t } = useTranslation();
const [editingViewId, setEditingViewId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const updateViewMutation = useUpdateViewMutation();
const deleteViewMutation = useDeleteViewMutation();
const handleRenameStart = useCallback(
(view: IBaseView) => {
setEditingViewId(view.id);
setEditingName(view.name);
},
[],
);
const handleRenameCommit = useCallback(() => {
if (!editingViewId) return;
const trimmed = editingName.trim();
const view = views.find((v) => v.id === editingViewId);
if (trimmed && view && trimmed !== view.name) {
updateViewMutation.mutate({
viewId: editingViewId,
baseId,
name: trimmed,
});
}
setEditingViewId(null);
}, [editingViewId, editingName, views, baseId, updateViewMutation]);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameCommit();
}
if (e.key === "Escape") {
e.preventDefault();
setEditingViewId(null);
}
},
[handleRenameCommit],
);
const handleDelete = useCallback(
(viewId: string) => {
if (views.length <= 1) return;
deleteViewMutation.mutate({ viewId, baseId });
if (viewId === activeViewId && views.length > 1) {
const remaining = views.filter((v) => v.id !== viewId);
onViewChange(remaining[0].id);
}
},
[views, baseId, activeViewId, deleteViewMutation, onViewChange],
);
return (
<Group gap={4}>
{views.map((view) => (
<ViewTab
key={view.id}
view={view}
isActive={view.id === activeViewId}
isEditing={view.id === editingViewId}
editingName={editingName}
canDelete={views.length > 1}
onClick={() => onViewChange(view.id)}
onRenameStart={() => handleRenameStart(view)}
onRenameChange={setEditingName}
onRenameCommit={handleRenameCommit}
onRenameKeyDown={handleRenameKeyDown}
onDelete={() => handleDelete(view.id)}
/>
))}
{onAddView && (
<Tooltip label={t("Add view")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onAddView}
>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
function ViewTab({
view,
isActive,
isEditing,
editingName,
canDelete,
onClick,
onRenameStart,
onRenameChange,
onRenameCommit,
onRenameKeyDown,
onDelete,
}: {
view: IBaseView;
isActive: boolean;
isEditing: boolean;
editingName: string;
canDelete: boolean;
onClick: () => void;
onRenameStart: () => void;
onRenameChange: (name: string) => void;
onRenameCommit: () => void;
onRenameKeyDown: (e: React.KeyboardEvent) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false);
if (isEditing) {
return (
<TextInput
size="xs"
w={120}
value={editingName}
onChange={(e) => onRenameChange(e.currentTarget.value)}
onBlur={onRenameCommit}
onKeyDown={onRenameKeyDown}
autoFocus
/>
);
}
return (
<Popover
opened={menuOpened}
onClose={() => setMenuOpened(false)}
position="bottom-start"
shadow="md"
width={180}
withinPortal
>
<Popover.Target>
<UnstyledButton
onClick={onClick}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpened(true);
}}
style={{
padding: "4px 10px",
borderRadius: "var(--mantine-radius-sm)",
fontWeight: isActive ? 600 : 400,
}}
>
<Group gap={6} wrap="nowrap">
<IconTable size={14} opacity={0.5} />
<Text
size="sm"
c={isActive ? undefined : "dimmed"}
>
{view.name}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown p={4}>
<Stack gap={0}>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onRenameStart();
}}
>
<Group gap={8} wrap="nowrap">
<IconPencil size={14} />
<Text size="sm">{t("Rename")}</Text>
</Group>
</UnstyledButton>
{canDelete && (
<>
<Divider my={4} />
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => {
setMenuOpened(false);
onDelete();
}}
style={{ color: "var(--mantine-color-red-6)" }}
>
<Group gap={8} wrap="nowrap">
<IconTrash size={14} />
<Text size="sm">{t("Delete view")}</Text>
</Group>
</UnstyledButton>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}