feat(core): new template doc property (#9538)
close AF-2045, AF-2047, AF-2065
This commit is contained in:
@@ -68,6 +68,7 @@ import {
|
|||||||
} from './specs/custom/spec-patchers';
|
} from './specs/custom/spec-patchers';
|
||||||
import { createEdgelessModeSpecs } from './specs/edgeless';
|
import { createEdgelessModeSpecs } from './specs/edgeless';
|
||||||
import { createPageModeSpecs } from './specs/page';
|
import { createPageModeSpecs } from './specs/page';
|
||||||
|
import { StarterBar } from './starter-bar';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
const adapted = {
|
const adapted = {
|
||||||
@@ -334,6 +335,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
data-testid="page-editor-blank"
|
data-testid="page-editor-blank"
|
||||||
onClick={onClickBlank}
|
onClick={onClickBlank}
|
||||||
></div>
|
></div>
|
||||||
|
<StarterBar doc={page} />
|
||||||
{!shared && displayBiDirectionalLink ? (
|
{!shared && displayBiDirectionalLink ? (
|
||||||
<BiDirectionalLinkPanel />
|
<BiDirectionalLinkPanel />
|
||||||
) : null}
|
) : 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,
|
DatabaseRow,
|
||||||
DatabaseValueCell,
|
DatabaseValueCell,
|
||||||
} from '@affine/core/modules/doc-info/types';
|
} from '@affine/core/modules/doc-info/types';
|
||||||
|
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
|
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -126,9 +127,13 @@ export const DocPropertyRow = ({
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const docService = useService(DocService);
|
const docService = useService(DocService);
|
||||||
const docsService = useService(DocsService);
|
const docsService = useService(DocsService);
|
||||||
|
const featureFlagService = useService(FeatureFlagService);
|
||||||
const customPropertyValue = useLiveData(
|
const customPropertyValue = useLiveData(
|
||||||
docService.doc.customProperty$(propertyInfo.id)
|
docService.doc.customProperty$(propertyInfo.id)
|
||||||
);
|
);
|
||||||
|
const enableTemplateDoc = useLiveData(
|
||||||
|
featureFlagService.flags.enable_template_doc.$
|
||||||
|
);
|
||||||
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
|
const typeInfo = isSupportedDocPropertyType(propertyInfo.type)
|
||||||
? DocPropertyTypes[propertyInfo.type]
|
? DocPropertyTypes[propertyInfo.type]
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -203,6 +208,9 @@ export const DocPropertyRow = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
|
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
|
||||||
|
if (propertyInfo.id === 'template' && !enableTemplateDoc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PropertyRoot
|
<PropertyRoot
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LongerIcon,
|
LongerIcon,
|
||||||
NumberIcon,
|
NumberIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
TemplateOutlineIcon,
|
||||||
TextIcon,
|
TextIcon,
|
||||||
TodayIcon,
|
TodayIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
@@ -22,6 +23,7 @@ import { JournalValue } from './journal';
|
|||||||
import { NumberValue } from './number';
|
import { NumberValue } from './number';
|
||||||
import { PageWidthValue } from './page-width';
|
import { PageWidthValue } from './page-width';
|
||||||
import { TagsValue } from './tags';
|
import { TagsValue } from './tags';
|
||||||
|
import { TemplateValue } from './template';
|
||||||
import { TextValue } from './text';
|
import { TextValue } from './text';
|
||||||
import type { PropertyValueProps } from './types';
|
import type { PropertyValueProps } from './types';
|
||||||
|
|
||||||
@@ -108,6 +110,14 @@ export const DocPropertyTypes = {
|
|||||||
name: 'com.affine.page-properties.property.pageWidth',
|
name: 'com.affine.page-properties.property.pageWidth',
|
||||||
description: 'com.affine.page-properties.property.pageWidth.tooltips',
|
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<
|
} as Record<
|
||||||
string,
|
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(),
|
edgelessColorTheme: f.string().optional(),
|
||||||
journal: f.string().optional(),
|
journal: f.string().optional(),
|
||||||
pageWidth: f.string().optional(),
|
pageWidth: f.string().optional(),
|
||||||
|
isTemplate: f.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
docCustomPropertyInfo: {
|
docCustomPropertyInfo: {
|
||||||
id: f.string().primaryKey().optional().default(nanoid),
|
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.
|
* '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',
|
id: 'tags',
|
||||||
type: 'tags',
|
type: 'tags',
|
||||||
@@ -23,6 +23,12 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
|
|||||||
show: 'always-hide',
|
show: 'always-hide',
|
||||||
index: 'a0000003',
|
index: 'a0000003',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'template',
|
||||||
|
type: 'template',
|
||||||
|
index: 'a00000031',
|
||||||
|
show: 'always-hide',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'createdAt',
|
id: 'createdAt',
|
||||||
type: 'createdAt',
|
type: 'createdAt',
|
||||||
@@ -51,4 +57,4 @@ export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
|
|||||||
show: 'always-hide',
|
show: 'always-hide',
|
||||||
index: 'a0000008',
|
index: 'a0000008',
|
||||||
},
|
},
|
||||||
] as DocCustomPropertyInfo[];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DocMode, RootBlockModel } from '@blocksuite/affine/blocks';
|
import type { DocMode, RootBlockModel } from '@blocksuite/affine/blocks';
|
||||||
import { Entity } from '@toeverything/infra';
|
import { Entity } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import type { DocProperties } from '../../db';
|
||||||
import type { WorkspaceService } from '../../workspace';
|
import type { WorkspaceService } from '../../workspace';
|
||||||
import type { DocScope } from '../scopes/doc';
|
import type { DocScope } from '../scopes/doc';
|
||||||
import type { DocsStore } from '../stores/docs';
|
import type { DocsStore } from '../stores/docs';
|
||||||
@@ -38,6 +39,18 @@ export class Doc extends Entity {
|
|||||||
return this.record.customProperty$(propertyId);
|
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) {
|
setCustomProperty(propertyId: string, value: string) {
|
||||||
return this.record.setCustomProperty(propertyId, value);
|
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, {
|
this.docPropertiesStore.updateDocProperties(this.id, {
|
||||||
[propertyId]: value,
|
[propertyId]: value,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import { Unreachable } from '@affine/env/constant';
|
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 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 type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
|
import { LiveData, ObjectPool, Service } from '@toeverything/infra';
|
||||||
import { omitBy } from 'lodash-es';
|
import { omitBy } from 'lodash-es';
|
||||||
@@ -150,4 +150,83 @@ export class DocsService extends Service {
|
|||||||
doc.changeDocTitle(newTitle);
|
doc.changeDocTitle(newTitle);
|
||||||
release();
|
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);
|
return this.workspaceService.workspace.docCollection.getDoc(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBlocksuiteCollection() {
|
||||||
|
return this.workspaceService.workspace.docCollection;
|
||||||
|
}
|
||||||
|
|
||||||
createBlockSuiteDoc() {
|
createBlockSuiteDoc() {
|
||||||
return this.workspaceService.workspace.docCollection.createDoc();
|
return this.workspaceService.workspace.docCollection.createDoc();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,15 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: !isMobile,
|
configurable: !isMobile,
|
||||||
defaultState: false,
|
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 };
|
} satisfies { [key in string]: FlagInfo };
|
||||||
|
|
||||||
// oxlint-disable-next-line no-redeclare
|
// oxlint-disable-next-line no-redeclare
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import { configureSystemFontFamilyModule } from './system-font-family';
|
import { configureSystemFontFamilyModule } from './system-font-family';
|
||||||
import { configureTagModule } from './tag';
|
import { configureTagModule } from './tag';
|
||||||
import { configureTelemetryModule } from './telemetry';
|
import { configureTelemetryModule } from './telemetry';
|
||||||
|
import { configureTemplateDocModule } from './template-doc';
|
||||||
import { configureAppThemeModule } from './theme';
|
import { configureAppThemeModule } from './theme';
|
||||||
import { configureThemeEditorModule } from './theme-editor';
|
import { configureThemeEditorModule } from './theme-editor';
|
||||||
import { configureUrlModule } from './url';
|
import { configureUrlModule } from './url';
|
||||||
@@ -94,4 +95,5 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureCommonGlobalStorageImpls(framework);
|
configureCommonGlobalStorageImpls(framework);
|
||||||
configureAINetworkSearchModule(framework);
|
configureAINetworkSearchModule(framework);
|
||||||
configureAIButtonModule(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/frontend/core/src/modules/template-doc/index.ts
Normal file
21
packages/frontend/core/src/modules/template-doc/index.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -717,6 +717,7 @@
|
|||||||
"com.affine.page-properties.property.updatedAt": "Updated",
|
"com.affine.page-properties.property.updatedAt": "Updated",
|
||||||
"com.affine.page-properties.property.edgelessTheme": "Edgeless theme",
|
"com.affine.page-properties.property.edgelessTheme": "Edgeless theme",
|
||||||
"com.affine.page-properties.property.pageWidth": "Page width",
|
"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.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.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.",
|
"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.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.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.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.property-list.section": "Properties",
|
||||||
"com.affine.propertySidebar.add-more.section": "Add more properties",
|
"com.affine.propertySidebar.add-more.section": "Add more properties",
|
||||||
"com.affine.page-properties.settings.title": "customize 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.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.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.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 { test } from '@affine-test/kit/playwright';
|
||||||
import {
|
import {
|
||||||
openHomePage,
|
openHomePage,
|
||||||
@@ -126,6 +125,7 @@ test('property table reordering', async ({ page }) => {
|
|||||||
'Tags',
|
'Tags',
|
||||||
'Doc mode',
|
'Doc mode',
|
||||||
'Journal',
|
'Journal',
|
||||||
|
'Template',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated',
|
'Updated',
|
||||||
'Created by',
|
'Created by',
|
||||||
@@ -171,6 +171,7 @@ test('page info show more will show all properties', async ({ page }) => {
|
|||||||
'Tags',
|
'Tags',
|
||||||
'Doc mode',
|
'Doc mode',
|
||||||
'Journal',
|
'Journal',
|
||||||
|
'Template',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated',
|
'Updated',
|
||||||
'Created by',
|
'Created by',
|
||||||
|
|||||||
94
tests/affine-local/e2e/template.spec.ts
Normal file
94
tests/affine-local/e2e/template.spec.ts
Normal 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,4 +1,4 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
export async function clickCollaborationPanel(page: Page) {
|
export async function clickCollaborationPanel(page: Page) {
|
||||||
await page.click('[data-tab-key="collaboration"]');
|
await page.click('[data-tab-key="collaboration"]');
|
||||||
|
|||||||
Reference in New Issue
Block a user