mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
WIP
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
||||
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
||||
@@ -45,7 +44,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,14 +74,6 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
createRowMutation.mutate({ baseId });
|
||||
}, [baseId, createRowMutation]);
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
createPropertyMutation.mutate({
|
||||
baseId,
|
||||
name: t("New property"),
|
||||
type: "text",
|
||||
});
|
||||
}, [baseId, createPropertyMutation, t]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(viewId: string) => {
|
||||
setActiveViewId(viewId);
|
||||
@@ -188,7 +178,7 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
table={table}
|
||||
onCellUpdate={handleCellUpdate}
|
||||
onAddRow={handleAddRow}
|
||||
onAddColumn={handleAddColumn}
|
||||
baseId={baseId}
|
||||
onColumnReorder={handleColumnReorder}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onRowReorder={handleRowReorder}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import {
|
||||
IBaseProperty,
|
||||
PersonTypeOptions,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
@@ -17,10 +20,14 @@ type CellPersonProps = {
|
||||
|
||||
export function CellPerson({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellPersonProps) {
|
||||
const allowMultiple =
|
||||
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
|
||||
|
||||
const personIds = Array.isArray(value)
|
||||
? (value as string[])
|
||||
: typeof value === "string"
|
||||
@@ -53,31 +60,38 @@ export function CellPerson({
|
||||
)
|
||||
: members;
|
||||
|
||||
const handleAdd = useCallback(
|
||||
const handleSelect = useCallback(
|
||||
(memberId: string) => {
|
||||
if (personIds.includes(memberId)) return;
|
||||
onCommit([...personIds, memberId]);
|
||||
if (allowMultiple) {
|
||||
// Multi mode: toggle add/remove
|
||||
if (personIds.includes(memberId)) {
|
||||
const newIds = personIds.filter((id) => id !== memberId);
|
||||
onCommit(newIds.length > 0 ? newIds : null);
|
||||
} else {
|
||||
onCommit([...personIds, memberId]);
|
||||
}
|
||||
} else {
|
||||
// Single mode: replace or clear
|
||||
if (personIds.includes(memberId)) {
|
||||
onCommit(null);
|
||||
} else {
|
||||
onCommit(memberId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[personIds, onCommit],
|
||||
[allowMultiple, personIds, onCommit],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(memberId: string) => {
|
||||
const newIds = personIds.filter((id) => id !== memberId);
|
||||
onCommit(newIds.length > 0 ? newIds : null);
|
||||
},
|
||||
[personIds, onCommit],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(memberId: string) => {
|
||||
if (personIds.includes(memberId)) {
|
||||
handleRemove(memberId);
|
||||
if (allowMultiple) {
|
||||
const newIds = personIds.filter((id) => id !== memberId);
|
||||
onCommit(newIds.length > 0 ? newIds : null);
|
||||
} else {
|
||||
handleAdd(memberId);
|
||||
onCommit(null);
|
||||
}
|
||||
},
|
||||
[personIds, handleAdd, handleRemove],
|
||||
[allowMultiple, personIds, onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -150,9 +164,11 @@ export function CellPerson({
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className={cellClasses.personDropdownDivider} />
|
||||
<div className={cellClasses.personDropdownHint}>
|
||||
Select as many as you like
|
||||
</div>
|
||||
{allowMultiple && (
|
||||
<div className={cellClasses.personDropdownHint}>
|
||||
Select as many as you like
|
||||
</div>
|
||||
)}
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filteredMembers.map((member) => (
|
||||
<div
|
||||
@@ -160,7 +176,7 @@ export function CellPerson({
|
||||
className={`${cellClasses.selectOption} ${
|
||||
selectedSet.has(member.id) ? cellClasses.selectOptionActive : ""
|
||||
}`}
|
||||
onClick={() => handleToggle(member.id)}
|
||||
onClick={() => handleSelect(member.id)}
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={member.avatarUrl}
|
||||
|
||||
@@ -33,7 +33,7 @@ type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
onAddColumn?: () => void;
|
||||
baseId?: string;
|
||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
@@ -46,7 +46,7 @@ export function GridContainer({
|
||||
table,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
onAddColumn,
|
||||
baseId,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
@@ -115,8 +115,8 @@ export function GridContainer({
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||
return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
|
||||
return columnWidths.join(" ") + (baseId ? " 40px" : "");
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
|
||||
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
@@ -148,6 +148,18 @@ export function GridContainer({
|
||||
onAddRow?.();
|
||||
}, [onAddRow]);
|
||||
|
||||
const handlePropertyCreated = useCallback(() => {
|
||||
// Wait for React to re-render with the new column, then scroll to it
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
left: scrollRef.current.scrollWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
@@ -194,7 +206,12 @@ export function GridContainer({
|
||||
items={sortableColumnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<GridHeader table={table} onAddColumn={onAddColumn} />
|
||||
<GridHeader
|
||||
table={table}
|
||||
baseId={baseId}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
onPropertyCreated={handlePropertyCreated}
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
{paddingTop > 0 && (
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { memo } from "react";
|
||||
import { Table, ColumnOrderState } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { GridHeaderCell } from "./grid-header-cell";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onAddColumn?: () => void;
|
||||
baseId?: string;
|
||||
// Passed explicitly to break memo when columns change
|
||||
// (table ref is stable from useReactTable, so memo won't fire without this)
|
||||
columnOrder: ColumnOrderState;
|
||||
onPropertyCreated?: () => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
table,
|
||||
onAddColumn,
|
||||
baseId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnOrder: _columnOrder,
|
||||
onPropertyCreated,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
onAddColumn?.();
|
||||
}, [onAddColumn]);
|
||||
|
||||
return (
|
||||
<div className={classes.headerRow} role="row">
|
||||
{headerGroups[0]?.headers.map((header) => (
|
||||
<GridHeaderCell key={header.id} header={header} />
|
||||
))}
|
||||
{onAddColumn && (
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleAddColumn}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
{baseId && (
|
||||
<CreatePropertyPopover
|
||||
baseId={baseId}
|
||||
onPropertyCreated={onPropertyCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ type ChoiceEditorProps = {
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
showCategories?: boolean;
|
||||
hideButtons?: boolean;
|
||||
};
|
||||
|
||||
export function ChoiceEditor({
|
||||
@@ -62,14 +63,28 @@ export function ChoiceEditor({
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
showCategories = false,
|
||||
hideButtons = false,
|
||||
}: ChoiceEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
||||
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
||||
|
||||
// Sync from parent only when not in live mode (hideButtons = create flow)
|
||||
useEffect(() => {
|
||||
setDraft(initialChoices);
|
||||
}, [initialChoices]);
|
||||
if (!hideButtons) {
|
||||
setDraft(initialChoices);
|
||||
}
|
||||
}, [initialChoices, hideButtons]);
|
||||
|
||||
// In live mode, propagate draft changes to parent immediately
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideButtons) {
|
||||
onSaveRef.current(draft.filter((c) => c.name.trim()));
|
||||
}
|
||||
}, [hideButtons, draft]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (draft.length !== initialChoices.length) return true;
|
||||
@@ -195,16 +210,20 @@ export function ChoiceEditor({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
{!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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Portal,
|
||||
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 "@/features/base/types/base.types";
|
||||
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||
import { PropertyOptions } from "./property-options";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type CreatePropertyPopoverProps = {
|
||||
baseId: string;
|
||||
onPropertyCreated?: () => void;
|
||||
};
|
||||
|
||||
type Panel = "typePicker" | "configure" | "confirmDiscard";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Keep in sync with the switch cases in PropertyOptions
|
||||
const typesWithOptions = new Set<BasePropertyType>([
|
||||
"select",
|
||||
"multiSelect",
|
||||
"status",
|
||||
"number",
|
||||
"date",
|
||||
"person",
|
||||
]);
|
||||
|
||||
export function CreatePropertyPopover({ baseId, onPropertyCreated }: 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>>({});
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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 resetState = useCallback(() => {
|
||||
setPanel("typePicker");
|
||||
setSelectedType(null);
|
||||
setName("");
|
||||
setTypeOptions({});
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
resetState();
|
||||
setOpened(true);
|
||||
}, [resetState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpened(false);
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
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({});
|
||||
setPanel("configure");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel === "configure") {
|
||||
setTimeout(() => nameInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [panel]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedType) return;
|
||||
const finalName = name.trim() || selectedTypeLabel;
|
||||
createPropertyMutation.mutate(
|
||||
{
|
||||
baseId,
|
||||
name: finalName,
|
||||
type: selectedType,
|
||||
typeOptions: Object.keys(typeOptions).length > 0
|
||||
? typeOptions as TypeOptions
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onPropertyCreated?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
handleClose();
|
||||
}, [selectedType, name, selectedTypeLabel, typeOptions, baseId, 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: "",
|
||||
baseId,
|
||||
name: name || "",
|
||||
type: selectedType ?? "text",
|
||||
position: "",
|
||||
typeOptions: typeOptions as TypeOptions,
|
||||
isPrimary: false,
|
||||
workspaceId: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}), [baseId, name, selectedType, typeOptions]);
|
||||
|
||||
const TypeIcon = selectedTypeIcon;
|
||||
const showOptions = selectedType && typesWithOptions.has(selectedType);
|
||||
|
||||
return (
|
||||
<>
|
||||
{opened && (
|
||||
<Portal>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 299,
|
||||
}}
|
||||
onClick={attemptClose}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={noop}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={320}
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ zIndex: 300 }}
|
||||
>
|
||||
{panel === "typePicker" && (
|
||||
<Stack gap={0} p={4}>
|
||||
<PropertyTypePicker
|
||||
onSelect={handleTypeSelect}
|
||||
showSearch
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{(panel === "configure" || panel === "confirmDiscard") && (
|
||||
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||
<TextInput
|
||||
ref={nameInputRef}
|
||||
size="xs"
|
||||
placeholder={selectedTypeLabel}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
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
|
||||
/>
|
||||
</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}>
|
||||
{t("Create field")}
|
||||
</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>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
PersonTypeOptions,
|
||||
Choice,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { ChoiceEditor } from "./choice-editor";
|
||||
@@ -15,9 +16,10 @@ type PropertyOptionsProps = {
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
};
|
||||
|
||||
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }: PropertyOptionsProps) {
|
||||
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange, hideButtons }: PropertyOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (property.type) {
|
||||
@@ -29,6 +31,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
@@ -38,6 +41,7 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
@@ -54,6 +58,13 @@ export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }:
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
case "person":
|
||||
return (
|
||||
<PersonOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -68,11 +79,13 @@ function SelectOptions({
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
@@ -95,6 +108,7 @@ function SelectOptions({
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories={false}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -104,11 +118,13 @@ function StatusOptions({
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
@@ -131,6 +147,7 @@ function StatusOptions({
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -215,3 +232,30 @@ function DateOptions({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = property.typeOptions as PersonTypeOptions | undefined;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Allow multiple people")}
|
||||
checked={options?.allowMultiple !== false}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
allowMultiple: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UnstyledButton, Group, Text } from "@mantine/core";
|
||||
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
IconCheck,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { BasePropertyType } from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import classes from "@/features/base/styles/cells.module.css";
|
||||
|
||||
const propertyTypes: {
|
||||
@@ -45,21 +47,46 @@ 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);
|
||||
|
||||
const types = excludeTypes
|
||||
? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
|
||||
: propertyTypes;
|
||||
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 field 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}
|
||||
|
||||
@@ -33,10 +33,19 @@ export function useBaseRowsQuery(
|
||||
filters?: ViewFilterConfig[],
|
||||
sorts?: ViewSortConfig[],
|
||||
) {
|
||||
// Normalize empty arrays to undefined so query keys stay stable
|
||||
const activeFilters = filters?.length ? filters : undefined;
|
||||
const activeSorts = sorts?.length ? sorts : undefined;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["base-rows", baseId, filters, sorts],
|
||||
queryKey: ["base-rows", baseId, activeFilters, activeSorts],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(baseId!, { cursor: pageParam, limit: 100, filters, sorts }),
|
||||
listRows(baseId!, {
|
||||
cursor: pageParam,
|
||||
limit: 100,
|
||||
filters: activeFilters,
|
||||
sorts: activeSorts,
|
||||
}),
|
||||
enabled: !!baseId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||
|
||||
@@ -59,6 +59,10 @@ export type EmailTypeOptions = {
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
|
||||
export type PersonTypeOptions = {
|
||||
allowMultiple?: boolean;
|
||||
};
|
||||
|
||||
export type TypeOptions =
|
||||
| SelectTypeOptions
|
||||
| NumberTypeOptions
|
||||
@@ -67,6 +71,7 @@ export type TypeOptions =
|
||||
| CheckboxTypeOptions
|
||||
| UrlTypeOptions
|
||||
| EmailTypeOptions
|
||||
| PersonTypeOptions
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type IBaseProperty = {
|
||||
|
||||
Reference in New Issue
Block a user