From 8a2c457ab914375bce51bde782b764e1dcdcab4f Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:35:26 +0100
Subject: [PATCH] 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
---
.../public/locales/en-US/translation.json | 138 +++++++++++++++++-
.../ee/base/components/kanban/base-kanban.tsx | 3 +-
.../kanban/kanban-column-header.tsx | 28 ++--
.../components/kanban/kanban-column-menu.tsx | 119 +++++++++++++++
.../components/kanban/kanban-column-title.tsx | 113 ++++++++++++++
.../base/components/kanban/kanban-column.tsx | 5 +-
.../components/property/property-menu.tsx | 10 +-
.../src/ee/base/hooks/use-kanban-columns.ts | 7 +-
.../src/ee/base/styles/kanban.module.css | 15 ++
.../base-embed/insert-base-embed.ts | 9 +-
.../groups/more-inserts-group.tsx | 59 ++++++--
.../components/slash-menu/command-list.tsx | 22 ++-
.../components/slash-menu/menu-items.ts | 2 +
.../slash-menu/slash-menu.module.css | 5 +-
.../editor/components/slash-menu/types.ts | 1 +
15 files changed, 484 insertions(+), 52 deletions(-)
create mode 100644 apps/client/src/ee/base/components/kanban/kanban-column-menu.tsx
create mode 100644 apps/client/src/ee/base/components/kanban/kanban-column-title.tsx
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index c23b4e2d3..18c981434 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1153,5 +1153,141 @@
"Create property": "Create property",
"Hide properties": "Hide properties",
"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"
}
diff --git a/apps/client/src/ee/base/components/kanban/base-kanban.tsx b/apps/client/src/ee/base/components/kanban/base-kanban.tsx
index 2ad2df93b..f19f37d6b 100644
--- a/apps/client/src/ee/base/components/kanban/base-kanban.tsx
+++ b/apps/client/src/ee/base/components/kanban/base-kanban.tsx
@@ -30,7 +30,7 @@ type BaseKanbanProps = {
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
- const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
+ const { groupByPropertyId, groupByProperty, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal(pageId);
@@ -201,6 +201,7 @@ export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
+ groupByProperty={groupByProperty}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx
index 3e53080f5..830a00a16 100644
--- a/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx
+++ b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx
@@ -1,24 +1,27 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
-import { ActionIcon, Menu, Text } from "@mantine/core";
-import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
+import { ActionIcon, Text } from "@mantine/core";
+import { IconPlus, IconGripVertical } from "@tabler/icons-react";
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 { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
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";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
+ property: IBaseProperty | undefined;
count?: string;
canEdit: boolean;
onHide: () => 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 dotColor = column.color
? choiceColor(column.color).color as string
@@ -49,22 +52,13 @@ export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onA
background: dotColor,
}}
/>
-
- {column.isNoValue ? t("No value") : column.name}
-
+
{count !== undefined && {count}}
{canEdit && (
<>
-
+ {property && (
+
+ )}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column-menu.tsx b/apps/client/src/ee/base/components/kanban/kanban-column-menu.tsx
new file mode 100644
index 000000000..be2e88f76
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-column-menu.tsx
@@ -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 (
+
+
+
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {view === "menu" ? (
+
+ }
+ label={t("Edit property")}
+ onClick={() => setView("property")}
+ />
+ }
+ label={t("Hide group")}
+ onClick={() => {
+ handleClose();
+ onHide();
+ }}
+ />
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column-title.tsx b/apps/client/src/ee/base/components/kanban/kanban-column-title.tsx
new file mode 100644
index 000000000..fe13996ed
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-column-title.tsx
@@ -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 (
+
+ {column.isNoValue ? t("No value") : column.name}
+
+ );
+ }
+
+ return (
+ {
+ if (!next) commit();
+ }}
+ position="bottom-start"
+ shadow="md"
+ width={240}
+ withinPortal
+ trapFocus
+ returnFocus
+ closeOnClickOutside
+ closeOnEscape={false}
+ >
+
+
+
+ {column.name}
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ cancel();
+ }
+ }}
+ >
+
+ e.currentTarget.select()}
+ onChange={(e) => setDraft(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commit();
+ }
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column.tsx b/apps/client/src/ee/base/components/kanban/kanban-column.tsx
index 95c1a1858..9975f4f66 100644
--- a/apps/client/src/ee/base/components/kanban/kanban-column.tsx
+++ b/apps/client/src/ee/base/components/kanban/kanban-column.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
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 { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
@@ -19,6 +19,7 @@ type KanbanColumnProps = {
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
+ groupByProperty: IBaseProperty | undefined;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
@@ -33,6 +34,7 @@ export function KanbanColumn({
column,
viewFilter,
groupByPropertyId,
+ groupByProperty,
canEdit,
onOpenRow,
onHide,
@@ -139,6 +141,7 @@ export function KanbanColumn({
onHide(column.key)}
diff --git a/apps/client/src/ee/base/components/property/property-menu.tsx b/apps/client/src/ee/base/components/property/property-menu.tsx
index 0ef1e9c48..492339904 100644
--- a/apps/client/src/ee/base/components/property/property-menu.tsx
+++ b/apps/client/src/ee/base/components/property/property-menu.tsx
@@ -53,6 +53,7 @@ type PropertyMenuContentProps = {
onDirtyChange?: (dirty: boolean) => void;
onEditFormula?: () => void;
pageId: string;
+ initialPanel?: "main" | "options";
};
type MenuPanel =
@@ -96,9 +97,10 @@ export function PropertyMenuContent({
onDirtyChange,
onEditFormula,
pageId,
+ initialPanel,
}: PropertyMenuContentProps) {
const { t } = useTranslation();
- const [panel, setPanel] = useState("main");
+ const [panel, setPanel] = useState(initialPanel ?? "main");
const [renameValue, setRenameValue] = useState(property.name);
const renameInputRef = useRef(null);
const [optionsDirty, setOptionsDirty] = useState(false);
@@ -117,12 +119,12 @@ export function PropertyMenuContent({
useEffect(() => {
if (opened) {
- setPanel("main");
+ setPanel(initialPanel ?? "main");
setRenameValue(property.name);
setOptionsDirty(false);
setPendingTargetType(null);
}
- }, [opened, property.name]);
+ }, [opened, property.name, initialPanel]);
useEffect(() => {
if (panel === "rename") {
@@ -463,7 +465,7 @@ export function PropertyMenuContent({
PropertyMenuContent.displayName = "PropertyMenuContent";
-function MenuItem({
+export function MenuItem({
icon,
label,
rightIcon,
diff --git a/apps/client/src/ee/base/hooks/use-kanban-columns.ts b/apps/client/src/ee/base/hooks/use-kanban-columns.ts
index 493da5ab8..df7ee9213 100644
--- a/apps/client/src/ee/base/hooks/use-kanban-columns.ts
+++ b/apps/client/src/ee/base/hooks/use-kanban-columns.ts
@@ -1,5 +1,5 @@
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 };
@@ -8,6 +8,7 @@ export function useKanbanColumns(
view: IBaseView | undefined,
): {
groupByPropertyId: string | undefined;
+ groupByProperty: IBaseProperty | undefined;
columns: KanbanColumn[];
allGroups: KanbanGroup[];
hasValidGroupBy: boolean;
@@ -18,7 +19,7 @@ export function useKanbanColumns(
const groupable = prop && (prop.type === "select" || prop.type === "status");
if (!groupable || !prop || !view) {
- return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
+ return { groupByPropertyId, groupByProperty: undefined, columns: [], allGroups: [], hasValidGroupBy: false };
}
const typeOptions = prop.typeOptions as SelectTypeOptions;
@@ -47,6 +48,6 @@ export function useKanbanColumns(
});
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
- return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
+ return { groupByPropertyId, groupByProperty: prop, columns, allGroups, hasValidGroupBy: true };
}, [base, view]);
}
diff --git a/apps/client/src/ee/base/styles/kanban.module.css b/apps/client/src/ee/base/styles/kanban.module.css
index 915188c73..c6aaa1ddd 100644
--- a/apps/client/src/ee/base/styles/kanban.module.css
+++ b/apps/client/src/ee/base/styles/kanban.module.css
@@ -76,6 +76,21 @@
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 {
display: flex;
flex-direction: column;
diff --git a/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts b/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts
index 8ef1fd500..107ed9576 100644
--- a/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts
+++ b/apps/client/src/features/editor/components/base-embed/insert-base-embed.ts
@@ -2,6 +2,8 @@ import type { Editor, Range } from "@tiptap/core";
import { v7 as uuid7 } from "uuid";
import { notifications } from "@mantine/notifications";
import api from "@/lib/api-client";
+import i18n from "@/i18n.ts";
+import { getApiErrorMessage } from "@/lib/api-error";
function findBaseEmbedPlaceholderPos(
editor: Editor,
@@ -50,7 +52,7 @@ export async function insertBaseEmbedBlock(
return true;
})
.run();
- } catch {
+ } catch (err) {
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
if (pos !== null) {
editor
@@ -62,6 +64,9 @@ export async function insertBaseEmbedBlock(
})
.run();
}
- notifications.show({ message: "Failed to create base", color: "red" });
+ notifications.show({
+ message: getApiErrorMessage(err, i18n.t("Failed to create base")),
+ color: "red",
+ });
}
}
diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx
index 2e414b217..b12f609cf 100644
--- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx
+++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx
@@ -1,6 +1,6 @@
import { FC } from "react";
import type { Editor } from "@tiptap/react";
-import { ActionIcon, Menu, Tooltip } from "@mantine/core";
+import { ActionIcon, Badge, Menu, Tooltip } from "@mantine/core";
import {
IconAppWindow,
IconCalendar,
@@ -32,6 +32,9 @@ import {
} from "@/components/icons";
import { useTranslation } from "react-i18next";
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 {
editor: Editor;
@@ -40,6 +43,8 @@ interface Props {
export const MoreInsertsGroup: FC = ({ editor, templateMode }) => {
const { t, i18n } = useTranslation();
+ const hasBases = useHasFeature(Feature.BASES);
+ const upgradeLabel = useUpgradeLabel();
const setEmbed = (provider: string) =>
editor.chain().focus().setEmbed({ provider }).run();
@@ -106,20 +111,48 @@ export const MoreInsertsGroup: FC = ({ editor, templateMode }) => {
)}
{!templateMode && (
- }
- onClick={() => insertBaseEmbedBlock(editor)}
- >
- {t("Base (Inline)")}
-
+
+ }
+ aria-disabled={!hasBases}
+ closeMenuOnClick={hasBases}
+ style={{ opacity: hasBases ? undefined : 0.7 }}
+ rightSection={
+ !hasBases && (
+
+ {t("Upgrade")}
+
+ )
+ }
+ onClick={() => {
+ if (hasBases) insertBaseEmbedBlock(editor);
+ }}
+ >
+ {t("Base (Inline)")}
+
+
)}
{!templateMode && (
- }
- onClick={() => insertBaseEmbedBlock(editor, { template: "kanban" })}
- >
- {t("Kanban")}
-
+
+ }
+ aria-disabled={!hasBases}
+ closeMenuOnClick={hasBases}
+ style={{ opacity: hasBases ? undefined : 0.7 }}
+ rightSection={
+ !hasBases && (
+
+ {t("Upgrade")}
+
+ )
+ }
+ onClick={() => {
+ if (hasBases) insertBaseEmbedBlock(editor, { template: "kanban" });
+ }}
+ >
+ {t("Kanban")}
+
+
)}
diff --git a/apps/client/src/features/editor/components/slash-menu/command-list.tsx b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
index d645e404f..885c056e2 100644
--- a/apps/client/src/features/editor/components/slash-menu/command-list.tsx
+++ b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
@@ -10,6 +10,7 @@ import {
Paper,
ScrollArea,
Text,
+ Tooltip,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
@@ -18,6 +19,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
+import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
const CommandList = ({
items,
@@ -37,11 +39,12 @@ const CommandList = ({
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const hasBases = useHasFeature(Feature.BASES);
- // Title must match the "Base (Inline)" item in menu-items.ts. Without the
- // bases entitlement the item stays visible but disabled; an expired license
- // the client can't detect falls through to a handled create failure.
+ const upgradeLabel = useUpgradeLabel();
+ // Without the bases entitlement the item stays visible but inert; an
+ // expired license the client can't detect falls through to a handled
+ // create failure.
const isItemDisabled = (item: SlashMenuItemType) =>
- !hasBases && item.title === "Base (Inline)";
+ !hasBases && item.requiresBases === true;
const flatItems = useMemo(() => {
return Object.values(items).flat();
@@ -152,18 +155,22 @@ const CommandList = ({
const itemIndex = flatIndex;
const disabled = isItemDisabled(item);
return (
+
selectItem(itemIndex)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: itemIndex === selectedIndex,
- [classes.disabledItem]: disabled,
+ [classes.gatedItem]: disabled,
})}
>
@@ -188,6 +195,7 @@ const CommandList = ({
)}
+
);
})}
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 352bdb84c..0327acdde 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -366,6 +366,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert an inline base on this page",
searchTerms: ["base", "database", "table", "grid", "spreadsheet"],
icon: IconTable,
+ requiresBases: true,
command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range });
},
@@ -375,6 +376,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
description: "Insert a kanban board on this page",
searchTerms: ["kanban", "board", "cards", "status", "task", "database"],
icon: IconLayoutKanban,
+ requiresBases: true,
command: ({ editor, range }: CommandProps) => {
insertBaseEmbedBlock(editor, { range, template: "kanban" });
},
diff --git a/apps/client/src/features/editor/components/slash-menu/slash-menu.module.css b/apps/client/src/features/editor/components/slash-menu/slash-menu.module.css
index 24ad3ecc0..5ffe1e591 100644
--- a/apps/client/src/features/editor/components/slash-menu/slash-menu.module.css
+++ b/apps/client/src/features/editor/components/slash-menu/slash-menu.module.css
@@ -26,7 +26,6 @@
}
}
-.disabledItem {
- opacity: 0.45;
- cursor: not-allowed;
+.gatedItem {
+ opacity: 0.7;
}
diff --git a/apps/client/src/features/editor/components/slash-menu/types.ts b/apps/client/src/features/editor/components/slash-menu/types.ts
index cf5bd3e42..07e1f6a4f 100644
--- a/apps/client/src/features/editor/components/slash-menu/types.ts
+++ b/apps/client/src/features/editor/components/slash-menu/types.ts
@@ -21,6 +21,7 @@ export type SlashMenuItemType = {
searchTerms: string[];
command: (props: CommandProps) => void;
disable?: (editor: ReturnType) => boolean;
+ requiresBases?: true;
};
export type SlashMenuGroupedItemsType = {