mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 10:26:25 +08:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldText } from "./field-text";
|
||||
import { FieldLongText } from "./field-long-text";
|
||||
import { FieldNumber } from "./field-number";
|
||||
import { FieldDate } from "./field-date";
|
||||
import { FieldChoice } from "./field-choice";
|
||||
import { FieldCellAdapter } from "./field-cell-adapter";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
export type FieldProps = {
|
||||
property: IBaseProperty;
|
||||
value: unknown;
|
||||
rowId: string;
|
||||
readOnly: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
type FieldShellProps = {
|
||||
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
|
||||
cursor?: "text" | "pointer" | "default";
|
||||
/** Popover open — keeps the focus ring while focus is in the portal. */
|
||||
active?: boolean;
|
||||
locked?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
|
||||
// ref injected into this element; without it the picker renders at (0,0).
|
||||
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
|
||||
function FieldShell(
|
||||
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
classes.fieldShell,
|
||||
cursor === "text" && classes.fieldShellText,
|
||||
cursor === "pointer" && classes.fieldShellPointer,
|
||||
active && classes.fieldShellActive,
|
||||
locked && classes.fieldShellLocked,
|
||||
alignTop && classes.fieldShellTop,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
|
||||
const checked = value === true;
|
||||
return (
|
||||
<FieldShell>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={readOnly}
|
||||
onChange={() => onChange(!checked)}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
return (
|
||||
<FieldShell locked>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
{CellComponent && (
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={false}
|
||||
readOnly
|
||||
onCommit={() => {}}
|
||||
onValueChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailFieldProps = {
|
||||
property: IBaseProperty;
|
||||
row: IBaseRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
|
||||
const descriptor = getDescriptor(property.type);
|
||||
const value = descriptor?.systemAccessor
|
||||
? descriptor.systemAccessor(row)
|
||||
: (row.cells ?? {})[property.id];
|
||||
const fieldProps: FieldProps = {
|
||||
property,
|
||||
value,
|
||||
rowId: row.id,
|
||||
readOnly,
|
||||
onChange: (next: unknown) => onUpdate(property.id, next),
|
||||
};
|
||||
|
||||
switch (property.type) {
|
||||
case "text":
|
||||
case "url":
|
||||
case "email":
|
||||
return <FieldText {...fieldProps} />;
|
||||
case "longText":
|
||||
return <FieldLongText {...fieldProps} />;
|
||||
case "number":
|
||||
return <FieldNumber {...fieldProps} />;
|
||||
case "checkbox":
|
||||
return <FieldCheckbox {...fieldProps} />;
|
||||
case "date":
|
||||
return <FieldDate {...fieldProps} />;
|
||||
case "select":
|
||||
case "status":
|
||||
case "multiSelect":
|
||||
return <FieldChoice {...fieldProps} />;
|
||||
case "person":
|
||||
case "file":
|
||||
case "page":
|
||||
return <FieldCellAdapter {...fieldProps} />;
|
||||
default:
|
||||
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
|
||||
return <FieldReadonlyCell {...fieldProps} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
/** Person, file and page editors are popover pickers owned by their cell
|
||||
* components; the shell supplies modal styling and click-anywhere
|
||||
* activation while the cell keeps its picker behavior. */
|
||||
export function FieldCellAdapter({
|
||||
property,
|
||||
value,
|
||||
rowId,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: FieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Whether the picker was open when the current gesture's mousedown fired.
|
||||
const editingAtMouseDownRef = useRef(false);
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
|
||||
// Files stay openable read-only (download-only popover), matching the grid.
|
||||
const canActivate = !readOnly || property.type === "file";
|
||||
|
||||
// Activate on click, not mousedown: opening on mousedown mounts the cell's
|
||||
// picker mid-dispatch, and its document-level outside-mousedown listener
|
||||
// then catches the same still-bubbling event and instantly closes it. By
|
||||
// click time the mousedown has fully finished. The ref keeps toggle-close
|
||||
// working: when the gesture started with the picker open, the picker's own
|
||||
// outside-close already handled it and we must not reopen.
|
||||
const handleMouseDown = useCallback(() => {
|
||||
editingAtMouseDownRef.current = editing;
|
||||
}, [editing]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!canActivate || editingAtMouseDownRef.current || editing) return;
|
||||
setEditing(true);
|
||||
}, [canActivate, editing]);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(next: unknown) => {
|
||||
setEditing(false);
|
||||
onChange(next);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
const handleCancel = useCallback(() => setEditing(false), []);
|
||||
|
||||
if (!CellComponent) return <FieldShell />;
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
cursor={canActivate ? "pointer" : "default"}
|
||||
active={editing}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
role={canActivate ? "button" : undefined}
|
||||
tabIndex={canActivate ? 0 : undefined}
|
||||
aria-label={property.name}
|
||||
onKeyDown={(e) => {
|
||||
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={editing}
|
||||
readOnly={readOnly}
|
||||
onCommit={handleCommit}
|
||||
onValueChange={onChange}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const multiple = property.type === "multiSelect";
|
||||
const choices =
|
||||
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||
|
||||
const selectedIds = multiple
|
||||
? Array.isArray(value)
|
||||
? (value as string[])
|
||||
: []
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(choice: Choice) => {
|
||||
if (multiple) {
|
||||
const next = selectedIds.includes(choice.id)
|
||||
? selectedIds.filter((id) => id !== choice.id)
|
||||
: [...selectedIds, choice.id];
|
||||
onChange(next.length > 0 ? next : null);
|
||||
} else {
|
||||
onChange(choice.id === selectedIds[0] ? null : choice.id);
|
||||
setOpened(false);
|
||||
}
|
||||
},
|
||||
[multiple, selectedIds, onChange],
|
||||
);
|
||||
|
||||
const chips = selectedChoices.map((choice) => (
|
||||
<span
|
||||
key={choice.id}
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
));
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="target"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
{opened && (
|
||||
<ChoicePicker
|
||||
property={property}
|
||||
selectedIds={selectedIds}
|
||||
multiple={multiple}
|
||||
grouped={property.type === "status"}
|
||||
allowCreate={property.type !== "status"}
|
||||
onToggle={handleToggle}
|
||||
onEscape={() => setOpened(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { DateTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
function toISODateString(dateStr: string | null): string | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
|
||||
const dateStr = typeof value === "string" ? value : null;
|
||||
const display = formatDateDisplay(dateStr, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="auto"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<DatePicker
|
||||
value={toISODateString(dateStr)}
|
||||
onChange={(selected) => {
|
||||
onChange(selected ? new Date(selected).toISOString() : null);
|
||||
setOpened(false);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Textarea } from "@mantine/core";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const normalize = (s: string) => {
|
||||
const trimmed = s.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell alignTop>
|
||||
<span className={classes.fieldValueTextMultiline}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text" alignTop>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={16}
|
||||
maxLength={25000}
|
||||
variant="unstyled"
|
||||
className={classes.fieldTextarea}
|
||||
classNames={{ input: classes.fieldTextareaInput }}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { formatNumber } from "@/ee/base/components/cells/cell-number";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toDraft = (value: unknown) =>
|
||||
typeof value === "number" ? String(value) : "";
|
||||
|
||||
const parse = (draft: string) => {
|
||||
const parsed = draft === "" ? null : Number(draft);
|
||||
return parsed != null && isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
const [draft, setDraft] = useState(toDraft(value));
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(toDraft(value));
|
||||
}, [value, focused]);
|
||||
|
||||
const formatted = formatNumber(numValue, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{formatted}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(toDraft(value));
|
||||
return;
|
||||
}
|
||||
if (parse(draft) !== numValue) onChange(parse(draft));
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={classes.fieldInput}
|
||||
value={focused ? draft : formatted}
|
||||
onFocus={() => {
|
||||
setDraft(toDraft(value));
|
||||
setFocused(true);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
|
||||
setDraft(v);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconExternalLink, IconMail } from "@tabler/icons-react";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
// Track remote/navigation updates while not typing.
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (draft !== text) onChange(draft);
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const linkHref =
|
||||
!focused && text
|
||||
? property.type === "email"
|
||||
? text.includes("@")
|
||||
? `mailto:${text}`
|
||||
: null
|
||||
: property.type === "url" && /^https?:\/\//i.test(text)
|
||||
? text
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
className={classes.fieldInput}
|
||||
value={draft}
|
||||
maxLength={1000}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
{linkHref && (
|
||||
<a
|
||||
href={linkHref}
|
||||
target={property.type === "url" ? "_blank" : undefined}
|
||||
rel="noopener noreferrer"
|
||||
className={classes.fieldTrailing}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
|
||||
>
|
||||
{property.type === "email" ? (
|
||||
<IconMail size={14} />
|
||||
) : (
|
||||
<IconExternalLink size={14} />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { DetailField } from "./fields/detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
type PropertyRowProps = {
|
||||
property: IBaseProperty;
|
||||
row: IBaseRow;
|
||||
pageId: string;
|
||||
menuOpened: boolean;
|
||||
onMenuOpenChange: (opened: boolean) => void;
|
||||
onMenuDirtyChange: (dirty: boolean) => void;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
autoFocusValue?: boolean;
|
||||
onAutoFocused?: () => void;
|
||||
};
|
||||
|
||||
export function PropertyRow({
|
||||
property,
|
||||
row,
|
||||
pageId,
|
||||
menuOpened,
|
||||
onMenuOpenChange,
|
||||
onMenuDirtyChange,
|
||||
onUpdate,
|
||||
autoFocusValue,
|
||||
onAutoFocused,
|
||||
}: PropertyRowProps) {
|
||||
const canEdit = useBaseEditable();
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const focusedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocusValue || focusedRef.current) return;
|
||||
focusedRef.current = true;
|
||||
const el = rowRef.current;
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
el.querySelector<HTMLElement>("input, textarea")?.focus();
|
||||
}
|
||||
onAutoFocused?.();
|
||||
}, [autoFocusValue, onAutoFocused]);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
onMenuOpenChange(!menuOpened);
|
||||
}, [menuOpened, onMenuOpenChange]);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
onMenuOpenChange(false);
|
||||
}, [onMenuOpenChange]);
|
||||
|
||||
const Icon = getDescriptor(property.type)?.icon;
|
||||
|
||||
const label = (
|
||||
<>
|
||||
{Icon && <Icon size={15} className={classes.propertyLabelIcon} />}
|
||||
<span className={classes.propertyLabelText}>{property.name}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.propertyRow} ref={rowRef}>
|
||||
{canEdit ? (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(classes.propertyLabel, classes.propertyLabelButton, {
|
||||
[classes.propertyLabelActive]: menuOpened,
|
||||
})}
|
||||
onClick={handleLabelClick}
|
||||
data-property-menu-target
|
||||
>
|
||||
{label}
|
||||
<IconChevronDown size={13} className={classes.propertyLabelChevron} />
|
||||
</button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PropertyMenuContent
|
||||
property={property}
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={onMenuDirtyChange}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className={classes.propertyLabel}>{label}</div>
|
||||
)}
|
||||
<DetailField
|
||||
property={property}
|
||||
row={row}
|
||||
readOnly={!canEdit}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconDotsVertical,
|
||||
IconLink,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IBase, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
useBaseRowQuery,
|
||||
useDeleteRowMutation,
|
||||
useUpdateRowMutation,
|
||||
} from "@/ee/base/queries/base-row-query";
|
||||
import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
|
||||
import { RowDetailTitle } from "./row-detail-title";
|
||||
import { PropertyRow } from "./property-row";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
type RowDetailModalProps = {
|
||||
base: IBase;
|
||||
rows: IBaseRow[];
|
||||
openRowId: string | null;
|
||||
onClose: () => void;
|
||||
onNavigate: (rowId: string) => void;
|
||||
};
|
||||
|
||||
export function RowDetailModal({
|
||||
base,
|
||||
rows,
|
||||
openRowId,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: RowDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const canEdit = useBaseEditable();
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const deleteRowMutation = useDeleteRowMutation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
|
||||
const rowIndex = useMemo(
|
||||
() => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
|
||||
[openRowId, rows],
|
||||
);
|
||||
const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
|
||||
// Deep links (?row=) can target rows outside the loaded pages or filtered
|
||||
// out of the active view — fetch by id instead of closing. Close only
|
||||
// when the server confirms the row is gone.
|
||||
const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
|
||||
enabled: !!openRowId && !rowFromList,
|
||||
});
|
||||
const row = rowFromList ?? rowQuery.data;
|
||||
const primaryProperty = useMemo(
|
||||
() => base.properties.find((p) => p.isPrimary),
|
||||
[base.properties],
|
||||
);
|
||||
|
||||
const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
|
||||
useEffect(() => {
|
||||
if (rowMissing) onClose();
|
||||
}, [rowMissing, onClose]);
|
||||
|
||||
const isSaving = updateRowMutation.isPending;
|
||||
const opened = !!openRowId;
|
||||
|
||||
// One field menu open at a time, mirroring the grid header's semantics.
|
||||
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
|
||||
// run its discard-confirm flow instead of being torn down mid-edit.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
|
||||
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
|
||||
const menuDirtyRef = useRef(false);
|
||||
const [closeRequest, setCloseRequest] = useAtom(
|
||||
propertyMenuCloseRequestAtomFamily(base.id),
|
||||
) as unknown as [number, (val: number) => void];
|
||||
|
||||
useEffect(() => {
|
||||
setOpenMenuId(null);
|
||||
menuDirtyRef.current = false;
|
||||
}, [openRowId]);
|
||||
|
||||
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
|
||||
menuDirtyRef.current = dirty;
|
||||
}, []);
|
||||
|
||||
const requestMenuClose = useCallback(() => {
|
||||
if (menuDirtyRef.current) {
|
||||
setCloseRequest(closeRequest + 1);
|
||||
} else {
|
||||
setOpenMenuId(null);
|
||||
}
|
||||
}, [closeRequest, setCloseRequest]);
|
||||
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(propertyId: string, nextOpened: boolean) => {
|
||||
if (!nextOpened) {
|
||||
setOpenMenuId(null);
|
||||
menuDirtyRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
|
||||
setCloseRequest(closeRequest + 1);
|
||||
return;
|
||||
}
|
||||
setOpenMenuId(propertyId);
|
||||
},
|
||||
[openMenuId, closeRequest, setCloseRequest],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-position]")) return;
|
||||
if (target.closest("[data-property-menu-target]")) return;
|
||||
requestMenuClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [openMenuId, requestMenuClose]);
|
||||
|
||||
const hasPrev = rowIndex > 0;
|
||||
const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
|
||||
const navigate = useCallback(
|
||||
(delta: number) => {
|
||||
if (rowIndex === -1) return;
|
||||
const next = rows[rowIndex + delta];
|
||||
if (next) onNavigate(next.id);
|
||||
},
|
||||
[rows, rowIndex, onNavigate],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
clipboard.copy(window.location.href);
|
||||
notifications.show({ message: t("Link copied") });
|
||||
}, [clipboard, t]);
|
||||
|
||||
const handleDeleteRecord = useCallback(() => {
|
||||
if (!row) return;
|
||||
const rowId = row.id;
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete record?"),
|
||||
centered: true,
|
||||
children: <Text size="sm">{t("This action cannot be undone.")}</Text>,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
deleteRowMutation.mutate({ rowId, pageId: base.id });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}, [row, base.id, deleteRowMutation, onClose, t]);
|
||||
|
||||
// Mantine's closeOnEscape runs a capture-phase window listener that fires
|
||||
// before inner popovers and inputs see the key, so we manage Esc ourselves
|
||||
// and yield to: nested dialogs (delete confirm), open popovers
|
||||
// ([data-position]) and editable elements. Arrows step records under the
|
||||
// same yield rules. Mantine puts role="dialog" and our content class on
|
||||
// the same element, which distinguishes this modal from nested ones.
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isEscape = event.key === "Escape";
|
||||
const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
|
||||
if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const dialog = target.closest('[role="dialog"]');
|
||||
if (dialog && !dialog.classList.contains(classes.modalContent)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest("[data-position]") ||
|
||||
target.matches("input, textarea, select, [contenteditable='true']")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isEscape) {
|
||||
if (openMenuId) {
|
||||
requestMenuClose();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (openMenuId) return;
|
||||
event.preventDefault();
|
||||
navigate(event.key === "ArrowUp" ? -1 : 1);
|
||||
},
|
||||
[opened, openMenuId, requestMenuClose, onClose, navigate],
|
||||
);
|
||||
useWindowEvent("keydown", handleKeyDown, { capture: true });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={!openMenuId}
|
||||
padding={0}
|
||||
radius="md"
|
||||
title={null}
|
||||
classNames={{ content: classes.modalContent }}
|
||||
>
|
||||
{row ? (
|
||||
<>
|
||||
<div className={classes.topBar}>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Tooltip label={t("Previous record")} openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!hasPrev}
|
||||
aria-label={t("Previous record")}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Next record")} openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!hasNext}
|
||||
aria-label={t("Next record")}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Menu position="bottom-end" shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
aria-label={t("Record actions")}
|
||||
>
|
||||
<IconDotsVertical size={16} />
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={14} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{t("Copy link")}
|
||||
</Menu.Item>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={handleDeleteRecord}
|
||||
>
|
||||
{t("Delete record")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.iconButton}
|
||||
onClick={onClose}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RowDetailTitle
|
||||
row={row}
|
||||
primaryProperty={primaryProperty}
|
||||
canEdit={canEdit}
|
||||
onCommit={(value) => {
|
||||
if (!primaryProperty) return;
|
||||
updateRowMutation.mutate({
|
||||
rowId: row.id,
|
||||
pageId: base.id,
|
||||
cells: { [primaryProperty.id]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={classes.body}>
|
||||
<div className={classes.propertyList}>
|
||||
{base.properties
|
||||
.filter((p) => !p.isPrimary)
|
||||
.map((property) => (
|
||||
<PropertyRow
|
||||
key={property.id}
|
||||
property={property}
|
||||
row={row}
|
||||
pageId={base.id}
|
||||
autoFocusValue={property.id === newPropertyId}
|
||||
onAutoFocused={clearNewProperty}
|
||||
menuOpened={openMenuId === property.id}
|
||||
onMenuOpenChange={(nextOpened) =>
|
||||
handleMenuOpenChange(property.id, nextOpened)
|
||||
}
|
||||
onMenuDirtyChange={handleMenuDirtyChange}
|
||||
onUpdate={(propertyId, value) => {
|
||||
updateRowMutation.mutate({
|
||||
rowId: row.id,
|
||||
pageId: base.id,
|
||||
cells: { [propertyId]: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<CreatePropertyPopover
|
||||
pageId={base.id}
|
||||
properties={base.properties}
|
||||
onPropertyCreated={(p) => setNewPropertyId(p.id)}
|
||||
renderTarget={(open) => (
|
||||
<button
|
||||
type="button"
|
||||
className={classes.addPropertyRow}
|
||||
onClick={open}
|
||||
>
|
||||
<span className={classes.addPropertyLabel}>
|
||||
<IconPlus size={15} />
|
||||
{t("Add property")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className={classes.footer}>
|
||||
<div className={classes.footerStatus}>
|
||||
{!canEdit ? (
|
||||
<span className={classes.lockedHint}>
|
||||
<IconLock size={12} />
|
||||
{t("Read-only")}
|
||||
</span>
|
||||
) : isSaving ? (
|
||||
<>
|
||||
<span className={classes.savingDot} />
|
||||
<span>{t("Saving…")}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classes.kbdHint}>
|
||||
{rowIndex >= 0 && rows.length > 1 && (
|
||||
<>
|
||||
<kbd className={classes.kbd}>↑</kbd>
|
||||
<kbd className={classes.kbd}>↓</kbd>
|
||||
<span>{t("to navigate")}</span>
|
||||
<span className={classes.kbdSeparator} />
|
||||
</>
|
||||
)}
|
||||
<kbd className={classes.kbd}>Esc</kbd>
|
||||
<span>{t("to close")}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<RowDetailSkeleton base={base} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hydration state for deep-linked rows: the schema is already loaded, so
|
||||
* render the real labels and shimmer only the unknown values. Matching the
|
||||
* final layout avoids a size jump when the row arrives. */
|
||||
function RowDetailSkeleton({ base }: { base: IBase }) {
|
||||
return (
|
||||
<>
|
||||
<div className={classes.topBar}>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
</div>
|
||||
<div className={classes.topBarGroup}>
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
<Skeleton height={28} width={28} radius={6} />
|
||||
</div>
|
||||
</div>
|
||||
<header className={classes.header}>
|
||||
<Skeleton height={30} width="45%" radius={8} />
|
||||
<div className={classes.metaRow}>
|
||||
<Skeleton height={12} width={150} radius={4} />
|
||||
</div>
|
||||
</header>
|
||||
<div className={classes.body}>
|
||||
<div className={classes.propertyList}>
|
||||
{base.properties
|
||||
.filter((p) => !p.isPrimary)
|
||||
.map((property) => {
|
||||
const Icon = getDescriptor(property.type)?.icon;
|
||||
return (
|
||||
<div key={property.id} className={classes.propertyRow}>
|
||||
<div className={classes.propertyLabel}>
|
||||
{Icon && (
|
||||
<Icon size={15} className={classes.propertyLabelIcon} />
|
||||
)}
|
||||
<span className={classes.propertyLabelText}>
|
||||
{property.name}
|
||||
</span>
|
||||
</div>
|
||||
<Skeleton
|
||||
height={property.type === "longText" ? 82 : 34}
|
||||
radius={7}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { timeAgo } from "@/lib/time.ts";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
type RowDetailTitleProps = {
|
||||
row: IBaseRow;
|
||||
primaryProperty: IBaseProperty | undefined;
|
||||
canEdit: boolean;
|
||||
onCommit: (value: string) => void;
|
||||
};
|
||||
|
||||
export function RowDetailTitle({
|
||||
row,
|
||||
primaryProperty,
|
||||
canEdit,
|
||||
onCommit,
|
||||
}: RowDetailTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
const initial = primaryProperty
|
||||
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
|
||||
: "";
|
||||
const [value, setValue] = useState(initial);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const didAutofocusRef = useRef(false);
|
||||
|
||||
// Re-sync when the row changes underneath us (navigation or remote edit).
|
||||
useEffect(() => {
|
||||
setValue(initial);
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didAutofocusRef.current || !canEdit || initial) return;
|
||||
didAutofocusRef.current = true;
|
||||
inputRef.current?.focus();
|
||||
}, [canEdit, initial]);
|
||||
|
||||
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
|
||||
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
{canEdit ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={classes.titleInput}
|
||||
placeholder={t("Untitled")}
|
||||
aria-label={primaryProperty?.name ?? t("Untitled")}
|
||||
value={value}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
onBlur={() => {
|
||||
if (value !== initial) onCommit(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h1 className={classes.titleStatic}>{value || t("Untitled")}</h1>
|
||||
)}
|
||||
{updatedAgo && (
|
||||
<div className={classes.metaRow}>
|
||||
<span>{t("Updated {{when}}", { when: updatedAgo })}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user