From 6cf8101ab3661c00e1b960739377d4c2bb71e73d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 19 May 2026 02:41:52 +0100 Subject: [PATCH] feat(ee): templates (#2215) * feat(ee): templates * fix tree * fix --- .../public/locales/en-US/translation.json | 29 ++ .../layouts/global/global-sidebar.module.css | 10 + .../layouts/global/global-sidebar.tsx | 68 +++-- .../destination-picker-modal.tsx | 4 + .../destination-picker.module.css | 10 +- .../destination-picker/destination-picker.tsx | 88 +++++- .../destination-picker.types.ts | 2 + .../ui/destination-picker/page-row.tsx | 44 ++- .../ui/destination-picker/space-row.tsx | 32 ++- .../components/allow-member-templates.tsx | 10 +- .../client/src/ee/security/pages/security.tsx | 1 - .../components/template-card.module.css | 20 +- .../ee/template/components/template-card.tsx | 28 +- .../template-picker-modal.module.css | 70 +++++ .../components/template-picker-modal.tsx | 259 ++++++++++++++++++ .../components/template-preview-modal.tsx | 13 +- .../components/use-template-modal.tsx | 4 + .../src/ee/template/pages/template-editor.tsx | 12 + .../src/ee/template/pages/template-list.tsx | 3 +- .../src/ee/template/queries/template-query.ts | 70 ++++- .../ee/template/services/template-service.ts | 5 +- .../features/editor/extensions/extensions.ts | 4 +- .../components/sidebar/space-sidebar.tsx | 32 +++ .../settings/workspace/workspace-settings.tsx | 4 + .../database/repos/template/template.repo.ts | 6 +- 25 files changed, 752 insertions(+), 76 deletions(-) create mode 100644 apps/client/src/ee/template/components/template-picker-modal.module.css create mode 100644 apps/client/src/ee/template/components/template-picker-modal.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index d59aa94b9..62927f66a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -936,6 +936,35 @@ "Page actions": "Page actions", "Pick emoji": "Pick emoji", "Template menu": "Template menu", + "Use": "Use", + "Use template": "Use template", + "Preview template: {{title}}": "Preview template: {{title}}", + "Use a template": "Use a template", + "Search templates...": "Search templates...", + "Search spaces...": "Search spaces...", + "No templates found": "No templates found", + "No spaces found": "No spaces found", + "Browse all templates": "Browse all templates", + "This space": "This space", + "All templates": "All templates", + "Global": "Global", + "New template": "New template", + "Edit template": "Edit template", + "Are you sure you want to delete this template?": "Are you sure you want to delete this template?", + "Template scope updated": "Template scope updated", + "Choose which space this template belongs to": "Choose which space this template belongs to", + "Scope": "Scope", + "Select scope": "Select scope", + "Title": "Title", + "Saving...": "Saving...", + "Saved": "Saved", + "Save failed. Retry": "Save failed. Retry", + "By {{name}}": "By {{name}}", + "Updated {{time}}": "Updated {{time}}", + "Choose destination": "Choose destination", + "Search pages and spaces...": "Search pages and spaces...", + "No results found": "No results found", + "You don't have permission to create pages here": "You don't have permission to create pages here", "Chat menu": "Chat menu", "API key menu": "API key menu", "Jump to comment selection": "Jump to comment selection", diff --git a/apps/client/src/components/layouts/global/global-sidebar.module.css b/apps/client/src/components/layouts/global/global-sidebar.module.css index 9a7bc1b6f..1385ec20d 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.module.css +++ b/apps/client/src/components/layouts/global/global-sidebar.module.css @@ -38,6 +38,16 @@ color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); } } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + + @mixin hover { + background-color: transparent; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + } + } } .linkIcon { diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx index 5019d5d98..6882f81d4 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.tsx +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core"; +import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core"; import { IconHome, IconClock, @@ -7,6 +7,7 @@ import { IconLayoutGrid, IconSettings, IconUserPlus, + IconTemplate, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./global-sidebar.module.css"; @@ -20,12 +21,9 @@ import { useDisclosure } from "@mantine/hooks"; import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import { AvatarIconType } from "@/features/attachments/types/attachment.types"; - -const mainNavItems = [ - { label: "Home", icon: IconHome, path: "/home" }, - { label: "Favorites", icon: IconStar, path: "/favorites" }, - { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, -]; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; export default function GlobalSidebar() { const { t } = useTranslation(); @@ -33,6 +31,19 @@ export default function GlobalSidebar() { const [active, setActive] = useState(location.pathname); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + const hasTemplates = useHasFeature(Feature.TEMPLATES); + const upgradeLabel = useUpgradeLabel(); + const mainNavItems = [ + { label: "Home", icon: IconHome, path: "/home" }, + { label: "Favorites", icon: IconStar, path: "/favorites" }, + { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, + { + label: "Templates", + icon: IconTemplate, + path: "/templates", + disabled: !hasTemplates, + }, + ]; const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space"); const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; const sortedFavoriteSpaces = [...favoriteSpaces] @@ -58,18 +69,37 @@ export default function GlobalSidebar() {
- {mainNavItems.map((item) => ( - - - {t(item.label)} - - ))} + {mainNavItems.map((item) => + item.disabled ? ( + + + + {t(item.label)} + + + ) : ( + + + {t(item.label)} + + ), + )}
diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx index 04e9ef7dd..21c906969 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -16,6 +16,8 @@ export function DestinationPickerModal({ loading, excludePageId, pageLimit, + initialSpaceId, + searchSpacesOnly, }: DestinationPickerModalProps) { const { t } = useTranslation(); const [selection, setSelection] = useState(null); @@ -46,6 +48,8 @@ export function DestinationPickerModal({ onSelectionChange={setSelection} excludePageId={excludePageId} pageLimit={pageLimit} + initialSpaceId={initialSpaceId} + searchSpacesOnly={searchSpacesOnly} /> diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.module.css b/apps/client/src/components/ui/destination-picker/destination-picker.module.css index ec598ac50..fb868bc14 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.module.css +++ b/apps/client/src/components/ui/destination-picker/destination-picker.module.css @@ -13,6 +13,7 @@ display: flex; align-items: center; gap: 8px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); transition: background-color 150ms ease; user-select: none; @@ -22,6 +23,11 @@ var(--mantine-color-dark-6) ); } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } } .selected { @@ -57,7 +63,7 @@ border-radius: var(--mantine-radius-sm); flex-shrink: 0; transition: transform 150ms ease; - color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); @mixin hover { background-color: light-dark( @@ -111,7 +117,7 @@ } .spaceName { - color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); font-size: var(--mantine-font-size-xs); flex-shrink: 0; } diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.tsx b/apps/client/src/components/ui/destination-picker/destination-picker.tsx index 1ddc747dd..b16a25a48 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback } from "react"; -import { TextInput, ScrollArea, Loader } from "@mantine/core"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; -import { IconSearch, IconFile } from "@tabler/icons-react"; +import { IconSearch, IconFileDescription } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; @@ -15,23 +15,29 @@ type DestinationPickerProps = { onSelectionChange: (selection: DestinationSelection | null) => void; excludePageId?: string; pageLimit?: number; + initialSpaceId?: string; + searchSpacesOnly?: boolean; }; export function DestinationPicker({ onSelectionChange, excludePageId, pageLimit = 15, + initialSpaceId, + searchSpacesOnly, }: DestinationPickerProps) { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); const [selection, setSelection] = useState(null); const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + const viewportRef = useRef(null); const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({ limit: 100, }); - const searchEnabled = debouncedQuery && debouncedQuery.length >= 2; + const searchEnabled = + !searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2; const { data: searchData, isLoading: searchLoading } = useSearchSuggestionsQuery({ @@ -42,6 +48,18 @@ export function DestinationPicker({ const isSearching = !!searchEnabled; + const filteredSpaces = useMemo(() => { + const items = spacesData?.items ?? []; + if (!searchSpacesOnly || !debouncedQuery) return items; + const fold = (s: string) => + s + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLocaleLowerCase(); + const term = fold(debouncedQuery); + return items.filter((s) => fold(s.name).includes(term)); + }, [spacesData, searchSpacesOnly, debouncedQuery]); + const selectedId = selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null; @@ -87,18 +105,48 @@ export function DestinationPicker({ [updateSelection], ); + // Pre-select space when initialSpaceId is set and spaces have loaded. + // Only runs once: skip if user has already made a selection. + useEffect(() => { + if (!initialSpaceId || selection) return; + const match = spacesData?.items?.find((s) => s.id === initialSpaceId); + if (match) { + updateSelection({ type: "space", spaceId: match.id, space: match }); + requestAnimationFrame(() => { + const el = viewportRef.current?.querySelector( + `[data-space-id="${match.id}"]`, + ); + el?.scrollIntoView({ block: "nearest" }); + }); + } + }, [initialSpaceId, selection, spacesData, updateSelection]); + return ( <> } - placeholder={t("Search pages and spaces...")} + placeholder={ + searchSpacesOnly + ? t("Search spaces...") + : t("Search pages and spaces...") + } + aria-label={ + searchSpacesOnly + ? t("Search spaces...") + : t("Search pages and spaces...") + } variant="filled" value={searchQuery} onChange={(e) => setSearchQuery(e.currentTarget.value)} className={classes.searchInput} /> - + {isSearching ? ( searchLoading ? (
@@ -111,16 +159,28 @@ export function DestinationPicker({
handleSearchResultClick(page)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSearchResultClick(page); + } + }} >
{page.icon ? ( page.icon ) : ( - + + + )}
@@ -141,8 +201,14 @@ export function DestinationPicker({
+ ) : filteredSpaces.length === 0 ? ( +
+ {searchSpacesOnly && debouncedQuery + ? t("No spaces found") + : t("No results found")} +
) : ( - spacesData?.items?.map((space) => ( + filteredSpaces.map((space) => ( { + if (!isExcluded) onSelect(page); + }; + + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(); + } + }; + return ( <>
!isExcluded && onSelect(page)} + role="button" + tabIndex={isExcluded ? -1 : 0} + aria-disabled={isExcluded || undefined} + onClick={handleSelect} + onKeyDown={handleRowKeyDown} > {page.hasChildren ? ( -
{ e.stopPropagation(); setExpanded(!expanded); }} > -
+ ) : (
)} @@ -61,10 +83,14 @@ export function PageRow({ {page.icon ? ( page.icon ) : ( - + + + )}
diff --git a/apps/client/src/components/ui/destination-picker/space-row.tsx b/apps/client/src/components/ui/destination-picker/space-row.tsx index 59273af7e..857f18f6c 100644 --- a/apps/client/src/components/ui/destination-picker/space-row.tsx +++ b/apps/client/src/components/ui/destination-picker/space-row.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Tooltip } from "@mantine/core"; +import { KeyboardEvent, useState } from "react"; +import { ActionIcon, Tooltip } from "@mantine/core"; import { IconChevronRight, IconLock } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { ISpace } from "@/features/space/types/space.types"; @@ -42,21 +42,43 @@ export function SpaceRow({ .filter(Boolean) .join(" "); + const handleSelect = () => { + if (writable) onSelectSpace(space); + }; + + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(); + } + }; + const rowContent = (
writable && onSelectSpace(space)} + data-space-id={space.id} + role="button" + tabIndex={writable ? 0 : -1} + aria-disabled={!writable || undefined} + onClick={handleSelect} + onKeyDown={handleRowKeyDown} > {writable ? ( -
{ e.stopPropagation(); setExpanded(!expanded); }} > -
+ ) : (
)} diff --git a/apps/client/src/ee/security/components/allow-member-templates.tsx b/apps/client/src/ee/security/components/allow-member-templates.tsx index f547d1644..8acc8ba6d 100644 --- a/apps/client/src/ee/security/components/allow-member-templates.tsx +++ b/apps/client/src/ee/security/components/allow-member-templates.tsx @@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() { const [checked, setChecked] = useState( workspace?.settings?.templates?.allowMemberTemplates === true, ); - const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS); + const hasTemplates = useHasFeature(Feature.TEMPLATES); const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { @@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() { }; return ( - + diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index ee6343516..2ff3670be 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -137,7 +137,6 @@ export default function Security() { { max: SCIM_TOKEN_LIMIT }, )} disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT} - refProp="rootRef" > {canManage && ( @@ -91,6 +114,7 @@ export default function TemplateCard({
{template.title}
+