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
@@ -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}
@@ -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({});
@@ -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} />;
};
@@ -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
@@ -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,
{
@@ -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,
});
@@ -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>
);
};
@@ -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),
@@ -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[];
];
@@ -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);
}
@@ -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,
});
@@ -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;
}
}
@@ -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();
}
@@ -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
@@ -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);
}
@@ -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);
}
}
@@ -0,0 +1,12 @@
import { Entity } from '@toeverything/infra';
export type TemplateDocSettings = {
templateId?: string;
journalTemplateId?: string;
};
export class TemplateDocSetting extends Entity {
constructor() {
super();
}
}
@@ -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);
};
@@ -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);
}
@@ -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 });
}
}
@@ -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,
});
@@ -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>
);
};
+7 -1
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"
}
@@ -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',
+94
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();
});
+1 -1
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"]');