feat(core): new template doc property (#9538)

close AF-2045, AF-2047, AF-2065
This commit is contained in:
CatsJuice
2025-01-14 02:10:33 +00:00
parent 777eea124d
commit 10196f6785
26 changed files with 719 additions and 8 deletions

View File

@@ -68,6 +68,7 @@ import {
} from './specs/custom/spec-patchers';
import { createEdgelessModeSpecs } from './specs/edgeless';
import { createPageModeSpecs } from './specs/page';
import { StarterBar } from './starter-bar';
import * as styles from './styles.css';
const adapted = {
@@ -334,6 +335,7 @@ export const BlocksuiteDocEditor = forwardRef<
data-testid="page-editor-blank"
onClick={onClickBlank}
></div>
<StarterBar doc={page} />
{!shared && displayBiDirectionalLink ? (
<BiDirectionalLinkPanel />
) : null}

View File

@@ -0,0 +1,66 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { container } from './bi-directional-link-panel.css';
export const root = style([
container,
{
paddingBottom: 6,
display: 'flex',
gap: 8,
alignItems: 'center',
fontSize: 12,
fontWeight: 400,
lineHeight: '20px',
color: cssVarV2.text.primary,
},
]);
export const badges = style({
display: 'flex',
gap: 12,
alignItems: 'center',
});
export const badge = style({
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 40,
backgroundColor: cssVarV2.layer.background.secondary,
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
':before': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0,0,0,.04)',
borderRadius: 'inherit',
opacity: 0,
transition: 'opacity 0.2s ease',
},
selectors: {
'&:hover:before': {
opacity: 1,
},
'&[data-active="true"]:before': {
opacity: 1,
},
},
});
export const badgeIcon = style({
fontSize: 16,
lineHeight: 0,
});
export const badgeText = style({});

View File

@@ -0,0 +1,126 @@
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
TemplateDocService,
TemplateListMenu,
} from '@affine/core/modules/template-doc';
import { useI18n } from '@affine/i18n';
import type { Store } from '@blocksuite/affine/store';
import {
AiIcon,
EdgelessIcon,
TemplateColoredIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
useEffect,
useMemo,
useState,
} from 'react';
import * as styles from './starter-bar.css';
const Badge = forwardRef<
HTMLLIElement,
HTMLAttributes<HTMLLIElement> & {
icon: React.ReactNode;
text: string;
active?: boolean;
}
>(function Badge({ icon, text, className, active, ...attrs }, ref) {
return (
<li
data-active={active}
className={clsx(styles.badge, className)}
ref={ref}
{...attrs}
>
<span className={styles.badgeText}>{text}</span>
<span className={styles.badgeIcon}>{icon}</span>
</li>
);
});
const StarterBarNotEmpty = ({ doc }: { doc: Store }) => {
const t = useI18n();
const templateDocService = useService(TemplateDocService);
const featureFlagService = useService(FeatureFlagService);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const isTemplate = useLiveData(
useMemo(
() => templateDocService.list.isTemplate$(doc.id),
[doc.id, templateDocService.list]
)
);
const enableTemplateDoc = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const showAI = false;
const showEdgeless = false;
const showTemplate = !isTemplate && enableTemplateDoc;
if (!showAI && !showEdgeless && !showTemplate) {
return null;
}
return (
<div className={styles.root}>
{t['com.affine.page-starter-bar.start']()}
<ul className={styles.badges}>
{showAI ? (
<Badge
icon={<AiIcon />}
text={t['com.affine.page-starter-bar.ai']()}
/>
) : null}
{showTemplate ? (
<TemplateListMenu
target={doc.id}
rootOptions={{
open: templateMenuOpen,
onOpenChange: setTemplateMenuOpen,
}}
>
<Badge
data-testid="template-docs-badge"
icon={<TemplateColoredIcon />}
text={t['com.affine.page-starter-bar.template']()}
active={templateMenuOpen}
/>
</TemplateListMenu>
) : null}
{showEdgeless ? (
<Badge
icon={<EdgelessIcon />}
text={t['com.affine.page-starter-bar.edgeless']()}
/>
) : null}
</ul>
</div>
);
};
export const StarterBar = ({ doc }: { doc: Store }) => {
const [isEmpty, setIsEmpty] = useState(doc.isEmpty);
useEffect(() => {
const disposable = doc.slots.blockUpdated.on(() => {
setIsEmpty(doc.isEmpty);
});
return () => {
disposable.dispose();
};
}, [doc]);
if (!isEmpty) return null;
return <StarterBarNotEmpty doc={doc} />;
};

View File

@@ -15,6 +15,7 @@ import type {
DatabaseRow,
DatabaseValueCell,
} from '@affine/core/modules/doc-info/types';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -126,9 +127,13 @@ export const DocPropertyRow = ({
const t = useI18n();
const docService = useService(DocService);
const docsService = useService(DocsService);
const featureFlagService = useService(FeatureFlagService);
const customPropertyValue = useLiveData(
docService.doc.customProperty$(propertyInfo.id)
);
const enableTemplateDoc = useLiveData(
featureFlagService.flags.enable_template_doc.$
);
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type]
: undefined;
@@ -203,6 +208,9 @@ export const DocPropertyRow = ({
);
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
if (propertyInfo.id === 'template' && !enableTemplateDoc) {
return null;
}
return (
<PropertyRoot

View File

@@ -9,6 +9,7 @@ import {
LongerIcon,
NumberIcon,
TagIcon,
TemplateOutlineIcon,
TextIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
@@ -22,6 +23,7 @@ import { JournalValue } from './journal';
import { NumberValue } from './number';
import { PageWidthValue } from './page-width';
import { TagsValue } from './tags';
import { TemplateValue } from './template';
import { TextValue } from './text';
import type { PropertyValueProps } from './types';
@@ -108,6 +110,14 @@ export const DocPropertyTypes = {
name: 'com.affine.page-properties.property.pageWidth',
description: 'com.affine.page-properties.property.pageWidth.tooltips',
},
template: {
uniqueId: 'template',
icon: TemplateOutlineIcon,
value: TemplateValue,
name: 'com.affine.page-properties.property.template',
renameable: false,
description: 'com.affine.page-properties.property.template.tooltips',
},
} as Record<
string,
{

View File

@@ -0,0 +1,13 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const property = style({
padding: '3px 4px',
display: 'flex',
alignItems: 'center',
});
export const checkbox = style({
fontSize: 24,
color: cssVarV2.icon.primary,
});

View File

@@ -0,0 +1,37 @@
import { Checkbox, PropertyValue } from '@affine/component';
import { DocService } from '@affine/core/modules/doc';
import { useLiveData, useService } from '@toeverything/infra';
import { type ChangeEvent, useCallback } from 'react';
import * as styles from './template.css';
export const TemplateValue = () => {
const docService = useService(DocService);
const isTemplate = useLiveData(
docService.doc.record.properties$.selector(p => p.isTemplate)
);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.checked;
docService.doc.record.setProperty('isTemplate', value);
},
[docService.doc.record]
);
const toggle = useCallback(() => {
docService.doc.record.setProperty('isTemplate', !isTemplate);
}, [docService.doc.record, isTemplate]);
return (
<PropertyValue className={styles.property} onClick={toggle}>
<Checkbox
data-testid="toggle-template-checkbox"
checked={!!isTemplate}
onChange={onChange}
className={styles.checkbox}
/>
</PropertyValue>
);
};

View File

@@ -21,6 +21,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
edgelessColorTheme: f.string().optional(),
journal: f.string().optional(),
pageWidth: f.string().optional(),
isTemplate: f.boolean().optional(),
}),
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),

View File

@@ -5,7 +5,7 @@ import type { DocCustomPropertyInfo } from '../db';
*
* 'id' and 'type' is request, 'index' is a manually maintained incremental key.
*/
export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
export const BUILT_IN_CUSTOM_PROPERTY_TYPE: DocCustomPropertyInfo[] = [
{
id: 'tags',
type: 'tags',
@@ -23,6 +23,12 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
show: 'always-hide',
index: 'a0000003',
},
{
id: 'template',
type: 'template',
index: 'a00000031',
show: 'always-hide',
},
{
id: 'createdAt',
type: 'createdAt',
@@ -51,4 +57,4 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
show: 'always-hide',
index: 'a0000008',
},
] as DocCustomPropertyInfo[];
];

View File

@@ -1,6 +1,7 @@
import type { DocMode, RootBlockModel } from '@blocksuite/affine/blocks';
import { Entity } from '@toeverything/infra';
import type { DocProperties } from '../../db';
import type { WorkspaceService } from '../../workspace';
import type { DocScope } from '../scopes/doc';
import type { DocsStore } from '../stores/docs';
@@ -38,6 +39,18 @@ export class Doc extends Entity {
return this.record.customProperty$(propertyId);
}
setProperty(propertyId: string, value: string) {
return this.record.setProperty(propertyId, value);
}
updateProperties(properties: Partial<DocProperties>) {
return this.record.updateProperties(properties);
}
getProperties() {
return this.record.getProperties();
}
setCustomProperty(propertyId: string, value: string) {
return this.record.setCustomProperty(propertyId, value);
}

View File

@@ -42,7 +42,18 @@ export class DocRecord extends Entity<{ id: string }> {
});
}
setProperty(propertyId: string, value: string) {
getProperties() {
return this.docPropertiesStore.getDocProperties(this.id);
}
updateProperties(properties: Partial<DocProperties>) {
this.docPropertiesStore.updateDocProperties(this.id, properties);
}
setProperty<Key extends keyof DocProperties>(
propertyId: Key,
value: DocProperties[Key]
) {
this.docPropertiesStore.updateDocProperties(this.id, {
[propertyId]: value,
});

View File

@@ -1,8 +1,8 @@
import { DebugLogger } from '@affine/debug';
import { Unreachable } from '@affine/env/constant';
import type { DocMode } from '@blocksuite/affine/blocks';
import { type DocMode, replaceIdMiddleware } from '@blocksuite/affine/blocks';
import type { DeltaInsert } from '@blocksuite/affine/inline';
import { Text } from '@blocksuite/affine/store';
import { Slice, Text, Transformer } from '@blocksuite/affine/store';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
import { omitBy } from 'lodash-es';
@@ -150,4 +150,83 @@ export class DocsService extends Service {
doc.changeDocTitle(newTitle);
release();
}
/**
* Duplicate a doc from template
* @param sourceDocId - the id of the source doc to be duplicated
* @param _targetDocId - the id of the target doc to be duplicated, if not provided, a new doc will be created
* @returns the id of the new doc
*/
async duplicateFromTemplate(sourceDocId: string, _targetDocId?: string) {
const targetDocId = _targetDocId ?? this.createDoc().id;
// check if source doc is removed
if (this.list.doc$(sourceDocId).value?.trash$.value) {
console.warn(
`Template doc(id: ${sourceDocId}) is removed, skip duplicate`
);
return targetDocId;
}
const { release: sourceRelease, doc: sourceDoc } = this.open(sourceDocId);
const { release: targetRelease, doc: targetDoc } = this.open(targetDocId);
await sourceDoc.waitForSyncReady();
// duplicate doc content
try {
const sourceBsDoc = this.store.getBlockSuiteDoc(sourceDocId);
const targetBsDoc = this.store.getBlockSuiteDoc(targetDocId);
if (!sourceBsDoc) throw new Error('Source doc not found');
if (!targetBsDoc) throw new Error('Target doc not found');
// clear the target doc (both surface and note)
targetBsDoc.root?.children.forEach(child =>
targetBsDoc.deleteBlock(child)
);
const collection = this.store.getBlocksuiteCollection();
const transformer = new Transformer({
schema: collection.schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc({ id }),
get: (id: string) => collection.getDoc(id),
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [replaceIdMiddleware(collection.idGenerator)],
});
const slice = Slice.fromModels(sourceBsDoc, [
...(sourceBsDoc.root?.children ?? []),
]);
const snapshot = transformer.sliceToSnapshot(slice);
if (!snapshot) {
throw new Error('Failed to create snapshot');
}
await transformer.snapshotToSlice(
snapshot,
targetBsDoc,
targetBsDoc.root?.id
);
} catch (e) {
logger.error('Failed to duplicate doc', {
sourceDocId,
targetDocId,
originalTargetDocId: _targetDocId,
error: e,
});
} finally {
sourceRelease();
targetRelease();
}
// duplicate doc properties
const properties = sourceDoc.getProperties();
const removedProperties = ['id', 'isTemplate', 'journal'];
removedProperties.forEach(key => {
delete properties[key];
});
targetDoc.updateProperties(properties);
return targetDocId;
}
}

View File

@@ -24,6 +24,10 @@ export class DocsStore extends Store {
return this.workspaceService.workspace.docCollection.getDoc(id);
}
getBlocksuiteCollection() {
return this.workspaceService.workspace.docCollection;
}
createBlockSuiteDoc() {
return this.workspaceService.workspace.docCollection.createDoc();
}

View File

@@ -230,6 +230,15 @@ export const AFFINE_FLAGS = {
configurable: !isMobile,
defaultState: false,
},
// TODO(@CatsJuice): remove this flag when ready
enable_template_doc: {
category: 'affine',
displayName: 'Enable template doc',
description:
'Allow users to mark a doc as a template, and create new docs from it',
configurable: !isMobile,
defaultState: isCanaryBuild,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare

View File

@@ -43,6 +43,7 @@ import {
import { configureSystemFontFamilyModule } from './system-font-family';
import { configureTagModule } from './tag';
import { configureTelemetryModule } from './telemetry';
import { configureTemplateDocModule } from './template-doc';
import { configureAppThemeModule } from './theme';
import { configureThemeEditorModule } from './theme-editor';
import { configureUrlModule } from './url';
@@ -94,4 +95,5 @@ export function configureCommonModules(framework: Framework) {
configureCommonGlobalStorageImpls(framework);
configureAINetworkSearchModule(framework);
configureAIButtonModule(framework);
configureTemplateDocModule(framework);
}

View File

@@ -0,0 +1,24 @@
import { Entity, LiveData } from '@toeverything/infra';
import type { DocRecord, DocsService } from '../../doc';
import type { TemplateDocListStore } from '../store/list';
export class TemplateDocList extends Entity {
constructor(
public listStore: TemplateDocListStore,
public docsService: DocsService
) {
super();
}
public isTemplate$(docId: string) {
return LiveData.from(this.listStore.watchTemplateDoc(docId), false);
}
public getTemplateDocs() {
return this.listStore
.getTemplateDocIds()
.map(id => this.docsService.list.doc$(id).value)
.filter((doc): doc is DocRecord => !!doc && !doc.trash$.value);
}
}

View File

@@ -0,0 +1,12 @@
import { Entity } from '@toeverything/infra';
export type TemplateDocSettings = {
templateId?: string;
journalTemplateId?: string;
};
export class TemplateDocSetting extends Entity {
constructor() {
super();
}
}

View File

@@ -0,0 +1,21 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db';
import { DocsService } from '../doc';
import { WorkspaceScope } from '../workspace';
import { TemplateDocList } from './entities/list';
import { TemplateDocSetting } from './entities/setting';
import { TemplateDocService } from './services/template-doc';
import { TemplateDocListStore } from './store/list';
export { TemplateDocService };
export * from './view/template-list-menu';
export const configureTemplateDocModule = (framework: Framework) => {
framework
.scope(WorkspaceScope)
.service(TemplateDocService)
.store(TemplateDocListStore, [WorkspaceDBService])
.entity(TemplateDocList, [TemplateDocListStore, DocsService])
.entity(TemplateDocSetting);
};

View File

@@ -0,0 +1,9 @@
import { Service } from '@toeverything/infra';
import { TemplateDocList } from '../entities/list';
import { TemplateDocSetting } from '../entities/setting';
export class TemplateDocService extends Service {
public readonly list = this.framework.createEntity(TemplateDocList);
public readonly setting = this.framework.createEntity(TemplateDocSetting);
}

View File

@@ -0,0 +1,33 @@
import { Store } from '@toeverything/infra';
import { map } from 'rxjs';
import type { WorkspaceDBService } from '../../db';
export class TemplateDocListStore extends Store {
constructor(private readonly dbService: WorkspaceDBService) {
super();
}
isTemplateDoc(docId: string) {
return !!this.dbService.db.docProperties.find({
id: docId,
isTemplate: true,
})[0]?.isTemplate;
}
watchTemplateDoc(docId: string) {
return this.dbService.db.docProperties
.find$({ id: docId, isTemplate: true })
.pipe(map(res => res[0]?.isTemplate));
}
getTemplateDocIds() {
return this.dbService.db.docProperties
.find({ isTemplate: true })
.map(property => property.id);
}
watchTemplateDocs() {
return this.dbService.db.docProperties.find$({ isTemplate: true });
}
}

View File

@@ -0,0 +1,42 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 250,
maxWidth: 355,
});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 4,
});
export const itemIcon = style({
fontSize: 20,
lineHeight: 0,
color: cssVarV2.icon.primary,
});
export const itemText = style({
width: 0,
flex: 1,
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.primary,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const menuContent = style({
paddingRight: 0,
});
export const scrollableViewport = style({
paddingRight: 8,
maxHeight: 360,
});

View File

@@ -0,0 +1,82 @@
import { Menu, MenuItem, type MenuProps, Scrollable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { type PropsWithChildren, useState } from 'react';
import { type DocRecord, DocsService } from '../../doc';
import { DocDisplayMetaService } from '../../doc-display-meta';
import { TemplateDocService } from '../services/template-doc';
import * as styles from './styles.css';
interface CommonProps {
target?: string;
}
interface DocItemProps extends CommonProps {
doc: DocRecord;
}
const DocItem = ({ doc, target }: DocItemProps) => {
const docDisplayService = useService(DocDisplayMetaService);
const Icon = useLiveData(docDisplayService.icon$(doc.id));
const title = useLiveData(docDisplayService.title$(doc.id));
const docsService = useService(DocsService);
const onClick = useAsyncCallback(async () => {
await docsService.duplicateFromTemplate(doc.id, target);
}, [doc.id, docsService, target]);
return (
<MenuItem onClick={onClick} style={{ padding: 0 }}>
<li className={styles.item}>
<Icon className={styles.itemIcon} />
<span className={styles.itemText}>{title}</span>
</li>
</MenuItem>
);
};
export const TemplateListMenuContent = ({ target }: CommonProps) => {
const templateDocService = useService(TemplateDocService);
const [templateDocs] = useState(() =>
templateDocService.list.getTemplateDocs()
);
return (
<ul className={styles.list}>
{templateDocs.map(doc => (
<DocItem key={doc.id} doc={doc} target={target} />
))}
</ul>
);
};
export const TemplateListMenuContentScrollable = ({ target }: CommonProps) => {
return (
<Scrollable.Root>
<Scrollable.Scrollbar />
<Scrollable.Viewport className={styles.scrollableViewport}>
<TemplateListMenuContent target={target} />
</Scrollable.Viewport>
</Scrollable.Root>
);
};
export const TemplateListMenu = ({
children,
target,
contentOptions,
...otherProps
}: PropsWithChildren<CommonProps> & Omit<MenuProps, 'items'>) => {
return (
<Menu
items={<TemplateListMenuContentScrollable target={target} />}
contentOptions={{
...contentOptions,
className: styles.menuContent,
}}
{...otherProps}
>
{children}
</Menu>
);
};

View File

@@ -717,6 +717,7 @@
"com.affine.page-properties.property.updatedAt": "Updated",
"com.affine.page-properties.property.edgelessTheme": "Edgeless theme",
"com.affine.page-properties.property.pageWidth": "Page width",
"com.affine.page-properties.property.template": "Template",
"com.affine.page-properties.property.tags.tooltips": "Add relevant identifiers or categories to the doc. Useful for organizing content, improving searchability, and grouping related docs together.",
"com.affine.page-properties.property.journal.tooltips": "Indicates that this doc is a journal entry or daily note. Facilitates easy capture of ideas, quick logging of thoughts, and ongoing personal reflection.",
"com.affine.page-properties.property.checkbox.tooltips": "Use a checkbox to indicate whether a condition is true or false. Useful for confirming options, toggling features, or tracking task states.",
@@ -735,6 +736,7 @@
"com.affine.page-properties.property.docPrimaryMode.tooltips": "Select the doc mode from Page Mode, Edgeless Mode, or Auto. Useful for choosing the best display for your content.",
"com.affine.page-properties.property.edgelessTheme.tooltips": "Select the doc theme from Light, Dark, or System. Useful for precise control over content viewing style.",
"com.affine.page-properties.property.pageWidth.tooltips": "Control the width of this page to fit content display needs.",
"com.affine.page-properties.property.template.tooltips": "Mark this doc as a template, which can be used to create new docs.",
"com.affine.propertySidebar.property-list.section": "Properties",
"com.affine.propertySidebar.add-more.section": "Add more properties",
"com.affine.page-properties.settings.title": "customize properties",
@@ -1622,5 +1624,9 @@
"com.affine.payment.sync-paused.member.storage.description": "This workspace has exceeded its storage limit and synchronization has been paused. Please contact your workspace owner to either reduce storage usage or upgrade the plan to resume syncing.",
"com.affine.payment.sync-paused.member.member.description": "This workspace has reached its maximum member capacity and synchronization has been paused. Please contact your workspace owner to either adjust team membership or upgrade the plan to resume syncing.",
"com.affine.payment.sync-paused.member.member.confirm": "Got It",
"com.affine.server.delete": "Delete Server"
"com.affine.server.delete": "Delete Server",
"com.affine.page-starter-bar.start": "Start",
"com.affine.page-starter-bar.template": "Template",
"com.affine.page-starter-bar.ai": "With AI",
"com.affine.page-starter-bar.edgeless": "Edgeless"
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable unicorn/prefer-dom-node-dataset */
import { test } from '@affine-test/kit/playwright';
import {
openHomePage,
@@ -126,6 +125,7 @@ test('property table reordering', async ({ page }) => {
'Tags',
'Doc mode',
'Journal',
'Template',
'Created',
'Updated',
'Created by',
@@ -171,6 +171,7 @@ test('page info show more will show all properties', async ({ page }) => {
'Tags',
'Doc mode',
'Journal',
'Template',
'Created',
'Updated',
'Created by',

View File

@@ -0,0 +1,94 @@
import { test } from '@affine-test/kit/playwright';
import { openHomePage } from '@affine-test/kit/utils/load-page';
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
import { expect, type Locator, type Page } from '@playwright/test';
function getTemplateRow(page: Page) {
return page.locator(
'[data-testid="doc-property-row"][data-info-id="template"]'
);
}
async function toggleTemplate(row: Locator, value: boolean) {
const checkbox = row.locator('input[type="checkbox"]');
const state = await checkbox.inputValue();
const checked = state === 'on';
if (checked !== value) {
await checkbox.click();
const newState = await checkbox.inputValue();
const newChecked = newState === 'on';
expect(newChecked).toBe(value);
}
}
const createDocAndMarkAsTemplate = async (
page: Page,
title?: string,
onCreated?: () => Promise<void>
) => {
await page.getByTestId('sidebar-new-page-button').click();
await waitForEditorLoad(page);
if (title) {
await page.keyboard.type(title);
}
const collapse = page.getByTestId('page-info-collapse');
const open = await collapse.getAttribute('aria-expanded');
if (open?.toLowerCase() !== 'true') {
await collapse.click();
}
// add if not exists
if ((await getTemplateRow(page).count()) === 0) {
const addPropertyButton = page.getByTestId('add-property-button');
if (!(await addPropertyButton.isVisible())) {
await page.getByTestId('property-collapsible-button').click();
}
await addPropertyButton.click();
await page
.locator('[role="menuitem"][data-property-type="journal"]')
.click();
await page.keyboard.press('Escape');
}
// expand if collapsed
else if (!(await getTemplateRow(page).isVisible())) {
await page.getByTestId('property-collapsible-button').click();
}
const templateRow = getTemplateRow(page);
await expect(templateRow).toBeVisible();
await toggleTemplate(templateRow, true);
// focus editor
await page.locator('affine-note').first().click();
await onCreated?.();
};
test('create a doc and mark it as template', async ({ page }) => {
await openHomePage(page);
await createDocAndMarkAsTemplate(page, 'Test Template', async () => {
await page.keyboard.type('# Template');
await page.keyboard.press('Enter');
await page.keyboard.type('This is a template doc');
});
});
test('create a doc, and initialize it from template', async ({ page }) => {
await openHomePage(page);
await createDocAndMarkAsTemplate(page, 'Test Template', async () => {
await page.keyboard.type('# Template');
await page.keyboard.press('Enter');
await page.keyboard.type('This is a template doc');
});
await page.getByTestId('sidebar-new-page-button').click();
await waitForEditorLoad(page);
await page.getByTestId('template-docs-badge').click();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.getByText('This is a template doc')).toBeVisible();
// the starter bar should be hidden
await expect(page.getByTestId('template-docs-badge')).not.toBeVisible();
});

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
export async function clickCollaborationPanel(page: Page) {
await page.click('[data-tab-key="collaboration"]');