feat: add Template option to empty page get-started row (#2565)

This commit is contained in:
Philip Okugbe 2026-10-11 16:02:33 +01:00 committed by GitHub
parent 6a06d7fd7d
commit 297cc29bba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 173 additions and 43 deletions

View file

@ -1004,6 +1004,9 @@
"Use template": "Use template",
"Preview template: {{title}}": "Preview template: {{title}}",
"Use a template": "Use a template",
"Template": "Template",
"Failed to load template": "Failed to load template",
"This page is no longer empty": "This page is no longer empty",
"Search templates...": "Search templates...",
"Search spaces...": "Search spaces...",
"No templates found": "No templates found",

View file

@ -34,6 +34,8 @@ type TemplatePickerModalProps = {
onClose: () => void;
/** Pre-select this space in the destination picker after a template is chosen. */
initialSpaceId?: string;
/** Receives the chosen template instead of creating a page from it. */
onUse?: (template: ITemplate) => void;
};
type ScopeFilter = "current" | "all";
@ -42,6 +44,7 @@ export default function TemplatePickerModal({
opened,
onClose,
initialSpaceId,
onUse,
}: TemplatePickerModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@ -101,6 +104,11 @@ export default function TemplatePickerModal({
};
const handleQuickUse = (tpl: ITemplate) => {
if (onUse) {
onUse(tpl);
handleClose();
return;
}
if (initialSpaceId) {
createInInitialSpace(tpl);
return;
@ -114,6 +122,11 @@ export default function TemplatePickerModal({
};
const handlePreviewUse = () => {
if (onUse && previewTemplate) {
onUse(previewTemplate);
handleClose();
return;
}
if (initialSpaceId && previewTemplate) {
createInInitialSpace(previewTemplate);
return;

View file

@ -23,6 +23,10 @@ import { useTranslation } from "react-i18next";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import {
spaceRoots,
updateSpaceRoots,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@ -185,15 +189,14 @@ export function useUseTemplateMutation() {
children: [],
};
// Only mutate the tree atom and broadcast if it currently represents
// this space. Cross-space template-use (e.g., from the gallery picking
// Only mutate the tree atom and broadcast if this space's tree is
// loaded. Cross-space template-use (e.g., from the gallery picking
// a different space) lets the target space's clients pick up the new
// page on their next React Query refetch (focus, navigation, etc.).
// Without this guard we'd both pollute the local tree and send a wrong
// `index` to remote clients in the target space.
const current = store.get(treeDataAtom);
const treeIsForThisSpace = current[0]?.spaceId === page.spaceId;
if (!treeIsForThisSpace) return;
const current = spaceRoots(store.get(treeDataAtom), page.spaceId);
if (current.length === 0) return;
const lastIndex =
parentId === null
@ -201,7 +204,9 @@ export function useUseTemplateMutation() {
: (treeModel.find(current, parentId)?.children?.length ?? 0);
setTreeData((prev) =>
treeModel.insert(prev, parentId, newNode, lastIndex),
updateSpaceRoots(prev, page.spaceId, (roots) =>
treeModel.insert(roots, parentId, newNode, lastIndex),
),
);
setTimeout(() => {

View file

@ -0,0 +1,16 @@
import { Editor } from "@tiptap/core";
import { ITemplate } from "@/ee/template/types/template.types";
export function applyTemplateToPage(
editor: Editor,
titleEditor: Editor,
template: ITemplate,
): boolean {
if (!editor.isEmpty) return false;
if (titleEditor.isEmpty && template.title) {
titleEditor.commands.setContent(template.title, { emitUpdate: true });
}
editor.commands.setContent(template.content);
return true;
}

View file

@ -1,31 +1,54 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { Button } from "@mantine/core";
import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconTable,
IconLayoutKanban,
IconTemplate,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { ErrorBoundary } from "react-error-boundary";
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
import {
pageEditorAtom,
titleEditorAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { ITemplate } from "@/ee/template/types/template.types";
import { getTemplateById } from "@/ee/template/services/template-service.ts";
import { applyTemplateToPage } from "@/ee/template/template.utils";
import classes from "./empty-page-get-started.module.css";
const TemplatePickerModal = lazy(
() => import("@/ee/template/components/template-picker-modal"),
);
type EmptyPageGetStartedProps = {
pageId: string;
spaceId: string;
editable: boolean;
};
export function EmptyPageGetStarted({
pageId,
spaceId,
editable,
}: EmptyPageGetStartedProps) {
const { t } = useTranslation();
const editor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const isSynced = useAtomValue(yjsSyncedAtom);
const hasBases = useHasFeature(Feature.BASES);
const hasTemplates = useHasFeature(Feature.TEMPLATES);
const convertMutation = useConvertPageToBaseMutation();
const [
templatePickerOpened,
{ open: openTemplatePicker, close: closeTemplatePicker },
] = useDisclosure(false);
const [isEmpty, setIsEmpty] = useState(false);
useEffect(() => {
@ -40,43 +63,106 @@ export function EmptyPageGetStarted({
};
}, [editor]);
if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
const handleUseTemplate = async (selected: ITemplate) => {
let template: ITemplate;
try {
template = await getTemplateById(selected.id);
} catch {
notifications.show({
message: t("Failed to load template"),
color: "red",
});
return;
}
if (
!editor ||
editor.isDestroyed ||
!titleEditor ||
titleEditor.isDestroyed
) {
return;
}
if (!applyTemplateToPage(editor, titleEditor, template)) {
notifications.show({
message: t("This page is no longer empty"),
color: "red",
});
}
};
const chips = [
{
key: "base",
label: t("Base"),
icon: IconTable,
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
...(hasBases
? [
{
key: "base",
label: t("Base"),
icon: IconTable,
onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending,
},
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () =>
convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
]
: []),
...(hasTemplates
? [
{
key: "template",
label: t("Template"),
icon: IconTemplate,
onClick: openTemplatePicker,
disabled: false,
},
]
: []),
];
const showChips =
editable && !!editor && isSynced && isEmpty && chips.length > 0;
return (
<div className={classes.wrapper} contentEditable={false}>
<span className={classes.label}>{t("Get started with")}</span>
<div className={classes.chipRow}>
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
onClick={chip.onClick}
disabled={chip.disabled}
>
{chip.label}
</Button>
))}
</div>
</div>
<>
{showChips && (
<div className={classes.wrapper} contentEditable={false}>
<span className={classes.label}>{t("Get started with")}</span>
<div className={classes.chipRow}>
{chips.map((chip) => (
<Button
key={chip.key}
variant="default"
size="xs"
radius="xl"
leftSection={<chip.icon size={16} />}
onClick={chip.onClick}
disabled={chip.disabled}
>
{chip.label}
</Button>
))}
</div>
</div>
)}
{hasTemplates && templatePickerOpened && (
<ErrorBoundary fallbackRender={() => null}>
<Suspense fallback={null}>
<TemplatePickerModal
opened={templatePickerOpened}
onClose={closeTemplatePicker}
initialSpaceId={spaceId}
onUse={handleUseTemplate}
/>
</Suspense>
</ErrorBoundary>
)}
</>
);
}

View file

@ -48,6 +48,7 @@ export interface FullEditorProps {
slugId: string;
title: string;
content: string;
spaceId: string;
spaceSlug: string;
editable: boolean;
creator?: PageUser;
@ -60,6 +61,7 @@ export function FullEditor({
title,
slugId,
content,
spaceId,
spaceSlug,
editable,
creator,
@ -115,7 +117,11 @@ export function FullEditor({
content={content}
canComment={canComment}
/>
<EmptyPageGetStarted pageId={pageId} editable={editable} />
<EmptyPageGetStarted
pageId={pageId}
spaceId={spaceId}
editable={editable}
/>
</Container>
);
}

View file

@ -184,6 +184,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
title={page.title}
content={page.content}
slugId={page.slugId}
spaceId={page.spaceId}
spaceSlug={page?.space?.slug}
editable={canEdit}
creator={page.creator}

@ -1 +1 @@
Subproject commit 662a0042eb6c56a01f8958447780fe2354467b14
Subproject commit e90378d63dd5f75e771d2bed05a550fad6e0d5ef