mirror of
https://github.com/docmost/docmost.git
synced 2026-07-26 03:04:51 +10:00
feat: kanban group editing (#2322)
* feat: support opening property menu directly on options panel * refactor: expose group-by property from useKanbanColumns * feat: rename kanban group by clicking column name * feat: edit group-by property from kanban column menu * fix: license-gate base and kanban inserts * fix: add missing bases strings to en-US translation file
This commit is contained in:
@@ -1153,5 +1153,141 @@
|
|||||||
"Create property": "Create property",
|
"Create property": "Create property",
|
||||||
"Hide properties": "Hide properties",
|
"Hide properties": "Hide properties",
|
||||||
"Find a property type": "Find a property type",
|
"Find a property type": "Find a property type",
|
||||||
"Properties": "Properties"
|
"Properties": "Properties",
|
||||||
|
"A property with this name already exists": "A property with this name already exists",
|
||||||
|
"Add card": "Add card",
|
||||||
|
"Add filter": "Add filter",
|
||||||
|
"Add option": "Add option",
|
||||||
|
"Add sort": "Add sort",
|
||||||
|
"Add view": "Add view",
|
||||||
|
"All data in this column will be lost.": "All data in this column will be lost.",
|
||||||
|
"Allow multiple people": "Allow multiple people",
|
||||||
|
"Alphabetize": "Alphabetize",
|
||||||
|
"Are you sure you want to delete": "Are you sure you want to delete",
|
||||||
|
"Ascending": "Ascending",
|
||||||
|
"Base deleted": "Base deleted",
|
||||||
|
"Base name": "Base name",
|
||||||
|
"Base table": "Base table",
|
||||||
|
"Card properties": "Card properties",
|
||||||
|
"Cells longer than the Text limit will be truncated and the extra content permanently lost.": "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
|
||||||
|
"Checkbox": "Checkbox",
|
||||||
|
"Checked by default": "Checked by default",
|
||||||
|
"Choose a property": "Choose a property",
|
||||||
|
"Clear selection": "Clear selection",
|
||||||
|
"Column options": "Column options",
|
||||||
|
"Comma, period": "Comma, period",
|
||||||
|
"Complete": "Complete",
|
||||||
|
"Contains": "Contains",
|
||||||
|
"Copy link to view": "Copy link to view",
|
||||||
|
"Create a status property": "Create a status property",
|
||||||
|
"Created at": "Created at",
|
||||||
|
"Currency": "Currency",
|
||||||
|
"Decimal places": "Decimal places",
|
||||||
|
"Default value": "Default value",
|
||||||
|
"Delete property": "Delete property",
|
||||||
|
"Delete view": "Delete view",
|
||||||
|
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||||
|
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||||
|
"Descending": "Descending",
|
||||||
|
"Discard": "Discard",
|
||||||
|
"Doesn't contain": "Doesn't contain",
|
||||||
|
"Done": "Done",
|
||||||
|
"Edit formula": "Edit formula",
|
||||||
|
"Edit property": "Edit property",
|
||||||
|
"Export CSV": "Export CSV",
|
||||||
|
"Failed to load base": "Failed to load base",
|
||||||
|
"False": "False",
|
||||||
|
"File": "File",
|
||||||
|
"Filter and sort changes are visible only to you": "Filter and sort changes are visible only to you",
|
||||||
|
"Filter by": "Filter by",
|
||||||
|
"Formula": "Formula",
|
||||||
|
"Get started with": "Get started with",
|
||||||
|
"Greater than": "Greater than",
|
||||||
|
"Group by": "Group by",
|
||||||
|
"Group this board by a select or status property.": "Group this board by a select or status property.",
|
||||||
|
"Hide all": "Hide all",
|
||||||
|
"Hide group": "Hide group",
|
||||||
|
"In Progress": "In Progress",
|
||||||
|
"In progress": "In progress",
|
||||||
|
"Include time": "Include time",
|
||||||
|
"Insert a kanban board on this page": "Insert a kanban board on this page",
|
||||||
|
"Insert an inline base on this page": "Insert an inline base on this page",
|
||||||
|
"Is": "Is",
|
||||||
|
"Is after": "Is after",
|
||||||
|
"Is any of": "Is any of",
|
||||||
|
"Is before": "Is before",
|
||||||
|
"Is empty": "Is empty",
|
||||||
|
"Is none of": "Is none of",
|
||||||
|
"Is not": "Is not",
|
||||||
|
"Is not empty": "Is not empty",
|
||||||
|
"Is on or after": "Is on or after",
|
||||||
|
"Is on or before": "Is on or before",
|
||||||
|
"Is within": "Is within",
|
||||||
|
"Keep editing": "Keep editing",
|
||||||
|
"Last edited at": "Last edited at",
|
||||||
|
"Last edited by": "Last edited by",
|
||||||
|
"Less than": "Less than",
|
||||||
|
"Link copied to clipboard": "Link copied to clipboard",
|
||||||
|
"Local": "Local",
|
||||||
|
"Long text": "Long text",
|
||||||
|
"Moved card to {{column}}": "Moved card to {{column}}",
|
||||||
|
"Moved column to {{column}}": "Moved column to {{column}}",
|
||||||
|
"Multi-select": "Multi-select",
|
||||||
|
"New row": "New row",
|
||||||
|
"Next month": "Next month",
|
||||||
|
"Next week": "Next week",
|
||||||
|
"Next year": "Next year",
|
||||||
|
"No filters applied": "No filters applied",
|
||||||
|
"No match": "No match",
|
||||||
|
"No options for this property type": "No options for this property type",
|
||||||
|
"No sorts applied": "No sorts applied",
|
||||||
|
"No value": "No value",
|
||||||
|
"None": "None",
|
||||||
|
"Not started": "Not started",
|
||||||
|
"Number": "Number",
|
||||||
|
"One month ago": "One month ago",
|
||||||
|
"One month from now": "One month from now",
|
||||||
|
"One week ago": "One week ago",
|
||||||
|
"One week from now": "One week from now",
|
||||||
|
"Open as page": "Open as page",
|
||||||
|
"Options": "Options",
|
||||||
|
"Past month": "Past month",
|
||||||
|
"Past week": "Past week",
|
||||||
|
"Past year": "Past year",
|
||||||
|
"Percent": "Percent",
|
||||||
|
"Period, comma": "Period, comma",
|
||||||
|
"Person": "Person",
|
||||||
|
"Please enter a valid email": "Please enter a valid email",
|
||||||
|
"Progress": "Progress",
|
||||||
|
"Property options": "Property options",
|
||||||
|
"Relative": "Relative",
|
||||||
|
"Rename property": "Rename property",
|
||||||
|
"Reset": "Reset",
|
||||||
|
"Save for everyone": "Save for everyone",
|
||||||
|
"Select": "Select",
|
||||||
|
"Select a property": "Select a property",
|
||||||
|
"Select all loaded rows": "Select all loaded rows",
|
||||||
|
"Show all": "Show all",
|
||||||
|
"Sort": "Sort",
|
||||||
|
"Sort by": "Sort by",
|
||||||
|
"Space, comma": "Space, comma",
|
||||||
|
"Space, period": "Space, period",
|
||||||
|
"This board has no grouping property yet.": "This board has no grouping property yet.",
|
||||||
|
"This month": "This month",
|
||||||
|
"This year": "This year",
|
||||||
|
"Thousands and decimal separators": "Thousands and decimal separators",
|
||||||
|
"Time format": "Time format",
|
||||||
|
"To Do": "To Do",
|
||||||
|
"Tomorrow": "Tomorrow",
|
||||||
|
"True": "True",
|
||||||
|
"Unsaved changes": "Unsaved changes",
|
||||||
|
"Untitled base": "Untitled base",
|
||||||
|
"URL": "URL",
|
||||||
|
"Value": "Value",
|
||||||
|
"View updated for everyone": "View updated for everyone",
|
||||||
|
"You have unsaved changes. Do you want to discard them?": "You have unsaved changes. Do you want to discard them?",
|
||||||
|
"{{count}} rows deleted_one": "1 row deleted",
|
||||||
|
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||||
|
"{{count}} selected_one": "1 selected",
|
||||||
|
"{{count}} selected_other": "{{count}} selected"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type BaseKanbanProps = {
|
|||||||
|
|
||||||
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
|
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
|
const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view);
|
||||||
const updateView = useUpdateViewMutation();
|
const updateView = useUpdateViewMutation();
|
||||||
const moveCard = useKanbanMoveCardMutation();
|
const moveCard = useKanbanMoveCardMutation();
|
||||||
const { openRow } = useRowDetailModal(pageId);
|
const { openRow } = useRowDetailModal(pageId);
|
||||||
@@ -201,6 +201,7 @@ export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter
|
|||||||
column={column}
|
column={column}
|
||||||
viewFilter={viewFilter}
|
viewFilter={viewFilter}
|
||||||
groupByPropertyId={groupByPropertyId!}
|
groupByPropertyId={groupByPropertyId!}
|
||||||
|
groupByProperty={groupByProperty}
|
||||||
canEdit={editable}
|
canEdit={editable}
|
||||||
onOpenRow={handleOpenRow}
|
onOpenRow={handleOpenRow}
|
||||||
onHide={hideColumn}
|
onHide={hideColumn}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActionIcon, Menu, Text } from "@mantine/core";
|
import { ActionIcon, Text } from "@mantine/core";
|
||||||
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
|
import { IconPlus, IconGripVertical } from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KanbanColumn } from "@/ee/base/types/base.types";
|
import { IBaseProperty, KanbanColumn } from "@/ee/base/types/base.types";
|
||||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||||
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
|
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
|
||||||
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
|
||||||
|
import { KanbanColumnTitle } from "@/ee/base/components/kanban/kanban-column-title";
|
||||||
|
import { KanbanColumnMenu } from "@/ee/base/components/kanban/kanban-column-menu";
|
||||||
import classes from "@/ee/base/styles/kanban.module.css";
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
type KanbanColumnHeaderProps = {
|
type KanbanColumnHeaderProps = {
|
||||||
column: KanbanColumn;
|
column: KanbanColumn;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
property: IBaseProperty | undefined;
|
||||||
count?: string;
|
count?: string;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
onAddCard: () => void;
|
onAddCard: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
|
export function KanbanColumnHeader({ column, pageId, property, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dotColor = column.color
|
const dotColor = column.color
|
||||||
? choiceColor(column.color).color as string
|
? choiceColor(column.color).color as string
|
||||||
@@ -49,22 +52,13 @@ export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onA
|
|||||||
background: dotColor,
|
background: dotColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text fw={600} size="sm" flex={1} truncate>
|
<KanbanColumnTitle column={column} property={property} pageId={pageId} canEdit={canEdit} />
|
||||||
{column.isNoValue ? t("No value") : column.name}
|
|
||||||
</Text>
|
|
||||||
{count !== undefined && <Text className={classes.count}>{count}</Text>}
|
{count !== undefined && <Text className={classes.count}>{count}</Text>}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<Menu position="bottom-end" withinPortal>
|
{property && (
|
||||||
<Menu.Target>
|
<KanbanColumnMenu property={property} pageId={pageId} onHide={onHide} />
|
||||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Column options")}>
|
)}
|
||||||
<IconDots size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Menu.Target>
|
|
||||||
<Menu.Dropdown>
|
|
||||||
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
|
||||||
</Menu>
|
|
||||||
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
|
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add card")} onClick={onAddCard}>
|
||||||
<IconPlus size={14} />
|
<IconPlus size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActionIcon, Popover, Stack } from "@mantine/core";
|
||||||
|
import { IconDots, IconEyeOff, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
propertyMenuCloseRequestAtomFamily,
|
||||||
|
propertyMenuDirtyAtomFamily,
|
||||||
|
} from "@/ee/base/atoms/base-atoms";
|
||||||
|
import { IBaseProperty } from "@/ee/base/types/base.types";
|
||||||
|
import {
|
||||||
|
MenuItem,
|
||||||
|
PropertyMenuContent,
|
||||||
|
} from "@/ee/base/components/property/property-menu";
|
||||||
|
|
||||||
|
type KanbanColumnMenuProps = {
|
||||||
|
property: IBaseProperty;
|
||||||
|
pageId: string;
|
||||||
|
onHide: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanColumnMenu({ property, pageId, onHide }: KanbanColumnMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [view, setView] = useState<"menu" | "property">("menu");
|
||||||
|
const [dirty, setDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
|
||||||
|
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpened(false);
|
||||||
|
setView("menu");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wasOpenedRef = useRef(opened);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasOpenedRef.current && !opened) setDirty(false);
|
||||||
|
wasOpenedRef.current = opened;
|
||||||
|
}, [opened, setDirty]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (next) return;
|
||||||
|
if (dirty) {
|
||||||
|
setCloseRequest(closeRequest + 1);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dirty, closeRequest, setCloseRequest, handleClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (opened) {
|
||||||
|
handleOpenChange(false);
|
||||||
|
} else if (!dirty) {
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, [opened, dirty, handleOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={handleOpenChange}
|
||||||
|
onClose={handleClose}
|
||||||
|
position="bottom-end"
|
||||||
|
shadow="md"
|
||||||
|
width={260}
|
||||||
|
trapFocus
|
||||||
|
returnFocus
|
||||||
|
withinPortal
|
||||||
|
closeOnClickOutside
|
||||||
|
closeOnEscape
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
aria-label={t("Column options")}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown
|
||||||
|
p={0}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{view === "menu" ? (
|
||||||
|
<Stack gap={0} p={4}>
|
||||||
|
<MenuItem
|
||||||
|
icon={<IconSettings size={14} />}
|
||||||
|
label={t("Edit property")}
|
||||||
|
onClick={() => setView("property")}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={<IconEyeOff size={14} />}
|
||||||
|
label={t("Hide group")}
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
onHide();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<PropertyMenuContent
|
||||||
|
property={property}
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
onDirtyChange={setDirty}
|
||||||
|
pageId={pageId}
|
||||||
|
initialPanel={property.pendingType ? "main" : "options"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button, Group, Popover, Text, TextInput, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IBaseProperty, KanbanColumn, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||||
|
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
|
||||||
|
import classes from "@/ee/base/styles/kanban.module.css";
|
||||||
|
|
||||||
|
type KanbanColumnTitleProps = {
|
||||||
|
column: KanbanColumn;
|
||||||
|
property: IBaseProperty | undefined;
|
||||||
|
pageId: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KanbanColumnTitle({ column, property, pageId, canEdit }: KanbanColumnTitleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const updateProperty = useUpdatePropertyMutation();
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
setOpened(false);
|
||||||
|
const name = draft.trim();
|
||||||
|
const options = property?.typeOptions as SelectTypeOptions | undefined;
|
||||||
|
if (!property || !options || !name || name === column.name) return;
|
||||||
|
if (!options.choices.some((c) => c.id === column.key)) return;
|
||||||
|
updateProperty.mutate({
|
||||||
|
propertyId: property.id,
|
||||||
|
pageId,
|
||||||
|
typeOptions: {
|
||||||
|
...options,
|
||||||
|
choices: options.choices.map((c) =>
|
||||||
|
c.id === column.key ? { ...c, name } : c,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [draft, property, column.name, column.key, pageId, updateProperty]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (opened) {
|
||||||
|
commit();
|
||||||
|
} else {
|
||||||
|
setDraft(column.name);
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, [opened, commit, column.name]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => setOpened(false), []);
|
||||||
|
|
||||||
|
if (!canEdit || column.isNoValue || !property) {
|
||||||
|
return (
|
||||||
|
<Text fw={600} size="sm" flex={1} truncate>
|
||||||
|
{column.isNoValue ? t("No value") : column.name}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={(next) => {
|
||||||
|
if (!next) commit();
|
||||||
|
}}
|
||||||
|
position="bottom-start"
|
||||||
|
shadow="md"
|
||||||
|
width={240}
|
||||||
|
withinPortal
|
||||||
|
trapFocus
|
||||||
|
returnFocus
|
||||||
|
closeOnClickOutside
|
||||||
|
closeOnEscape={false}
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<UnstyledButton className={classes.columnTitleButton} onClick={toggle}>
|
||||||
|
<Text fw={600} size="sm" truncate flex={1} ta="left">
|
||||||
|
{column.name}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown
|
||||||
|
p="xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
flex={1}
|
||||||
|
value={draft}
|
||||||
|
data-autofocus
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="xs" onClick={commit}>
|
||||||
|
{t("Done")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
import { type IBase, type IBaseProperty, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||||
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
|
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
|
||||||
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
|
import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
|
||||||
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
|
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
|
||||||
@@ -19,6 +19,7 @@ type KanbanColumnProps = {
|
|||||||
column: KanbanColumnType;
|
column: KanbanColumnType;
|
||||||
viewFilter: FilterGroup | undefined;
|
viewFilter: FilterGroup | undefined;
|
||||||
groupByPropertyId: string;
|
groupByPropertyId: string;
|
||||||
|
groupByProperty: IBaseProperty | undefined;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onOpenRow: (rowId: string) => void;
|
onOpenRow: (rowId: string) => void;
|
||||||
onHide: (columnKey: string) => void;
|
onHide: (columnKey: string) => void;
|
||||||
@@ -33,6 +34,7 @@ export function KanbanColumn({
|
|||||||
column,
|
column,
|
||||||
viewFilter,
|
viewFilter,
|
||||||
groupByPropertyId,
|
groupByPropertyId,
|
||||||
|
groupByProperty,
|
||||||
canEdit,
|
canEdit,
|
||||||
onOpenRow,
|
onOpenRow,
|
||||||
onHide,
|
onHide,
|
||||||
@@ -139,6 +141,7 @@ export function KanbanColumn({
|
|||||||
<KanbanColumnHeader
|
<KanbanColumnHeader
|
||||||
column={column}
|
column={column}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
|
property={groupByProperty}
|
||||||
count={count}
|
count={count}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onHide={() => onHide(column.key)}
|
onHide={() => onHide(column.key)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type PropertyMenuContentProps = {
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onEditFormula?: () => void;
|
onEditFormula?: () => void;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
initialPanel?: "main" | "options";
|
||||||
};
|
};
|
||||||
|
|
||||||
type MenuPanel =
|
type MenuPanel =
|
||||||
@@ -96,9 +97,10 @@ export function PropertyMenuContent({
|
|||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onEditFormula,
|
onEditFormula,
|
||||||
pageId,
|
pageId,
|
||||||
|
initialPanel,
|
||||||
}: PropertyMenuContentProps) {
|
}: PropertyMenuContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [panel, setPanel] = useState<MenuPanel>("main");
|
const [panel, setPanel] = useState<MenuPanel>(initialPanel ?? "main");
|
||||||
const [renameValue, setRenameValue] = useState(property.name);
|
const [renameValue, setRenameValue] = useState(property.name);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [optionsDirty, setOptionsDirty] = useState(false);
|
const [optionsDirty, setOptionsDirty] = useState(false);
|
||||||
@@ -117,12 +119,12 @@ export function PropertyMenuContent({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opened) {
|
if (opened) {
|
||||||
setPanel("main");
|
setPanel(initialPanel ?? "main");
|
||||||
setRenameValue(property.name);
|
setRenameValue(property.name);
|
||||||
setOptionsDirty(false);
|
setOptionsDirty(false);
|
||||||
setPendingTargetType(null);
|
setPendingTargetType(null);
|
||||||
}
|
}
|
||||||
}, [opened, property.name]);
|
}, [opened, property.name, initialPanel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (panel === "rename") {
|
if (panel === "rename") {
|
||||||
@@ -463,7 +465,7 @@ export function PropertyMenuContent({
|
|||||||
|
|
||||||
PropertyMenuContent.displayName = "PropertyMenuContent";
|
PropertyMenuContent.displayName = "PropertyMenuContent";
|
||||||
|
|
||||||
function MenuItem({
|
export function MenuItem({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
rightIcon,
|
rightIcon,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
|
import { IBase, IBaseProperty, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||||
|
|
||||||
export type KanbanGroup = KanbanColumn & { hidden: boolean };
|
export type KanbanGroup = KanbanColumn & { hidden: boolean };
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ export function useKanbanColumns(
|
|||||||
view: IBaseView | undefined,
|
view: IBaseView | undefined,
|
||||||
): {
|
): {
|
||||||
groupByPropertyId: string | undefined;
|
groupByPropertyId: string | undefined;
|
||||||
|
groupByProperty: IBaseProperty | undefined;
|
||||||
columns: KanbanColumn[];
|
columns: KanbanColumn[];
|
||||||
allGroups: KanbanGroup[];
|
allGroups: KanbanGroup[];
|
||||||
hasValidGroupBy: boolean;
|
hasValidGroupBy: boolean;
|
||||||
@@ -18,7 +19,7 @@ export function useKanbanColumns(
|
|||||||
const groupable = prop && (prop.type === "select" || prop.type === "status");
|
const groupable = prop && (prop.type === "select" || prop.type === "status");
|
||||||
|
|
||||||
if (!groupable || !prop || !view) {
|
if (!groupable || !prop || !view) {
|
||||||
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
|
return { groupByPropertyId, groupByProperty: undefined, columns: [], allGroups: [], hasValidGroupBy: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeOptions = prop.typeOptions as SelectTypeOptions;
|
const typeOptions = prop.typeOptions as SelectTypeOptions;
|
||||||
@@ -47,6 +48,6 @@ export function useKanbanColumns(
|
|||||||
});
|
});
|
||||||
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
|
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
|
||||||
|
|
||||||
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
|
return { groupByPropertyId, groupByProperty: prop, columns, allGroups, hasValidGroupBy: true };
|
||||||
}, [base, view]);
|
}, [base, view]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,21 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.columnTitleButton {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin: -2px -6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columnTitleButton:hover {
|
||||||
|
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
.cardList {
|
.cardList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Editor, Range } from "@tiptap/core";
|
|||||||
import { v7 as uuid7 } from "uuid";
|
import { v7 as uuid7 } from "uuid";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import api from "@/lib/api-client";
|
import api from "@/lib/api-client";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
import { getApiErrorMessage } from "@/lib/api-error";
|
||||||
|
|
||||||
function findBaseEmbedPlaceholderPos(
|
function findBaseEmbedPlaceholderPos(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
@@ -50,7 +52,7 @@ export async function insertBaseEmbedBlock(
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
} catch {
|
} catch (err) {
|
||||||
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
|
||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
editor
|
editor
|
||||||
@@ -62,6 +64,9 @@ export async function insertBaseEmbedBlock(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
notifications.show({ message: "Failed to create base", color: "red" });
|
notifications.show({
|
||||||
|
message: getApiErrorMessage(err, i18n.t("Failed to create base")),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-13
@@ -1,6 +1,6 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
import { ActionIcon, Badge, Menu, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
} from "@/components/icons";
|
} from "@/components/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
import { insertBaseEmbedBlock } from "@/features/editor/components/base-embed/insert-base-embed";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -40,6 +43,8 @@ interface Props {
|
|||||||
|
|
||||||
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const hasBases = useHasFeature(Feature.BASES);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
const setEmbed = (provider: string) =>
|
const setEmbed = (provider: string) =>
|
||||||
editor.chain().focus().setEmbed({ provider }).run();
|
editor.chain().focus().setEmbed({ provider }).run();
|
||||||
@@ -106,20 +111,48 @@ export const MoreInsertsGroup: FC<Props> = ({ editor, templateMode }) => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{!templateMode && (
|
{!templateMode && (
|
||||||
<Menu.Item
|
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
|
||||||
leftSection={<IconTable size={16} />}
|
<Menu.Item
|
||||||
onClick={() => insertBaseEmbedBlock(editor)}
|
leftSection={<IconTable size={16} />}
|
||||||
>
|
aria-disabled={!hasBases}
|
||||||
{t("Base (Inline)")}
|
closeMenuOnClick={hasBases}
|
||||||
</Menu.Item>
|
style={{ opacity: hasBases ? undefined : 0.7 }}
|
||||||
|
rightSection={
|
||||||
|
!hasBases && (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{t("Upgrade")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasBases) insertBaseEmbedBlock(editor);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Base (Inline)")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!templateMode && (
|
{!templateMode && (
|
||||||
<Menu.Item
|
<Tooltip label={upgradeLabel} disabled={hasBases} position="right">
|
||||||
leftSection={<IconLayoutKanban size={16} />}
|
<Menu.Item
|
||||||
onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
|
leftSection={<IconLayoutKanban size={16} />}
|
||||||
>
|
aria-disabled={!hasBases}
|
||||||
{t("Kanban")}
|
closeMenuOnClick={hasBases}
|
||||||
</Menu.Item>
|
style={{ opacity: hasBases ? undefined : 0.7 }}
|
||||||
|
rightSection={
|
||||||
|
!hasBases && (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{t("Upgrade")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasBases) insertBaseEmbedBlock(editor, { template: "kanban" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Kanban")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
VisuallyHidden,
|
VisuallyHidden,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -18,6 +19,7 @@ import clsx from "clsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
const CommandList = ({
|
const CommandList = ({
|
||||||
items,
|
items,
|
||||||
@@ -37,11 +39,12 @@ const CommandList = ({
|
|||||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||||
|
|
||||||
const hasBases = useHasFeature(Feature.BASES);
|
const hasBases = useHasFeature(Feature.BASES);
|
||||||
// Title must match the "Base (Inline)" item in menu-items.ts. Without the
|
const upgradeLabel = useUpgradeLabel();
|
||||||
// bases entitlement the item stays visible but disabled; an expired license
|
// Without the bases entitlement the item stays visible but inert; an
|
||||||
// the client can't detect falls through to a handled create failure.
|
// expired license the client can't detect falls through to a handled
|
||||||
|
// create failure.
|
||||||
const isItemDisabled = (item: SlashMenuItemType) =>
|
const isItemDisabled = (item: SlashMenuItemType) =>
|
||||||
!hasBases && item.title === "Base (Inline)";
|
!hasBases && item.requiresBases === true;
|
||||||
|
|
||||||
const flatItems = useMemo(() => {
|
const flatItems = useMemo(() => {
|
||||||
return Object.values(items).flat();
|
return Object.values(items).flat();
|
||||||
@@ -152,18 +155,22 @@ const CommandList = ({
|
|||||||
const itemIndex = flatIndex;
|
const itemIndex = flatIndex;
|
||||||
const disabled = isItemDisabled(item);
|
const disabled = isItemDisabled(item);
|
||||||
return (
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={itemIndex}
|
||||||
|
label={upgradeLabel}
|
||||||
|
disabled={!disabled}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
data-item-index={itemIndex}
|
data-item-index={itemIndex}
|
||||||
key={itemIndex}
|
|
||||||
id={`slash-command-option-${itemIndex}`}
|
id={`slash-command-option-${itemIndex}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={itemIndex === selectedIndex}
|
aria-selected={itemIndex === selectedIndex}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => selectItem(itemIndex)}
|
onClick={() => selectItem(itemIndex)}
|
||||||
className={clsx(classes.menuBtn, {
|
className={clsx(classes.menuBtn, {
|
||||||
[classes.selectedItem]: itemIndex === selectedIndex,
|
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||||
[classes.disabledItem]: disabled,
|
[classes.gatedItem]: disabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -188,6 +195,7 @@ const CommandList = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
description: "Insert an inline base on this page",
|
description: "Insert an inline base on this page",
|
||||||
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
|
||||||
icon: IconTable,
|
icon: IconTable,
|
||||||
|
requiresBases: true,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
insertBaseEmbedBlock(editor, { range });
|
insertBaseEmbedBlock(editor, { range });
|
||||||
},
|
},
|
||||||
@@ -375,6 +376,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
description: "Insert a kanban board on this page",
|
description: "Insert a kanban board on this page",
|
||||||
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
|
||||||
icon: IconLayoutKanban,
|
icon: IconLayoutKanban,
|
||||||
|
requiresBases: true,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
insertBaseEmbedBlock(editor, { range, template: "kanban" });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabledItem {
|
.gatedItem {
|
||||||
opacity: 0.45;
|
opacity: 0.7;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type SlashMenuItemType = {
|
|||||||
searchTerms: string[];
|
searchTerms: string[];
|
||||||
command: (props: CommandProps) => void;
|
command: (props: CommandProps) => void;
|
||||||
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
|
||||||
|
requiresBases?: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlashMenuGroupedItemsType = {
|
export type SlashMenuGroupedItemsType = {
|
||||||
|
|||||||
Reference in New Issue
Block a user