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,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>
);
}