feat(ee): templates

This commit is contained in:
Philipinho
2026-05-19 02:03:27 +01:00
parent a689cca7a0
commit bf9ea63247
24 changed files with 678 additions and 69 deletions
@@ -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<HTMLInputElement>) => {
@@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() {
};
return (
<Tooltip
label={upgradeLabel}
disabled={hasSecuritySettings}
refProp="rootRef"
>
<Tooltip label={upgradeLabel} disabled={hasTemplates} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSecuritySettings}
disabled={!hasTemplates}
aria-label={t("Toggle allow members to create templates")}
/>
</Tooltip>
@@ -137,7 +137,6 @@ export default function Security() {
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
@@ -8,6 +8,11 @@
@mixin hover {
transform: scale(1.02);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
.cardBody {
@@ -50,18 +55,27 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
gap: 6px;
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-lg);
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.scopeDot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background-color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.menuTarget {
opacity: 0;
transition: opacity 100ms ease;
.card:hover & {
.card:hover &,
.card:focus-within & {
opacity: 1;
}
}
@@ -1,4 +1,4 @@
import { Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import { Button, Card, Text, ActionIcon, Menu, Group } from "@mantine/core";
import {
IconDots,
IconEdit,
@@ -12,6 +12,7 @@ import classes from "./template-card.module.css";
type TemplateCardProps = {
template: ITemplate;
spaceName?: string;
onPreview: (template: ITemplate) => void;
onUse: (template: ITemplate) => void;
onEdit?: (template: ITemplate) => void;
onDelete?: (template: ITemplate) => void;
@@ -21,6 +22,7 @@ type TemplateCardProps = {
export default function TemplateCard({
template,
spaceName,
onPreview,
onUse,
onEdit,
onDelete,
@@ -34,7 +36,17 @@ export default function TemplateCard({
padding="lg"
className={classes.card}
style={{ cursor: "pointer" }}
onClick={() => onUse(template)}
role="button"
tabIndex={0}
aria-label={t("Preview template: {{title}}", { title: template.title })}
onClick={() => onPreview(template)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPreview(template);
}
}}
>
<div className={classes.cardBody}>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
@@ -47,6 +59,17 @@ export default function TemplateCard({
)}
<Group gap={6} wrap="nowrap">
<Button
size="compact-xs"
variant="filled"
className={classes.menuTarget}
onClick={(e) => {
e.stopPropagation();
onUse(template);
}}
>
{t("Use")}
</Button>
{canManage && (
<Menu width={150} shadow="md" withArrow>
<Menu.Target>
@@ -91,6 +114,7 @@ export default function TemplateCard({
<div className={classes.title}>{template.title}</div>
<div className={classes.footer}>
<span className={classes.scopeDot} aria-hidden="true" />
<Text size="sm" fw={500} c="dimmed">
{template.spaceId ? (spaceName || t("Space")) : t("Global")}
</Text>
@@ -0,0 +1,60 @@
.row {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
width: 100%;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
line-height: 1;
}
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--mantine-font-size-sm);
text-align: left;
}
.scope {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
flex-shrink: 0;
}
.useButton {
opacity: 0;
transition: opacity 100ms ease;
flex-shrink: 0;
.row:hover &,
.row:focus-within &,
&:focus-visible {
opacity: 1;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
@@ -0,0 +1,261 @@
import { useMemo, useState } from "react";
import {
Button,
Modal,
TextInput,
ScrollArea,
Loader,
Text,
UnstyledButton,
Group,
SegmentedControl,
Anchor,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconSearch,
IconFileText,
IconExternalLink,
} from "@tabler/icons-react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
useGetTemplatesQuery,
useUseTemplateMutation,
} from "@/ee/template/queries/template-query";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { ITemplate } from "@/ee/template/types/template.types";
import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import { buildPageUrl } from "@/features/page/page.utils";
import classes from "./template-picker-modal.module.css";
type TemplatePickerModalProps = {
opened: boolean;
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
};
type ScopeFilter = "current" | "all";
export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const useTemplateMutation = useUseTemplateMutation();
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 200);
const [scope, setScope] = useState<ScopeFilter>(
initialSpaceId ? "current" : "all",
);
// Two-stage selection: previewing first, then destination-picker.
// `previewTemplate` is set when the user clicks a row in the picker.
// `destinationTemplate` is set when they click "Use template" in the preview.
const [previewTemplate, setPreviewTemplate] = useState<ITemplate | null>(
null,
);
const [destinationTemplate, setDestinationTemplate] =
useState<ITemplate | null>(null);
const { data, isPending } = useGetTemplatesQuery({
spaceId: scope === "current" ? initialSpaceId : undefined,
});
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
const spaceNamesById = useMemo(() => {
const map = new Map<string, string>();
spacesData?.items?.forEach((s) => map.set(s.id, s.name));
return map;
}, [spacesData]);
const filtered = useMemo(() => {
const all = data?.pages.flatMap((p) => p.items) ?? [];
const term = debouncedQuery.trim().toLowerCase();
if (!term) return all;
return all.filter((tpl) => tpl.title.toLowerCase().includes(term));
}, [data, debouncedQuery]);
const createInInitialSpace = async (tpl: ITemplate) => {
if (!initialSpaceId) return;
try {
const page = await useTemplateMutation.mutateAsync({
templateId: tpl.id,
spaceId: initialSpaceId,
});
setPreviewTemplate(null);
onClose();
const space = spacesData?.items?.find((s) => s.id === initialSpaceId);
if (page?.slugId && space?.slug) {
navigate(buildPageUrl(space.slug, page.slugId, page.title));
}
} catch {
// error notification handled by mutation's onError
}
};
const handlePick = (tpl: ITemplate) => {
setPreviewTemplate(tpl);
};
const handleQuickUse = (tpl: ITemplate) => {
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
}
setDestinationTemplate(tpl);
};
const handlePreviewClose = () => {
// Closing preview returns to the picker list (no full unmount).
setPreviewTemplate(null);
};
const handlePreviewUse = () => {
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;
}
// Move from preview into destination-picker stage.
setDestinationTemplate(previewTemplate);
setPreviewTemplate(null);
};
const handleDestinationClose = () => {
setDestinationTemplate(null);
onClose();
};
const handleClose = () => {
setQuery("");
setScope(initialSpaceId ? "current" : "all");
setPreviewTemplate(null);
setDestinationTemplate(null);
onClose();
};
return (
<>
<Modal
opened={opened && !previewTemplate && !destinationTemplate}
onClose={handleClose}
size={550}
padding="lg"
yOffset="10vh"
title={<Text fw={500}>{t("Use a template")}</Text>}
>
<TextInput
leftSection={<IconSearch size={16} />}
placeholder={t("Search templates...")}
variant="filled"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
mb="xs"
autoFocus
/>
{initialSpaceId && (
<SegmentedControl
fullWidth
size="xs"
mb="sm"
value={scope}
onChange={(v) => setScope(v as ScopeFilter)}
data={[
{ label: t("This space"), value: "current" },
{ label: t("All templates"), value: "all" },
]}
/>
)}
<ScrollArea h="50vh" offsetScrollbars>
{isPending ? (
<div className={classes.empty}>
<Loader size="xs" />
</div>
) : filtered.length === 0 ? (
<div className={classes.empty}>
<Text size="sm" c="dimmed">
{t("No templates found")}
</Text>
</div>
) : (
filtered.map((tpl) => (
<UnstyledButton
key={tpl.id}
className={classes.row}
onClick={() => handlePick(tpl)}
>
<div className={classes.icon}>
{tpl.icon ? (
<span>{tpl.icon}</span>
) : (
<IconFileText
size={16}
color="var(--mantine-color-gray-6)"
/>
)}
</div>
<div className={classes.title}>{tpl.title}</div>
<div className={classes.scope}>
{tpl.spaceId
? spaceNamesById.get(tpl.spaceId) ?? t("Space")
: t("Global")}
</div>
<Button
size="compact-xs"
variant="filled"
className={classes.useButton}
loading={useTemplateMutation.isPending}
disabled={useTemplateMutation.isPending}
onClick={(e) => {
e.stopPropagation();
handleQuickUse(tpl);
}}
>
{t("Use")}
</Button>
</UnstyledButton>
))
)}
</ScrollArea>
<Group justify="space-between" mt="md">
<Anchor
component={Link}
to="/templates"
size="sm"
onClick={handleClose}
>
<Group gap={4} wrap="nowrap">
{t("Browse all templates")}
<IconExternalLink size={14} />
</Group>
</Anchor>
</Group>
</Modal>
{previewTemplate && (
<TemplatePreviewModal
templateId={previewTemplate.id}
opened={true}
onClose={handlePreviewClose}
onUse={handlePreviewUse}
useLoading={useTemplateMutation.isPending}
/>
)}
{destinationTemplate && (
<UseTemplateModal
template={destinationTemplate}
opened={true}
onClose={handleDestinationClose}
initialSpaceId={initialSpaceId}
/>
)}
</>
);
}
@@ -9,6 +9,7 @@ type TemplatePreviewModalProps = {
onClose: () => void;
onUse: () => void;
onEdit?: () => void;
useLoading?: boolean;
};
export default function TemplatePreviewModal({
@@ -17,6 +18,7 @@ export default function TemplatePreviewModal({
onClose,
onUse,
onEdit,
useLoading,
}: TemplatePreviewModalProps) {
const { t } = useTranslation();
const { data: template, isLoading } = useGetTemplateByIdQuery(templateId);
@@ -37,14 +39,19 @@ export default function TemplatePreviewModal({
</Group>
</Modal.Title>
<Group gap="sm">
<Button
size="xs"
onClick={onUse}
loading={useLoading}
disabled={useLoading}
>
{t("Use template")}
</Button>
{onEdit && (
<Button size="xs" variant="default" onClick={onEdit}>
{t("Edit")}
</Button>
)}
<Button size="xs" onClick={onUse}>
{t("Use template")}
</Button>
<Modal.CloseButton />
</Group>
</Modal.Header>
@@ -10,12 +10,14 @@ type UseTemplateModalProps = {
template: ITemplate;
opened: boolean;
onClose: () => void;
initialSpaceId?: string;
};
export default function UseTemplateModal({
template,
opened,
onClose,
initialSpaceId,
}: UseTemplateModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -54,6 +56,8 @@ export default function UseTemplateModal({
actionLabel={t("Create page")}
onSelect={handleSelect}
loading={useTemplateMutation.isPending}
initialSpaceId={initialSpaceId ?? template.spaceId}
searchSpacesOnly
/>
);
}
@@ -75,6 +75,18 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
},
onUpdate() {
if (loadedRef.current) {
markDirty();
@@ -160,7 +160,8 @@ export default function TemplateList() {
? spaceNameMap.get(template.spaceId)
: undefined
}
onUse={handlePreview}
onPreview={handlePreview}
onUse={handleUse}
onEdit={handleEdit}
onDelete={handleDelete}
canManage={isWorkspaceAdmin}