fix: license-gate base and kanban inserts

This commit is contained in:
Philipinho
2026-07-03 01:47:51 +01:00
parent 6ff7f69137
commit 6126fe378b
6 changed files with 73 additions and 25 deletions
@@ -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",
});
} }
} }
@@ -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 = {