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 && ( <> - - - - - - - - {t("Hide group")} - - + {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 = {