mirror of
https://github.com/docmost/docmost.git
synced 2026-07-25 07:14:43 +10:00
feat: bases - WIP
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user