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"
>