feat(editor): support user provided role and role schema (#10939)

Let me analyze the key changes in this diff:

1. **Role System Changes**:
- Changed from a fixed enum of roles (`root`, `hub`, `content`) to a more flexible string-based system
- Removed strict role hierarchy validation rules (hub/content/root relationships)
- Added support for role-based matching using `@` prefix (e.g., `@root`, `@content`)

2. **Schema Validation Updates**:
- Added new `_matchFlavourOrRole` method to handle both flavour and role-based matching
- Updated `_validateParent` to consider both roles and flavours when validating parent-child relationships
- Simplified `_validateRole` by removing specific role hierarchy constraints

3. **Block Schema Changes**:
- Updated parent/children references in various block schemas to use the new `@` prefix notation
- Changed parent definitions from `['affine:page']` to `['@root']` in several blocks
- Updated children definitions to use role-based references (e.g., `['@content']`)

4. **Test Updates**:
- Added new test cases for role-based schema validation
- Introduced new test block schemas (`TestRoleBlockSchema`, `TestParagraphBlockSchema`) to verify role-based functionality

This appears to be a significant architectural change that makes the block schema system more flexible by:
1. Moving away from hardcoded role hierarchies
2. Introducing a more dynamic role-based relationship system
3. Supporting both flavour-based and role-based parent-child relationships
4. Using the `@` prefix convention to distinguish role references from flavour references

The changes make the system more extensible while maintaining backward compatibility with existing flavour-based relationships.
This commit is contained in:
Saul-Mirone
2025-03-18 01:58:59 +00:00
parent 4a3180ee04
commit 9f3cf271e3
5 changed files with 122 additions and 44 deletions

View File

@@ -21,7 +21,7 @@ export const SurfaceBlockSchema = defineBlockSchema({
metadata: { metadata: {
version: 5, version: 5,
role: 'hub', role: 'hub',
parent: ['affine:page'], parent: ['@root'],
children: [ children: [
'affine:frame', 'affine:frame',
'affine:image', 'affine:image',

View File

@@ -25,7 +25,6 @@ import {
StrokeStyleSchema, StrokeStyleSchema,
} from '../../consts/note'; } from '../../consts/note';
import { type Color, ColorSchema, DefaultTheme } from '../../themes'; import { type Color, ColorSchema, DefaultTheme } from '../../themes';
import { TableModelFlavour } from '../table';
export const NoteZodSchema = z export const NoteZodSchema = z
.object({ .object({
@@ -74,22 +73,12 @@ export const NoteBlockSchema = defineBlockSchema({
metadata: { metadata: {
version: 1, version: 1,
role: 'hub', role: 'hub',
parent: ['affine:page'], parent: ['@root'],
children: [ children: [
'affine:paragraph', '@content',
'affine:list',
'affine:code',
'affine:divider',
'affine:database', 'affine:database',
'affine:data-view', 'affine:data-view',
'affine:image',
'affine:bookmark',
'affine:attachment',
'affine:surface-ref',
'affine:embed-*',
'affine:latex',
'affine:callout', 'affine:callout',
TableModelFlavour,
], ],
}, },
toModel: () => { toModel: () => {

View File

@@ -54,6 +54,34 @@ const TestInvalidNoteBlockSchemaExtension = BlockSchemaExtension(
TestInvalidNoteBlockSchema TestInvalidNoteBlockSchema
); );
const TestRoleBlockSchema = defineBlockSchema({
flavour: 'affine:note-block-role-test',
metadata: {
version: 1,
role: 'content',
parent: ['affine:note'],
children: ['@test'],
},
props: internal => ({
text: internal.Text(),
}),
});
const TestRoleBlockSchemaExtension = BlockSchemaExtension(TestRoleBlockSchema);
const TestParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:test-paragraph',
metadata: {
version: 1,
role: 'test',
parent: ['@content'],
},
});
const TestParagraphBlockSchemaExtension = BlockSchemaExtension(
TestParagraphBlockSchema
);
const extensions = [ const extensions = [
RootBlockSchemaExtension, RootBlockSchemaExtension,
ParagraphBlockSchemaExtension, ParagraphBlockSchemaExtension,
@@ -62,6 +90,8 @@ const extensions = [
DividerBlockSchemaExtension, DividerBlockSchemaExtension,
TestCustomNoteBlockSchemaExtension, TestCustomNoteBlockSchemaExtension,
TestInvalidNoteBlockSchemaExtension, TestInvalidNoteBlockSchemaExtension,
TestRoleBlockSchemaExtension,
TestParagraphBlockSchemaExtension,
]; ];
const defaultDocId = 'doc0'; const defaultDocId = 'doc0';
@@ -128,4 +158,28 @@ describe('schema', () => {
return call[0] instanceof SchemaValidateError; return call[0] instanceof SchemaValidateError;
}); });
}); });
it('should be able to validate schema by role', () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const roleId = doc.addBlock('affine:note-block-role-test', {}, noteId);
doc.addBlock('affine:paragraph', {}, roleId);
doc.addBlock('affine:paragraph', {}, roleId);
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
doc.addBlock('affine:test-paragraph', {}, roleId);
doc.addBlock('affine:test-paragraph', {}, roleId);
expect(consoleMock).not.toBeCalled();
expect(doc.getBlocksByFlavour('affine:test-paragraph')).toHaveLength(2);
});
}); });

View File

@@ -8,10 +8,9 @@ import type { BlockModel } from './block-model.js';
const FlavourSchema = z.string(); const FlavourSchema = z.string();
const ParentSchema = z.array(z.string()).optional(); const ParentSchema = z.array(z.string()).optional();
const ContentSchema = z.array(z.string()).optional(); const ContentSchema = z.array(z.string()).optional();
const role = ['root', 'hub', 'content'] as const; const RoleSchema = z.string();
const RoleSchema = z.enum(role);
export type RoleType = (typeof role)[number]; export type RoleType = 'root' | 'content' | string;
export interface InternalPrimitives { export interface InternalPrimitives {
Text: (input?: Y.Text | string) => Text; Text: (input?: Y.Text | string) => Text;

View File

@@ -59,7 +59,7 @@ export class Schema {
if (!parentFlavour) { if (!parentFlavour) {
throw new SchemaValidateError( throw new SchemaValidateError(
schema.model.flavour, schema.model.flavour,
'Hub/Content must have parent.' 'None root block must have parent.'
); );
} }
@@ -86,33 +86,84 @@ export class Schema {
); );
} }
private _matchFlavourOrRole(
childValue: string,
parentValue: string,
childRole: string,
parentRole: string
): boolean {
// Check if either value starts with '@' indicating it's a role
const isChildRole = childValue.startsWith('@');
const isParentRole = parentValue.startsWith('@');
// If both are roles, do exact match
if (isChildRole && isParentRole) {
return childValue === parentValue;
}
// If child is role, compare with parent's actual role
if (isChildRole) {
return childValue === `@${parentRole}`;
}
// If parent is role, compare with child's actual role
if (isParentRole) {
return parentValue === `@${childRole}`;
}
// If neither is role, use flavour matching
return this._matchFlavour(childValue, parentValue);
}
private _validateParent( private _validateParent(
child: BlockSchemaType, child: BlockSchemaType,
parent: BlockSchemaType parent: BlockSchemaType
): boolean { ): boolean {
const _childFlavour = child.model.flavour; const _childFlavour = child.model.flavour;
const _parentFlavour = parent.model.flavour; const _parentFlavour = parent.model.flavour;
const _childRole = child.model.role;
const _parentRole = parent.model.role;
const childValidFlavours = child.model.parent || ['*']; const childValidFlavourOrRole = child.model.parent || ['*'];
const parentValidFlavours = parent.model.children || ['*']; const parentValidFlavourOrRole = parent.model.children || ['*'];
return parentValidFlavours.some(parentValidFlavour => { return parentValidFlavourOrRole.some(parentValidFlavourOrRole => {
return childValidFlavours.some(childValidFlavour => { return childValidFlavourOrRole.some(childValidFlavourOrRole => {
if (parentValidFlavour === '*' && childValidFlavour === '*') { if (
parentValidFlavourOrRole === '*' &&
childValidFlavourOrRole === '*'
) {
return true; return true;
} }
if (parentValidFlavour === '*') { if (parentValidFlavourOrRole === '*') {
return this._matchFlavour(childValidFlavour, _parentFlavour); return this._matchFlavourOrRole(
childValidFlavourOrRole,
_parentFlavour,
_childRole,
_parentRole
);
} }
if (childValidFlavour === '*') { if (childValidFlavourOrRole === '*') {
return this._matchFlavour(_childFlavour, parentValidFlavour); return this._matchFlavourOrRole(
_childFlavour,
parentValidFlavourOrRole,
_childRole,
_parentRole
);
} }
return ( return (
this._matchFlavour(_childFlavour, parentValidFlavour) && this._matchFlavourOrRole(
this._matchFlavour(childValidFlavour, _parentFlavour) _childFlavour,
parentValidFlavourOrRole,
_childRole,
_parentRole
) &&
this._matchFlavourOrRole(
childValidFlavourOrRole,
_parentFlavour,
_childRole,
_parentRole
)
); );
}); });
}); });
@@ -120,7 +171,6 @@ export class Schema {
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) { private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
const childRole = child.model.role; const childRole = child.model.role;
const parentRole = parent.model.role;
const childFlavour = child.model.flavour; const childFlavour = child.model.flavour;
const parentFlavour = parent.model.flavour; const parentFlavour = parent.model.flavour;
@@ -130,20 +180,6 @@ export class Schema {
`Root block cannot have parent: ${parentFlavour}.` `Root block cannot have parent: ${parentFlavour}.`
); );
} }
if (childRole === 'hub' && parentRole === 'content') {
throw new SchemaValidateError(
childFlavour,
`Hub block cannot be child of content block: ${parentFlavour}.`
);
}
if (childRole === 'content' && parentRole === 'root') {
throw new SchemaValidateError(
childFlavour,
`Content block can only be child of hub block or itself. But get: ${parentFlavour}.`
);
}
} }
isValid(child: string, parent: string) { isValid(child: string, parent: string) {