mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 09:17:06 +08:00
feat(ee): bases (#2295)
* feat(ee): bases Table and kanban UI, formula engine package, and the base-embed editor extension. * - default status - type fix - error helper * fix: base trash list handling * feat: base nodeview menu * feat: translation * fix number precision * feat(base): add focused-cell atom and cell coordinate types * feat(base): add cell focus-ring style * feat(base): add pure next-cell navigation helper * feat(base): keyboard navigation controller and grid wiring * update offerings * feat(base): cell focus ring, click-to-focus, and gridcell ARIA * feat(base): row ARIA index and selected state * feat(base): seed editor value on type-to-edit for free-text cells * feat(base): make column headers keyboard-focusable as tab stops * fix(base): remove focus outline on grid container * fix(base): show cell focus ring only while the grid is focused * feat(base): keyboard-navigate the row-number column for selection * fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order * fix(base): tab from long-text editor moves to next cell instead of leaving the table * fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop * fix(base): show cell focus ring only while the grid body itself is focused * fix(base): render view-tab rename as an inline pill so the tab band height stays put * fix(base): refer to the feature as 'base' rather than 'database' * fix: change permissions object shape * license file * fix tsconfig * fix base cache * fix: preserve sidebar title/icon on partial page updates * fix: skip duplicate row fetch when opening new kanban card * fix refetch * fix focus * fix spacing * fix(base): select grid cell on mousedown to avoid stale focus ring flash The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell. Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints. * fix: activate New row button via keyboard (Enter/Space) The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell. Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys. * fix(base): keep add-property popover within viewport on mobile Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it. Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active. * fix(base): enable grid cell editing on touch devices Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable. * feat(editor): add base and kanban inserts to the toolbar * feat(base): insert row below via Shift+Enter on the primary cell * fix(base): place caret at end instead of selecting all when editing cells * fix(base): prevent popover inputs from losing focus on mobile in row detail modal * fix grid cells on mobile * sync * fix: read-only export * feat(base): add prefixed nanoid id schemas and generators * feat(base): enforce strict property/choice id validation * feat(base): make property id varchar with per-base composite pk * feat(base): pass property id as text to cell extractors * feat(base): scope property lookups per base and generate property ids in repo * feat(base): generate status template choice ids as nanoid * feat(base): generate choice ids as nanoid on the client * chore(base): seed choice ids with nanoid * fix(base): mint kanban choice ids as nanoid * sync * sync * sync
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } 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 classes from "@/ee/base/styles/property.module.css";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||
import { Choice } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id";
|
||||
import { DefaultValuePicker } from "./default-value-picker";
|
||||
|
||||
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;
|
||||
|
||||
// Default choices for a new status property, one per category.
|
||||
export function defaultStatusChoices(): Choice[] {
|
||||
return [
|
||||
{ id: generateBaseChoiceId(), name: "Not started", color: "gray", category: "todo" },
|
||||
{ id: generateBaseChoiceId(), name: "In progress", color: "blue", category: "inProgress" },
|
||||
{ id: generateBaseChoiceId(), name: "Done", color: "green", category: "complete" },
|
||||
];
|
||||
}
|
||||
|
||||
function pruneDefault(
|
||||
value: string | string[] | null,
|
||||
choices: Choice[],
|
||||
): string | string[] | null {
|
||||
if (value === null) return null;
|
||||
const ids = new Set(choices.map((c) => c.id));
|
||||
if (Array.isArray(value)) {
|
||||
const live = value.filter((id) => ids.has(id));
|
||||
return live.length ? live : null;
|
||||
}
|
||||
return ids.has(value) ? value : null;
|
||||
}
|
||||
|
||||
function defaultsEqual(
|
||||
a: string | string[] | null,
|
||||
b: string | string[] | null,
|
||||
): boolean {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
type ChoiceEditorProps = {
|
||||
initialChoices: Choice[];
|
||||
onSave: (choices: Choice[], defaultValue: string | string[] | null) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
showCategories?: boolean;
|
||||
hideButtons?: boolean;
|
||||
initialDefaultValue?: string | string[] | null;
|
||||
multiDefault?: boolean;
|
||||
/**
|
||||
* Where the per-choice color-picker popover portals. Pass the enclosing
|
||||
* property-menu dropdown node so the picker renders INSIDE that subtree —
|
||||
* otherwise a color click registers as "outside" and closes the menu.
|
||||
*/
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function ChoiceEditor({
|
||||
initialChoices,
|
||||
onSave,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
showCategories = false,
|
||||
hideButtons = false,
|
||||
initialDefaultValue = null,
|
||||
multiDefault = false,
|
||||
dropdownPortalTarget,
|
||||
}: ChoiceEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
||||
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
||||
const [defaultDraft, setDefaultDraft] = useState<string | string[] | null>(
|
||||
initialDefaultValue,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideButtons) {
|
||||
setDraft(initialChoices);
|
||||
setDefaultDraft(initialDefaultValue);
|
||||
}
|
||||
}, [initialChoices, initialDefaultValue, hideButtons]);
|
||||
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideButtons) {
|
||||
const cleaned = draft.filter((c) => c.name.trim());
|
||||
onSaveRef.current(cleaned, pruneDefault(defaultDraft, cleaned));
|
||||
}
|
||||
}, [hideButtons, draft, defaultDraft]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!defaultsEqual(defaultDraft, initialDefaultValue)) return true;
|
||||
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, defaultDraft, initialDefaultValue]);
|
||||
|
||||
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));
|
||||
setDefaultDraft((prev) => {
|
||||
if (prev === null) return prev;
|
||||
if (Array.isArray(prev)) {
|
||||
const next = prev.filter((id) => id !== choiceId);
|
||||
return next.length ? next : null;
|
||||
}
|
||||
return prev === choiceId ? null : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
|
||||
const id = generateBaseChoiceId();
|
||||
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, pruneDefault(defaultDraft, cleaned));
|
||||
onClose();
|
||||
}, [draft, defaultDraft, onSave, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraft(initialChoices);
|
||||
setDefaultDraft(initialDefaultValue);
|
||||
onDirtyChange?.(false);
|
||||
onClose();
|
||||
}, [initialChoices, initialDefaultValue, onDirtyChange, onClose]);
|
||||
|
||||
const handleReorder = useCallback(
|
||||
(activeId: string, targetId: string, edge: Edge) => {
|
||||
setDraft((prev) => {
|
||||
const startIndex = prev.findIndex((c) => c.id === activeId);
|
||||
const indexOfTarget = prev.findIndex((c) => c.id === targetId);
|
||||
if (startIndex === -1 || indexOfTarget === -1) return prev;
|
||||
const finishIndex = getReorderDestinationIndex({
|
||||
startIndex,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget: edge,
|
||||
axis: "vertical",
|
||||
});
|
||||
if (finishIndex === startIndex) return prev;
|
||||
return reorder({ list: prev, startIndex, finishIndex });
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCategoryReorder = useCallback(
|
||||
(category: string, activeId: string, targetId: string, edge: Edge) => {
|
||||
setDraft((prev) => {
|
||||
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
|
||||
const startIndex = catChoices.findIndex((c) => c.id === activeId);
|
||||
const indexOfTarget = catChoices.findIndex((c) => c.id === targetId);
|
||||
if (startIndex === -1 || indexOfTarget === -1) return prev;
|
||||
const finishIndex = getReorderDestinationIndex({
|
||||
startIndex,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget: edge,
|
||||
axis: "vertical",
|
||||
});
|
||||
if (finishIndex === startIndex) return prev;
|
||||
const reordered = reorder({
|
||||
list: catChoices,
|
||||
startIndex,
|
||||
finishIndex,
|
||||
});
|
||||
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} className={classes.alphabetizeBtn}>
|
||||
<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}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
) : (
|
||||
<FlatChoiceList
|
||||
draft={draft}
|
||||
focusChoiceId={focusChoiceId}
|
||||
onFocused={() => setFocusChoiceId(null)}
|
||||
onRename={handleRename}
|
||||
onColorChange={handleColorChange}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onReorder={handleReorder}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DefaultValuePicker
|
||||
choices={draft.filter((c) => c.name.trim())}
|
||||
value={defaultDraft}
|
||||
multiple={multiDefault}
|
||||
onChange={setDefaultDraft}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
|
||||
{!hideButtons && (
|
||||
<>
|
||||
<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,
|
||||
dropdownPortalTarget,
|
||||
}: {
|
||||
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, targetId: string, edge: Edge) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{draft.map((choice) => (
|
||||
<SortableChoiceRow
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
dragType="base-choice-flat"
|
||||
autoFocus={choice.id === focusChoiceId}
|
||||
onFocused={onFocused}
|
||||
onRename={onRename}
|
||||
onColorChange={onColorChange}
|
||||
onRemove={onRemove}
|
||||
onReorder={onReorder}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => onAdd()}
|
||||
className={classes.addOptionBtn}
|
||||
>
|
||||
<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,
|
||||
dropdownPortalTarget,
|
||||
}: {
|
||||
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, targetId: string, edge: Edge) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
}) {
|
||||
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}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
label,
|
||||
choices,
|
||||
focusChoiceId,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onReorder,
|
||||
dropdownPortalTarget,
|
||||
}: {
|
||||
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,
|
||||
targetId: string,
|
||||
edge: Edge,
|
||||
) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRowReorder = useCallback(
|
||||
(activeId: string, targetId: string, edge: Edge) => {
|
||||
onReorder(category, activeId, targetId, edge);
|
||||
},
|
||||
[category, onReorder],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t(label)}
|
||||
</Text>
|
||||
|
||||
{choices.map((choice) => (
|
||||
<SortableChoiceRow
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
// Per-category drag type prevents cross-category drops.
|
||||
dragType={`base-choice-status:${category}`}
|
||||
autoFocus={choice.id === focusChoiceId}
|
||||
onFocused={onFocused}
|
||||
onRename={onRename}
|
||||
onColorChange={onColorChange}
|
||||
onRemove={onRemove}
|
||||
onReorder={handleRowReorder}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => onAdd(category)}
|
||||
className={classes.addOptionBtn}
|
||||
>
|
||||
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="xs" c="dimmed">{t("Add option")}</Text>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableChoiceRow({
|
||||
choice,
|
||||
dragType,
|
||||
autoFocus,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
onReorder,
|
||||
dropdownPortalTarget,
|
||||
}: {
|
||||
choice: Choice;
|
||||
dragType: string;
|
||||
autoFocus?: boolean;
|
||||
onFocused?: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onColorChange: (id: string, color: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
// Stable ref so the DnD effect doesn't re-register on every parent render.
|
||||
const onReorderRef = useRef(onReorder);
|
||||
useLayoutEffect(() => {
|
||||
onReorderRef.current = onReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
inputRef.current?.focus();
|
||||
onFocused?.();
|
||||
}
|
||||
}, [autoFocus, onFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const handle = handleRef.current;
|
||||
if (!row || !handle) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: row,
|
||||
dragHandle: handle,
|
||||
getInitialData: () => ({ type: dragType, choiceId: choice.id }),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: row,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === dragType &&
|
||||
source.data.choiceId !== choice.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ choiceId: choice.id },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onReorderRef.current(
|
||||
source.data.choiceId as string,
|
||||
choice.id,
|
||||
edge,
|
||||
);
|
||||
triggerPostMoveFlash(row);
|
||||
liveRegion.announce("Moved option");
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [choice.id, dragType]);
|
||||
|
||||
const hasError = !choice.name.trim();
|
||||
|
||||
return (
|
||||
<Group
|
||||
ref={rowRef}
|
||||
gap={6}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
position: "relative",
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
data-dragging={isDragging || undefined}
|
||||
>
|
||||
<div ref={handleRef} className={classes.dragHandle}>
|
||||
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<ColorDot
|
||||
color={choice.color}
|
||||
onChange={(c) => onColorChange(choice.id, c)}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
<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)} />
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorDot({
|
||||
color,
|
||||
onChange,
|
||||
dropdownPortalTarget,
|
||||
}: {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const colors = choiceColor(color);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom"
|
||||
shadow="sm"
|
||||
withinPortal
|
||||
portalProps={{ target: dropdownPortalTarget ?? undefined }}
|
||||
>
|
||||
<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,127 @@
|
||||
import type { BasePropertyType } from "@/ee/base/types/base.types";
|
||||
|
||||
export const NON_USER_TARGET_TYPES = new Set<BasePropertyType>([
|
||||
"createdAt",
|
||||
"lastEditedAt",
|
||||
"lastEditedBy",
|
||||
"formula",
|
||||
]);
|
||||
|
||||
type ConversionInfo = {
|
||||
// i18n source key (translation files key them by their exact text).
|
||||
message: string;
|
||||
// True when cells can be cleared, discarded, truncated, or have their
|
||||
// structured value flattened, i.e. the change is not safely reversible.
|
||||
// Drives the destructive (red) "Apply" button in the confirm panel.
|
||||
lossy: boolean;
|
||||
};
|
||||
|
||||
// Buckets ordered most-specific first; default covers safe reinterpretations.
|
||||
function describeConversion(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): ConversionInfo {
|
||||
if (to === "text" || to === "longText") {
|
||||
if (from === "longText" && to === "text") {
|
||||
return {
|
||||
message:
|
||||
"Cells longer than the Text limit will be truncated and the extra content permanently lost.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "select" || from === "status") {
|
||||
return { message: "Cells will be replaced with the option name.", lossy: true };
|
||||
}
|
||||
if (from === "multiSelect") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be replaced with a comma-separated list of option names.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "person") {
|
||||
return { message: "Cells will be replaced with the person's name.", lossy: true };
|
||||
}
|
||||
if (from === "file") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be replaced with a comma-separated list of file names.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
if (from === "page") {
|
||||
return { message: "Cells will be replaced with the page title.", lossy: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (to === "select" && from === "multiSelect") {
|
||||
return {
|
||||
message:
|
||||
"Only the first selected item per row will be kept; the rest will be discarded.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "multiSelect" && from === "select") {
|
||||
return {
|
||||
message: "Existing values become single-item lists. No data is lost.",
|
||||
lossy: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "page") {
|
||||
return {
|
||||
message: "Cells that aren't already a page reference will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "number" && from !== "number") {
|
||||
return {
|
||||
message: "Cells that can't be parsed as a number will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "date" && from !== "date") {
|
||||
return {
|
||||
message: "Cells that can't be parsed as a date will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (to === "checkbox" && from !== "checkbox") {
|
||||
return {
|
||||
message:
|
||||
"Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
if ((to === "url" || to === "email") && from !== to) {
|
||||
return {
|
||||
message:
|
||||
to === "url"
|
||||
? "Cells that aren't a valid URL will be cleared."
|
||||
: "Cells that aren't a valid email address will be cleared.",
|
||||
lossy: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Cells will be reinterpreted under the new type.", lossy: false };
|
||||
}
|
||||
|
||||
export function conversionWarning(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): string {
|
||||
return describeConversion(from, to).message;
|
||||
}
|
||||
|
||||
// Whether the type change can lose data, used to make "Apply" destructive.
|
||||
export function isLossyConversion(
|
||||
from: BasePropertyType,
|
||||
to: BasePropertyType,
|
||||
): boolean {
|
||||
return describeConversion(from, to).lossy;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Popover,
|
||||
TextInput,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Divider,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BasePropertyType,
|
||||
IBaseProperty,
|
||||
TypeOptions,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||
import { PropertyTypePicker } from "./property-type-picker";
|
||||
import { PropertyOptions } from "./property-options";
|
||||
import {
|
||||
getDescriptor,
|
||||
defaultTypeOptionsFor,
|
||||
propertyTypes,
|
||||
} from "@/ee/base/property-types/property-type.registry";
|
||||
import { FormulaEditor } from "../formula/formula-editor";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type CreatePropertyPopoverProps = {
|
||||
pageId: string;
|
||||
properties?: IBaseProperty[];
|
||||
onPropertyCreated?: (property: IBaseProperty) => void;
|
||||
/** Custom trigger; must return a ref-forwarding element for Popover.Target.
|
||||
* Defaults to the grid's + column button. */
|
||||
renderTarget?: (open: () => void) => React.ReactElement;
|
||||
};
|
||||
|
||||
type Panel = "typePicker" | "configure" | "confirmDiscard";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: CreatePropertyPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [panel, setPanel] = useState<Panel>("typePicker");
|
||||
const [selectedType, setSelectedType] = useState<BasePropertyType | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [typeOptions, setTypeOptions] = useState<Record<string, unknown>>({});
|
||||
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
|
||||
const [dropdownNode, setDropdownNode] = useState<HTMLDivElement | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<"bottom-start" | "top-start">(
|
||||
"bottom-start",
|
||||
);
|
||||
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
|
||||
const selectedTypeDef = useMemo(
|
||||
() => propertyTypes.find((pt) => pt.type === selectedType),
|
||||
[selectedType],
|
||||
);
|
||||
const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
|
||||
const selectedTypeIcon = selectedTypeDef?.icon;
|
||||
|
||||
const hasContent = useMemo(() => {
|
||||
return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
|
||||
}, [name, typeOptions]);
|
||||
|
||||
const nameTaken = useMemo(() => {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
if (!trimmed) return false;
|
||||
return (properties ?? []).some(
|
||||
(p) => p.name.trim().toLowerCase() === trimmed,
|
||||
);
|
||||
}, [name, properties]);
|
||||
|
||||
// Fall back to the type label when Name is blank, suffixing a counter if taken.
|
||||
const fallbackName = useMemo(() => {
|
||||
const base = selectedTypeLabel || "Property";
|
||||
const existing = new Set(
|
||||
(properties ?? []).map((p) => p.name.trim().toLowerCase()),
|
||||
);
|
||||
if (!existing.has(base.toLowerCase())) return base;
|
||||
for (let i = 1; i < 1000; i++) {
|
||||
const candidate = `${base} ${i}`;
|
||||
if (!existing.has(candidate.toLowerCase())) return candidate;
|
||||
}
|
||||
return `${base} ${Date.now()}`;
|
||||
}, [selectedTypeLabel, properties]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setPanel("typePicker");
|
||||
setSelectedType(null);
|
||||
setName("");
|
||||
setTypeOptions({});
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(event?: React.SyntheticEvent) => {
|
||||
resetState();
|
||||
const trigger = event?.currentTarget as HTMLElement | undefined;
|
||||
if (trigger) {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setPosition(spaceAbove > spaceBelow ? "top-start" : "bottom-start");
|
||||
}
|
||||
setOpened(true);
|
||||
},
|
||||
[resetState],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Don't reset state here: resetting mid-close flashes the type picker.
|
||||
// handleOpen resets on the next open instead.
|
||||
setOpened(false);
|
||||
}, []);
|
||||
|
||||
const attemptClose = useCallback(() => {
|
||||
if (panel === "configure" && hasContent) {
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}, [panel, hasContent, handleClose]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
setPanel("configure");
|
||||
}, []);
|
||||
|
||||
const handleTypeSelect = useCallback((type: BasePropertyType) => {
|
||||
setSelectedType(type);
|
||||
setTypeOptions(defaultTypeOptionsFor(type));
|
||||
setPanel("configure");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel === "configure") {
|
||||
setTimeout(() => nameInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [panel]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedType || nameTaken) return;
|
||||
const finalName = name.trim() || fallbackName;
|
||||
createPropertyMutation.mutate(
|
||||
{
|
||||
pageId,
|
||||
name: finalName,
|
||||
type: selectedType,
|
||||
typeOptions: Object.keys(typeOptions).length > 0
|
||||
? typeOptions as TypeOptions
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
onPropertyCreated?.(created);
|
||||
},
|
||||
},
|
||||
);
|
||||
handleClose();
|
||||
}, [selectedType, nameTaken, name, fallbackName, typeOptions, pageId, createPropertyMutation, handleClose, onPropertyCreated]);
|
||||
|
||||
const handleBackToTypePicker = useCallback(() => {
|
||||
setPanel("typePicker");
|
||||
setTypeOptions({});
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (panel === "confirmDiscard") {
|
||||
handleCancelDiscard();
|
||||
} else if (panel === "configure") {
|
||||
handleBackToTypePicker();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
|
||||
);
|
||||
|
||||
const handleNameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
},
|
||||
[handleCreate],
|
||||
);
|
||||
|
||||
const handleOptionsUpdate = useCallback(
|
||||
(newTypeOptions: Record<string, unknown>) => {
|
||||
setTypeOptions(newTypeOptions);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syntheticProperty: IBaseProperty = useMemo(() => ({
|
||||
id: "",
|
||||
pageId,
|
||||
name: name || "",
|
||||
type: selectedType ?? "text",
|
||||
position: "",
|
||||
typeOptions: typeOptions as TypeOptions,
|
||||
isPrimary: false,
|
||||
workspaceId: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}), [pageId, name, selectedType, typeOptions]);
|
||||
|
||||
const TypeIcon = selectedTypeIcon;
|
||||
const showOptions = !!selectedType && (getDescriptor(selectedType)?.hasOptions ?? false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) attemptClose();
|
||||
}}
|
||||
position={position}
|
||||
shadow="md"
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
withinPortal
|
||||
hideDetached={false}
|
||||
middlewares={{
|
||||
flip: false,
|
||||
shift: true,
|
||||
size: {
|
||||
padding: 8,
|
||||
apply: ({ availableHeight }) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.style.maxHeight = `${availableHeight}px`;
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
{renderTarget ? (
|
||||
renderTarget(handleOpen)
|
||||
) : (
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
)}
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
ref={setDropdownNode}
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
zIndex: 300,
|
||||
width: selectedType === "formula" ? 460 : undefined,
|
||||
minWidth: selectedType === "formula" ? undefined : 320,
|
||||
maxWidth: "calc(100vw - 32px)",
|
||||
}}
|
||||
>
|
||||
<div ref={scrollRef} style={{ overflowY: "auto", overflowX: "hidden" }}>
|
||||
{panel === "typePicker" && (
|
||||
<Stack gap={0} p={4}>
|
||||
<ScrollArea.Autosize
|
||||
mah="min(60vh, 400px)"
|
||||
scrollbarSize={6}
|
||||
offsetScrollbars
|
||||
>
|
||||
<PropertyTypePicker
|
||||
onSelect={handleTypeSelect}
|
||||
showSearch
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "configure" && selectedType === "formula" && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<TextInput
|
||||
ref={nameInputRef}
|
||||
size="xs"
|
||||
label={t("Name")}
|
||||
placeholder={fallbackName}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
error={nameTaken ? t("A property with this name already exists") : undefined}
|
||||
/>
|
||||
<FormulaEditor
|
||||
properties={properties ?? []}
|
||||
editingPropertyId={null}
|
||||
name={name.trim() || undefined}
|
||||
onCancel={handleBackToTypePicker}
|
||||
disabled={nameTaken}
|
||||
onSave={(source, ast, resultType, dependencies) => {
|
||||
if (nameTaken) return;
|
||||
createPropertyMutation.mutate(
|
||||
{
|
||||
pageId,
|
||||
name: name.trim() || fallbackName,
|
||||
type: "formula",
|
||||
typeOptions: {
|
||||
source,
|
||||
ast,
|
||||
resultType,
|
||||
dependencies,
|
||||
astVersion: 1,
|
||||
} as TypeOptions,
|
||||
},
|
||||
{ onSuccess: (created) => onPropertyCreated?.(created) },
|
||||
);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
|
||||
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||
<TextInput
|
||||
ref={nameInputRef}
|
||||
size="xs"
|
||||
label={t("Name")}
|
||||
placeholder={fallbackName}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
error={nameTaken ? t("A property with this name already exists") : undefined}
|
||||
mb="xs"
|
||||
/>
|
||||
<UnstyledButton
|
||||
onClick={handleBackToTypePicker}
|
||||
py={6}
|
||||
px={0}
|
||||
mb={showOptions ? "xs" : 0}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{TypeIcon && <TypeIcon size={14} />}
|
||||
<Text size="sm" style={{ flex: 1 }}>
|
||||
{selectedTypeLabel}
|
||||
</Text>
|
||||
<IconChevronRight size={14} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{showOptions && (
|
||||
<>
|
||||
<Divider mb="xs" />
|
||||
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
|
||||
<PropertyOptions
|
||||
property={syntheticProperty}
|
||||
onUpdate={handleOptionsUpdate}
|
||||
onClose={noop}
|
||||
onDirtyChange={noop}
|
||||
hideButtons
|
||||
dropdownPortalTarget={dropdownNode}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my="xs" />
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={attemptClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleCreate} disabled={nameTaken}>
|
||||
{t("Create property")}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Group, MultiSelect, Select, Text } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Choice } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ComboboxItem } from "@mantine/core";
|
||||
|
||||
type DefaultValuePickerProps = {
|
||||
choices: Choice[];
|
||||
value: string | string[] | null;
|
||||
multiple?: boolean;
|
||||
onChange: (value: string | string[] | null) => void;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function DefaultValuePicker({
|
||||
choices,
|
||||
value,
|
||||
multiple,
|
||||
onChange,
|
||||
dropdownPortalTarget,
|
||||
}: DefaultValuePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = choices.map((c) => ({ value: c.id, label: c.name }));
|
||||
const comboboxProps = {
|
||||
portalProps: { target: dropdownPortalTarget ?? undefined },
|
||||
};
|
||||
|
||||
const renderOption = ({
|
||||
option,
|
||||
checked,
|
||||
}: {
|
||||
option: ComboboxItem;
|
||||
checked?: boolean;
|
||||
}) => {
|
||||
const choice = choices.find((c) => c.id === option.value);
|
||||
const colors = choice ? choiceColor(choice.color) : undefined;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="space-between" style={{ flex: 1 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{colors && (
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.backgroundColor as string,
|
||||
border: `2px solid ${colors.color as string}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs">{option.label}</Text>
|
||||
</Group>
|
||||
{checked && (
|
||||
<IconCheck size={14} color="var(--mantine-color-dimmed)" />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
if (multiple) {
|
||||
const selected = (
|
||||
Array.isArray(value) ? value : value ? [value] : []
|
||||
).filter((id) => choices.some((c) => c.id === id));
|
||||
return (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={selected.length ? undefined : t("None")}
|
||||
data={data}
|
||||
value={selected}
|
||||
onChange={(vals) => onChange(vals.length ? vals : null)}
|
||||
clearable
|
||||
comboboxProps={comboboxProps}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const single =
|
||||
typeof value === "string" && choices.some((c) => c.id === value)
|
||||
? value
|
||||
: null;
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
data={data}
|
||||
value={single}
|
||||
onChange={(val) => onChange(val)}
|
||||
clearable
|
||||
comboboxProps={comboboxProps}
|
||||
renderOption={renderOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconTrash,
|
||||
IconPencil,
|
||||
IconChevronRight,
|
||||
IconSettings,
|
||||
IconMathFunction,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
BasePropertyType,
|
||||
TypeOptions,
|
||||
SelectTypeOptions,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import {
|
||||
useUpdatePropertyMutation,
|
||||
useDeletePropertyMutation,
|
||||
} from "@/ee/base/queries/base-property-query";
|
||||
import { PropertyTypePicker } from "./property-type-picker";
|
||||
import { PropertyOptions } from "./property-options";
|
||||
import {
|
||||
conversionWarning,
|
||||
isLossyConversion,
|
||||
NON_USER_TARGET_TYPES,
|
||||
} from "./conversion-warning";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
isSystemPropertyType,
|
||||
propertyTypes,
|
||||
defaultTypeOptionsFor,
|
||||
} from "@/ee/base/property-types/property-type.registry";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
import classes from "@/ee/base/styles/property.module.css";
|
||||
|
||||
type PropertyMenuContentProps = {
|
||||
property: IBaseProperty;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onEditFormula?: () => void;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
type MenuPanel =
|
||||
| "main"
|
||||
| "rename"
|
||||
| "options"
|
||||
| "changeType"
|
||||
| "confirmTypeChange"
|
||||
| "confirmDelete"
|
||||
| "confirmDiscard";
|
||||
|
||||
const CHOICE_TYPES = new Set<BasePropertyType>([
|
||||
"select",
|
||||
"multiSelect",
|
||||
"status",
|
||||
]);
|
||||
|
||||
function typeOptionsForConversion(
|
||||
source: IBaseProperty,
|
||||
target: BasePropertyType,
|
||||
): TypeOptions {
|
||||
if (!CHOICE_TYPES.has(source.type) || !CHOICE_TYPES.has(target)) {
|
||||
return defaultTypeOptionsFor(target);
|
||||
}
|
||||
const opts = source.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = opts?.choices ?? [];
|
||||
const choiceOrder = opts?.choiceOrder?.length
|
||||
? opts.choiceOrder
|
||||
: choices.map((c) => c.id);
|
||||
const carried: SelectTypeOptions = { choices, choiceOrder };
|
||||
if (target === "status") {
|
||||
carried.defaultValue = choices[0]?.id ?? null;
|
||||
}
|
||||
return carried;
|
||||
}
|
||||
|
||||
export function PropertyMenuContent({
|
||||
property,
|
||||
opened,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
onEditFormula,
|
||||
pageId,
|
||||
}: 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);
|
||||
// Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
|
||||
const [optionsAnchor, setOptionsAnchor] = useState<HTMLDivElement | null>(null);
|
||||
const [pendingTargetType, setPendingTargetType] = useState<BasePropertyType | null>(null);
|
||||
const pendingActionRef = useRef<"back" | "close" | null>(null);
|
||||
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
|
||||
const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number];
|
||||
const closeRequestRef = useRef(closeRequest);
|
||||
|
||||
const renameDirty = renameValue !== property.name;
|
||||
|
||||
const updatePropertyMutation = useUpdatePropertyMutation();
|
||||
const deletePropertyMutation = useDeletePropertyMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setPanel("main");
|
||||
setRenameValue(property.name);
|
||||
setOptionsDirty(false);
|
||||
setPendingTargetType(null);
|
||||
}
|
||||
}, [opened, property.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel === "rename") {
|
||||
setTimeout(() => renameInputRef.current?.select(), 0);
|
||||
}
|
||||
}, [panel]);
|
||||
|
||||
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
|
||||
setOptionsDirty(dirty);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const dirty =
|
||||
(panel === "rename" && renameDirty) ||
|
||||
(panel === "options" && optionsDirty);
|
||||
onDirtyChange?.(dirty);
|
||||
}, [panel, renameDirty, optionsDirty, onDirtyChange]);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== property.name) {
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
pageId: property.pageId,
|
||||
name: trimmed,
|
||||
});
|
||||
}
|
||||
}, [renameValue, property, updatePropertyMutation]);
|
||||
|
||||
const handleRenameAndClose = useCallback(() => {
|
||||
commitRename();
|
||||
onClose();
|
||||
}, [commitRename, onClose]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (panel === "rename" && renameDirty) {
|
||||
sourcePanelRef.current = "rename";
|
||||
pendingActionRef.current = "close";
|
||||
setPanel("confirmDiscard");
|
||||
} else if (panel === "options" && optionsDirty) {
|
||||
sourcePanelRef.current = "options";
|
||||
pendingActionRef.current = "close";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [panel, renameDirty, optionsDirty, onClose]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameAndClose();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
requestClose();
|
||||
}
|
||||
},
|
||||
[handleRenameAndClose, requestClose],
|
||||
);
|
||||
|
||||
const handleOptionsUpdate = useCallback(
|
||||
(typeOptions: Record<string, unknown>) => {
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
pageId: property.pageId,
|
||||
typeOptions,
|
||||
});
|
||||
setOptionsDirty(false);
|
||||
},
|
||||
[property, updatePropertyMutation],
|
||||
);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type: BasePropertyType) => {
|
||||
if (type === property.type) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setPendingTargetType(type);
|
||||
setPanel("confirmTypeChange");
|
||||
},
|
||||
[property.type, onClose],
|
||||
);
|
||||
|
||||
const handleApplyTypeChange = useCallback(() => {
|
||||
if (!pendingTargetType) return;
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
pageId: property.pageId,
|
||||
type: pendingTargetType,
|
||||
typeOptions: typeOptionsForConversion(property, pendingTargetType),
|
||||
});
|
||||
onClose();
|
||||
}, [pendingTargetType, property, updatePropertyMutation, onClose]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deletePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
pageId: property.pageId,
|
||||
});
|
||||
onClose();
|
||||
}, [property, deletePropertyMutation, onClose]);
|
||||
|
||||
const handleOptionsBack = useCallback(() => {
|
||||
if (optionsDirty) {
|
||||
sourcePanelRef.current = "options";
|
||||
pendingActionRef.current = "back";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
setPanel("main");
|
||||
}
|
||||
}, [optionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeRequest !== closeRequestRef.current) {
|
||||
closeRequestRef.current = closeRequest;
|
||||
if (opened) {
|
||||
requestClose();
|
||||
}
|
||||
}
|
||||
}, [closeRequest, opened, requestClose]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
setOptionsDirty(false);
|
||||
setRenameValue(property.name);
|
||||
const action = pendingActionRef.current;
|
||||
pendingActionRef.current = null;
|
||||
sourcePanelRef.current = null;
|
||||
if (action === "back") {
|
||||
setPanel("main");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [property.name, onClose]);
|
||||
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
const source = sourcePanelRef.current ?? "options";
|
||||
pendingActionRef.current = null;
|
||||
sourcePanelRef.current = null;
|
||||
setPanel(source);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{panel === "main" && (
|
||||
<MainPanel
|
||||
property={property}
|
||||
onRename={() => setPanel("rename")}
|
||||
onChangeType={() => setPanel("changeType")}
|
||||
onOptions={() => setPanel("options")}
|
||||
onDelete={() => setPanel("confirmDelete")}
|
||||
onEditFormula={onEditFormula}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={requestClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleRenameAndClose}
|
||||
disabled={!renameValue.trim() || renameValue.trim() === property.name}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "changeType" && (
|
||||
<Stack gap={0} p={4}>
|
||||
<Group gap="xs" px="sm" py={6}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => setPanel("main")}
|
||||
>
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
className={classes.chevronBack}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Change type")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
|
||||
<PropertyTypePicker
|
||||
onSelect={handleTypeSelect}
|
||||
currentType={property.type}
|
||||
excludeTypes={NON_USER_TARGET_TYPES}
|
||||
showSearch
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "confirmTypeChange" && pendingTargetType && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Change type to {{label}}?", {
|
||||
label: t(
|
||||
propertyTypes.find((pt) => pt.type === pendingTargetType)
|
||||
?.labelKey ?? pendingTargetType,
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(conversionWarning(property.type, pendingTargetType))}
|
||||
</Text>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => setPanel("main")}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
isLossyConversion(property.type, pendingTargetType)
|
||||
? "red"
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApplyTypeChange}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{(panel === "options" || panel === "confirmDiscard") && (
|
||||
<Stack
|
||||
ref={setOptionsAnchor}
|
||||
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}
|
||||
className={classes.chevronBack}
|
||||
/>
|
||||
</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}
|
||||
dropdownPortalTarget={optionsAnchor}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onEditFormula,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onRename: () => void;
|
||||
onChangeType: () => void;
|
||||
onOptions: () => void;
|
||||
onDelete: () => void;
|
||||
onEditFormula?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSystem = isSystemPropertyType(property.type);
|
||||
const isPending = property.pendingType != null;
|
||||
|
||||
const hasOptions =
|
||||
!isSystem &&
|
||||
!isPending &&
|
||||
(property.type === "select" ||
|
||||
property.type === "multiSelect" ||
|
||||
property.type === "status" ||
|
||||
property.type === "number" ||
|
||||
property.type === "date" ||
|
||||
property.type === "text" ||
|
||||
property.type === "longText" ||
|
||||
property.type === "checkbox" ||
|
||||
property.type === "url" ||
|
||||
property.type === "email");
|
||||
|
||||
const typeDef = propertyTypes.find((pt) => pt.type === property.type);
|
||||
const TypeIcon = typeDef?.icon;
|
||||
|
||||
return (
|
||||
<Stack gap={0} p={4}>
|
||||
<MenuItem
|
||||
icon={<IconPencil size={14} />}
|
||||
label={t("Rename")}
|
||||
onClick={onRename}
|
||||
/>
|
||||
{property.type === "formula" && !isPending && onEditFormula && (
|
||||
<MenuItem
|
||||
icon={<IconMathFunction size={14} />}
|
||||
label={t("Edit formula")}
|
||||
onClick={onEditFormula}
|
||||
/>
|
||||
)}
|
||||
{isPending && (
|
||||
<Group gap={8} px="sm" py={8}>
|
||||
<Loader size={12} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Converting…")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!isSystem && !isPending && !property.isPrimary && (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={onChangeType}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
{TypeIcon ? <TypeIcon size={14} /> : null}
|
||||
<Text size="sm">
|
||||
{typeDef ? t(typeDef.labelKey) : property.type}
|
||||
</Text>
|
||||
</Group>
|
||||
<IconChevronRight size={14} />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{hasOptions && (
|
||||
<MenuItem
|
||||
icon={<IconSettings size={14} />}
|
||||
label={t("Options")}
|
||||
rightIcon={<IconChevronRight size={14} />}
|
||||
onClick={onOptions}
|
||||
/>
|
||||
)}
|
||||
{!property.isPrimary && !isPending && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<MenuItem
|
||||
icon={<IconTrash size={14} />}
|
||||
label={t("Delete property")}
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Divider,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
PersonTypeOptions,
|
||||
Choice,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { ChoiceEditor } from "./choice-editor";
|
||||
import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
|
||||
import {
|
||||
CURRENCIES,
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
} from "@/ee/base/constants/currencies";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PropertyOptionsProps = {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
// Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function PropertyOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: PropertyOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (property.type) {
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<SelectOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<StatusOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "person":
|
||||
return (
|
||||
<PersonOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
case "longText":
|
||||
case "url":
|
||||
case "email":
|
||||
return (
|
||||
<TextDefaultOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckboxOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No options for this property type")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type OptionEditorProps = {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
dropdownPortalTarget?: HTMLElement | null;
|
||||
};
|
||||
|
||||
const EMPTY_OPTIONS: Record<string, unknown> = {};
|
||||
|
||||
function optionsEqual(
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>,
|
||||
): boolean {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const key of keys) {
|
||||
const av = a[key];
|
||||
const bv = b[key];
|
||||
if (Array.isArray(av) && Array.isArray(bv)) {
|
||||
if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
|
||||
return false;
|
||||
}
|
||||
} else if (av !== bv) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
|
||||
function useEditableTypeOptions(
|
||||
initialRaw: Record<string, unknown> | undefined,
|
||||
{
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
onUpdate: (opts: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
},
|
||||
) {
|
||||
const initial = initialRaw ?? EMPTY_OPTIONS;
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideButtons) setDraft(initial);
|
||||
}, [initial, hideButtons]);
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
useEffect(() => {
|
||||
if (hideButtons) onUpdateRef.current(draft);
|
||||
}, [hideButtons, draft]);
|
||||
|
||||
const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Record<string, unknown>) =>
|
||||
setDraft((prev) => ({ ...prev, ...patch })),
|
||||
[],
|
||||
);
|
||||
const save = useCallback(() => {
|
||||
onUpdate(draft);
|
||||
onClose();
|
||||
}, [draft, onUpdate, onClose]);
|
||||
const cancel = useCallback(() => {
|
||||
setDraft(initial);
|
||||
onDirtyChange?.(false);
|
||||
onClose();
|
||||
}, [initial, onClose, onDirtyChange]);
|
||||
|
||||
return { draft, update, isDirty, save, cancel };
|
||||
}
|
||||
|
||||
function OptionsFooter({
|
||||
isDirty,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
isDirty: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={onCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={onSave} disabled={!isDirty}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[], defaultValue: string | string[] | null) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
defaultValue,
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories={false}
|
||||
hideButtons={hideButtons}
|
||||
initialDefaultValue={options?.defaultValue ?? null}
|
||||
multiDefault={property.type === "multiSelect"}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[], defaultValue: string | string[] | null) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
defaultValue,
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories
|
||||
hideButtons={hideButtons}
|
||||
initialDefaultValue={options?.defaultValue ?? null}
|
||||
dropdownPortalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as NumberTypeOptions;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Format")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
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) => update({ format: val ?? "plain" })}
|
||||
/>
|
||||
{options.format === "currency" && (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Currency")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={CURRENCIES.map((c) => ({
|
||||
value: c.code,
|
||||
label: `${c.name} (${c.code})`,
|
||||
}))}
|
||||
value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
|
||||
onChange={(val) =>
|
||||
update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Thousands and decimal separators")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "none", label: t("None") },
|
||||
{ value: "local", label: t("Local") },
|
||||
{ value: "comma_period", label: t("Comma, period") },
|
||||
{ value: "period_comma", label: t("Period, comma") },
|
||||
{ value: "space_comma", label: t("Space, comma") },
|
||||
{ value: "space_period", label: t("Space, period") },
|
||||
]}
|
||||
value={options.separators ?? "none"}
|
||||
onChange={(val) => update({ separators: val ?? "none" })}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Decimal places")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "default", label: t("Default") },
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: String(i),
|
||||
})),
|
||||
]}
|
||||
value={options.precision == null ? "default" : String(options.precision)}
|
||||
onChange={(val) =>
|
||||
update({ precision: val == null || val === "default" ? undefined : Number(val) })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
value={typeof options.defaultValue === "number" ? options.defaultValue : ""}
|
||||
onChange={(val) =>
|
||||
update({ defaultValue: typeof val === "number" ? val : undefined })
|
||||
}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DateOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as DateTypeOptions;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Include time")}
|
||||
checked={options.includeTime ?? false}
|
||||
onChange={(e) => update({ includeTime: e.currentTarget.checked })}
|
||||
/>
|
||||
{options.includeTime && (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Time format")}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ portalProps: { target: dropdownPortalTarget ?? undefined } }}
|
||||
data={[
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
]}
|
||||
value={options.timeFormat ?? "12h"}
|
||||
onChange={(val) => update({ timeFormat: val ?? "12h" })}
|
||||
/>
|
||||
)}
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
dropdownPortalTarget,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const options = draft as PersonTypeOptions;
|
||||
const allowMultiple = options.allowMultiple === true;
|
||||
|
||||
const handleAllowMultipleChange = (toMulti: boolean) => {
|
||||
const dv = options.defaultValue;
|
||||
const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
|
||||
update({
|
||||
allowMultiple: toMulti,
|
||||
defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Allow multiple people")}
|
||||
checked={allowMultiple}
|
||||
onChange={(e) => handleAllowMultipleChange(e.currentTarget.checked)}
|
||||
/>
|
||||
<FilterPersonInput
|
||||
pageId={property.pageId}
|
||||
multiple={allowMultiple}
|
||||
value={options.defaultValue ?? null}
|
||||
onChange={(value) =>
|
||||
update({ defaultValue: value as string | string[] | undefined })
|
||||
}
|
||||
placeholder={t("None")}
|
||||
label={t("Default value")}
|
||||
w="100%"
|
||||
portalTarget={dropdownPortalTarget}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function TextDefaultOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
const defaultValue =
|
||||
typeof draft.defaultValue === "string" ? draft.defaultValue : "";
|
||||
const defaultValueError =
|
||||
defaultValue && property.type === "url" && !URL.canParse(defaultValue)
|
||||
? t("Please enter a valid url")
|
||||
: defaultValue &&
|
||||
property.type === "email" &&
|
||||
!EMAIL_FORMAT.test(defaultValue)
|
||||
? t("Please enter a valid email")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{property.type === "longText" ? (
|
||||
<Textarea
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={t("None")}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
value={defaultValue}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
defaultValue: e.currentTarget.value.trim()
|
||||
? e.currentTarget.value
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
size="xs"
|
||||
label={t("Default value")}
|
||||
placeholder={
|
||||
property.type === "url"
|
||||
? "https://example.com"
|
||||
: property.type === "email"
|
||||
? "name@example.com"
|
||||
: t("None")
|
||||
}
|
||||
value={defaultValue}
|
||||
error={defaultValueError}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
defaultValue: e.currentTarget.value.trim()
|
||||
? e.currentTarget.value
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!hideButtons && (
|
||||
<OptionsFooter
|
||||
isDirty={isDirty && !defaultValueError}
|
||||
onCancel={cancel}
|
||||
onSave={save}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: OptionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
|
||||
property.typeOptions as Record<string, unknown> | undefined,
|
||||
{ onUpdate, onClose, onDirtyChange, hideButtons },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Checked by default")}
|
||||
checked={draft.defaultValue === true}
|
||||
onChange={(e) =>
|
||||
update({ defaultValue: e.currentTarget.checked ? true : undefined })
|
||||
}
|
||||
/>
|
||||
{!hideButtons && (
|
||||
<OptionsFooter isDirty={isDirty} onCancel={cancel} onSave={save} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
||||
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||
import { BasePropertyType } from "@/ee/base/types/base.types";
|
||||
import { propertyTypes } from "@/ee/base/property-types/property-type.registry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import classes from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
type PropertyTypePickerProps = {
|
||||
onSelect: (type: BasePropertyType) => void;
|
||||
currentType?: BasePropertyType;
|
||||
excludeTypes?: Set<BasePropertyType>;
|
||||
showSearch?: boolean;
|
||||
};
|
||||
|
||||
export function PropertyTypePicker({
|
||||
onSelect,
|
||||
currentType,
|
||||
excludeTypes,
|
||||
showSearch,
|
||||
}: PropertyTypePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSearch) {
|
||||
setTimeout(() => searchRef.current?.focus(), 0);
|
||||
}
|
||||
}, [showSearch]);
|
||||
|
||||
const types = propertyTypes
|
||||
.filter(({ type }) => !excludeTypes?.has(type))
|
||||
.filter(({ labelKey }) =>
|
||||
!search || t(labelKey).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
size="xs"
|
||||
placeholder={t("Find a property type")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
mx="sm"
|
||||
mt="sm"
|
||||
mb={4}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user