mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 09:17:06 +08:00
592 lines
16 KiB
TypeScript
592 lines
16 KiB
TypeScript
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;
|
|
initialPanel?: "main" | "options";
|
|
};
|
|
|
|
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,
|
|
initialPanel,
|
|
}: PropertyMenuContentProps) {
|
|
const { t } = useTranslation();
|
|
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "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(initialPanel ?? "main");
|
|
setRenameValue(property.name);
|
|
setOptionsDirty(false);
|
|
setPendingTargetType(null);
|
|
}
|
|
}, [opened, property.name, initialPanel]);
|
|
|
|
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";
|
|
|
|
export 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>
|
|
);
|
|
}
|
|
|