refactor(editor): add runtime type checks to database cell values (#10770)

This commit is contained in:
zzj3720
2025-03-12 09:22:41 +00:00
parent fd3ce431fe
commit 01151ec18f
54 changed files with 775 additions and 629 deletions

View File

@@ -4,16 +4,17 @@ import type { Block, BlockModel } from '@blocksuite/store';
type PropertyMeta<
T extends BlockModel = BlockModel,
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
ColumnData extends NonNullable<unknown> = NonNullable<unknown>,
> = {
name: string;
key: string;
metaConfig: PropertyMetaConfig<string, ColumnData, Value>;
metaConfig: PropertyMetaConfig<string, ColumnData, RawValue, JsonValue>;
getColumnData?: (block: T) => ColumnData;
setColumnData?: (block: T, data: ColumnData) => void;
get: (block: T) => Value;
set?: (block: T, value: Value) => void;
get: (block: T) => RawValue;
set?: (block: T, value: RawValue) => void;
updated: (block: T, callback: () => void) => DisposableMember;
};
export type BlockMeta<T extends BlockModel = BlockModel> = {

View File

@@ -1,6 +1,4 @@
import { richTextColumnConfig } from '@blocksuite/affine-block-database';
import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model';
import { propertyPresets } from '@blocksuite/data-view/property-presets';
import { createBlockMeta } from './base.js';
@@ -13,48 +11,3 @@ export const todoMeta = createBlockMeta<ListBlockModel>({
return (block.model as ListBlockModel).type === 'todo';
},
});
todoMeta.addProperty({
name: 'Content',
key: 'todo-title',
metaConfig: richTextColumnConfig,
get: block => block.text.yText,
set: (_block, _value) => {
//
},
updated: (block, callback) => {
block.text?.yText.observe(callback);
return {
dispose: () => {
block.text?.yText.unobserve(callback);
},
};
},
});
todoMeta.addProperty({
name: 'Checked',
key: 'todo-checked',
metaConfig: propertyPresets.checkboxPropertyConfig,
get: block => block.checked,
set: (block, value) => {
block.checked = value ?? false;
},
updated: (block, callback) => {
return block.propsUpdated.subscribe(({ key }) => {
if (key === 'checked') {
callback();
}
});
},
});
todoMeta.addProperty({
name: 'Source',
key: 'todo-source',
metaConfig: propertyPresets.textPropertyConfig,
get: block => block.doc.meta?.title ?? '',
updated: (block, callback) => {
return block.doc.workspace.slots.docListUpdated.subscribe(() => {
callback();
});
},
});

View File

@@ -10,9 +10,12 @@ export const queryBlockColumns = [
propertyPresets.multiSelectPropertyConfig,
propertyPresets.checkboxPropertyConfig,
];
export const queryBlockHiddenColumns: PropertyMetaConfig<string, any, any>[] = [
richTextColumnConfig,
];
export const queryBlockHiddenColumns: PropertyMetaConfig<
string,
any,
any,
any
>[] = [richTextColumnConfig];
const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns];
export const queryBlockAllColumnMap = Object.fromEntries(
queryBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig])

View File

@@ -199,7 +199,7 @@ export class BlockQueryDataSource extends DataSourceBase {
const property = this.getProperty(propertyId);
return (
property.getColumnData?.(this.blocks[0].model) ??
property.metaConfig.config.defaultData()
property.metaConfig.config.propertyData.default()
);
}
@@ -284,7 +284,8 @@ export class BlockQueryDataSource extends DataSourceBase {
currentCells as any
) ?? {
property: databaseBlockAllPropertyMap[toType].config.defaultData(),
property:
databaseBlockAllPropertyMap[toType].config.propertyData.default(),
cells: currentCells.map(() => undefined),
};
this.block.doc.captureSync();

View File

@@ -94,7 +94,7 @@ export const processTable = (
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.cellToString({
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});

View File

@@ -107,7 +107,7 @@ export class DatabaseBlockDataSource extends DataSourceBase {
return this._model.doc;
}
allPropertyMetas$ = computed<PropertyMetaConfig<any, any, any>[]>(() => {
allPropertyMetas$ = computed<PropertyMetaConfig<any, any, any, any>[]>(() => {
return databaseBlockPropertyList;
});
@@ -151,23 +151,27 @@ export class DatabaseBlockDataSource extends DataSourceBase {
this._runCapture();
const type = this.propertyTypeGet(propertyId);
const update = this.propertyMetaGet(type).config.valueUpdate;
let newValue = value;
if (update) {
const old = this.cellValueGet(rowId, propertyId);
newValue = update({
value: old,
data: this.propertyDataGet(propertyId),
dataSource: this,
newValue: value,
const update = this.propertyMetaGet(type)?.config.rawValue.setValue;
const old = this.cellValueGet(rowId, propertyId);
const updateFn =
update ??
(({ setValue, newValue }) => {
setValue(newValue);
});
}
if (this._model.columns$.value.some(v => v.id === propertyId)) {
updateCell(this._model, rowId, {
columnId: propertyId,
value: newValue,
});
}
updateFn({
value: old,
data: this.propertyDataGet(propertyId),
dataSource: this,
newValue: value,
setValue: newValue => {
if (this._model.columns$.value.some(v => v.id === propertyId)) {
updateCell(this._model, rowId, {
columnId: propertyId,
value: newValue,
});
}
},
});
}
cellValueGet(rowId: string, propertyId: string): unknown {
@@ -183,14 +187,32 @@ export class DatabaseBlockDataSource extends DataSourceBase {
const model = this.getModelById(rowId);
return model?.text;
}
return getCell(this._model, rowId, propertyId)?.value;
const meta = this.propertyMetaGet(type);
if (!meta) {
return;
}
const rawValue =
getCell(this._model, rowId, propertyId)?.value ??
meta.config.rawValue.default();
const schema = meta.config.rawValue.schema;
const result = schema.safeParse(rawValue);
if (result.success) {
return result.data;
}
return;
}
propertyAdd(insertToPosition: InsertToPosition, type?: string): string {
propertyAdd(
insertToPosition: InsertToPosition,
type?: string
): string | undefined {
this.doc.captureSync();
const property = this.propertyMetaGet(
type ?? propertyPresets.multiSelectPropertyConfig.type
);
if (!property) {
return;
}
const result = addProperty(
this._model,
insertToPosition,
@@ -233,6 +255,9 @@ export class DatabaseBlockDataSource extends DataSourceBase {
}
if (this.isFixedProperty(propertyId)) {
const meta = this.propertyMetaGet(propertyId);
if (!meta) {
return;
}
const defaultData = meta.config.fixed?.defaultData ?? {};
return {
column: {
@@ -288,7 +313,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
}
const { column } = result;
const meta = this.propertyMetaGet(column.type);
return meta.config.type({
if (!meta) {
return;
}
return meta.config?.jsonValue.type({
data: column.data,
dataSource: this,
});
@@ -335,15 +363,8 @@ export class DatabaseBlockDataSource extends DataSourceBase {
return id;
}
propertyMetaGet(type: string): PropertyMetaConfig {
const property = databaseBlockAllPropertyMap[type];
if (!property) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
`Unknown property type: ${type}`
);
}
return property;
propertyMetaGet(type: string): PropertyMetaConfig | undefined {
return databaseBlockAllPropertyMap[type];
}
propertyNameGet(propertyId: string): string {
@@ -382,6 +403,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
if (this.isFixedProperty(propertyId)) {
return;
}
const meta = this.propertyMetaGet(toType);
if (!meta) {
return;
}
const currentType = this.propertyTypeGet(propertyId);
const currentData = this.propertyDataGet(propertyId);
const rows = this.rows$.value;
@@ -396,7 +421,7 @@ export class DatabaseBlockDataSource extends DataSourceBase {
currentCells as any
) ?? {
property: this.propertyMetaGet(toType).config.defaultData(),
property: meta.config.propertyData.default(),
cells: currentCells.map(() => undefined),
};
this.doc.captureSync();

View File

@@ -29,7 +29,7 @@ import {
} from './cell-renderer.css.js';
import { linkPropertyModelConfig } from './define.js';
export class LinkCell extends BaseCellRenderer<string> {
export class LinkCell extends BaseCellRenderer<string, string> {
protected override firstUpdated(_changedProperties: PropertyValues) {
super.firstUpdated(_changedProperties);
this.classList.add(linkCellStyle);

View File

@@ -3,17 +3,23 @@ import zod from 'zod';
export const linkColumnType = propertyType('link');
export const linkPropertyModelConfig = linkColumnType.modelConfig({
name: 'Link',
valueSchema: zod.string().optional(),
type: () => t.string.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value?.toString() ?? '',
cellFromString: ({ value }) => {
return {
value: value,
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.string(),
type: () => t.string.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod.string(),
default: () => '',
toString: ({ value }) => value,
fromString: ({ value }) => {
return { value: value };
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
isEmpty: ({ value }) => value == null || value.length == 0,
});

View File

@@ -81,7 +81,7 @@ function toggleStyle(
inlineEditor.syncInlineRange();
}
export class RichTextCell extends BaseCellRenderer<Text> {
export class RichTextCell extends BaseCellRenderer<Text, string> {
inlineEditor$ = computed(() => {
return this.richText$.value?.inlineEditor;
});

View File

@@ -19,51 +19,59 @@ export const toYText = (text?: RichTextCellType): undefined | Text['yText'] => {
export const richTextPropertyModelConfig = richTextColumnType.modelConfig({
name: 'Text',
valueSchema: zod
.custom<RichTextCellType>(
data => data instanceof Text || data instanceof Y.Text
)
.optional(),
type: () => t.richText.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value?.toString() ?? '',
cellFromString: ({ value }) => {
return {
value: new Text(value),
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
cellToJson: ({ value, dataSource }) => {
if (!value) return null;
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
jsonValue: {
schema: zod.string(),
type: () => t.richText.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod
.custom<RichTextCellType>(
data => data instanceof Text || data instanceof Y.Text
)
.optional(),
default: () => undefined,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
return {
value: new Text(value),
};
},
toJson: ({ value, dataSource }) => {
if (!value) return null;
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
const yText = toYText(value);
const deltas = yText?.toDelta();
const text = deltas
.map((delta: DeltaInsert<AffineTextAttributes>) => {
if (isLinkedDoc(delta)) {
const linkedDocId = delta.attributes?.reference?.pageId as string;
return collection.getDoc(linkedDocId)?.meta?.title;
}
return delta.insert;
})
.join('');
return text;
}
return value?.toString() ?? null;
},
fromJson: ({ value }) =>
typeof value !== 'string' ? undefined : new Text(value),
onUpdate: ({ value, callback }) => {
const yText = toYText(value);
const deltas = yText?.toDelta();
const text = deltas
.map((delta: DeltaInsert<AffineTextAttributes>) => {
if (isLinkedDoc(delta)) {
const linkedDocId = delta.attributes?.reference?.pageId as string;
return collection.getDoc(linkedDocId)?.meta?.title;
}
return delta.insert;
})
.join('');
return text;
}
return value?.toString() ?? null;
yText?.observe(callback);
callback();
return {
dispose: () => {
yText?.unobserve(callback);
},
};
},
},
cellFromJson: ({ value }) =>
typeof value !== 'string' ? undefined : new Text(value),
onUpdate: ({ value, callback }) => {
const yText = toYText(value);
yText?.observe(callback);
callback();
return {
dispose: () => {
yText?.unobserve(callback);
},
};
},
isEmpty: ({ value }) => value == null || value.length === 0,
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
});

View File

@@ -1,5 +1,6 @@
import { propertyType, t } from '@blocksuite/data-view';
import { Text } from '@blocksuite/store';
import { Doc } from 'yjs';
import zod from 'zod';
import { HostContextKey } from '../../context/host-context.js';
@@ -9,61 +10,74 @@ export const titleColumnType = propertyType('title');
export const titlePropertyModelConfig = titleColumnType.modelConfig({
name: 'Title',
valueSchema: zod.custom<Text>(data => data instanceof Text).optional(),
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.string(),
type: () => t.richText.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod.custom<Text>(data => data instanceof Text).optional(),
default: () => undefined,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
return { value: new Text(value) };
},
toJson: ({ value, dataSource }) => {
if (!value) return '';
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
const deltas = value.deltas$.value;
const text = deltas
.map(delta => {
if (isLinkedDoc(delta)) {
const linkedDocId = delta.attributes?.reference?.pageId as string;
return collection.getDoc(linkedDocId)?.meta?.title;
}
return delta.insert;
})
.join('');
return text;
}
return value?.toString() ?? '';
},
fromJson: ({ value }) => new Text(value),
onUpdate: ({ value, callback }) => {
value?.yText.observe(callback);
callback();
return {
dispose: () => {
value?.yText.unobserve(callback);
},
};
},
setValue: ({ value, newValue }) => {
if (value == null) {
return;
}
const v = newValue as unknown;
if (v == null) {
value.replace(0, value.length, '');
return;
}
if (typeof v === 'string') {
value.replace(0, value.length, v);
return;
}
if (newValue instanceof Text) {
new Doc().getMap('root').set('text', newValue.yText);
value.clear();
value.applyDelta(newValue.toDelta());
return;
}
},
},
fixed: {
defaultData: {},
defaultShow: true,
},
type: () => t.richText.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value?.toString() ?? '',
cellFromString: ({ value }) => {
return {
value: value,
};
},
cellToJson: ({ value, dataSource }) => {
if (!value) return null;
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
const deltas = value.deltas$.value;
const text = deltas
.map(delta => {
if (isLinkedDoc(delta)) {
const linkedDocId = delta.attributes?.reference?.pageId as string;
return collection.getDoc(linkedDocId)?.meta?.title;
}
return delta.insert;
})
.join('');
return text;
}
return value?.toString() ?? null;
},
cellFromJson: ({ value }) =>
typeof value !== 'string' ? undefined : new Text(value),
onUpdate: ({ value, callback }) => {
value?.yText.observe(callback);
callback();
return {
dispose: () => {
value?.yText.unobserve(callback);
},
};
},
valueUpdate: ({ value, newValue }) => {
const v = newValue as unknown;
if (typeof v === 'string') {
value?.replace(0, value.length, v);
return value;
}
if (v == null) {
value?.replace(0, value.length, '');
return value;
}
return newValue;
},
isEmpty: ({ value }) => value == null || value.length === 0,
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
});

View File

@@ -29,7 +29,7 @@ import {
titleRichTextStyle,
} from './cell-renderer.css.js';
export class HeaderAreaTextCell extends BaseCellRenderer<Text> {
export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
activity = true;
docId$ = signal<string>();