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>();

View File

@@ -53,8 +53,11 @@ export interface DataSource {
propertyReadonlyGet(propertyId: string): boolean;
propertyReadonlyGet$(propertyId: string): ReadonlySignal<boolean>;
propertyMetaGet(type: string): PropertyMetaConfig;
propertyAdd(insertToPosition: InsertToPosition, type?: string): string;
propertyMetaGet(type: string): PropertyMetaConfig | undefined;
propertyAdd(
insertToPosition: InsertToPosition,
type?: string
): string | undefined;
propertyDuplicate(propertyId: string): string | undefined;
propertyCanDuplicate(propertyId: string): boolean;
@@ -152,7 +155,7 @@ export abstract class DataSourceBase implements DataSource {
abstract propertyAdd(
insertToPosition: InsertToPosition,
type?: string
): string;
): string | undefined;
abstract propertyDataGet(propertyId: string): Record<string, unknown>;
@@ -179,7 +182,7 @@ export abstract class DataSourceBase implements DataSource {
abstract propertyDuplicate(propertyId: string): string | undefined;
abstract propertyMetaGet(type: string): PropertyMetaConfig;
abstract propertyMetaGet(type: string): PropertyMetaConfig | undefined;
abstract propertyNameGet(propertyId: string): string;

View File

@@ -10,7 +10,7 @@ export const defaultGroupBy = (
data: NonNullable<unknown>
): GroupBy | undefined => {
const name = groupByMatcher.match(
propertyMeta.config.type({ data, dataSource })
propertyMeta.config.jsonValue.type({ data, dataSource })
)?.name;
return name != null
? {

View File

@@ -6,7 +6,6 @@ import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { GroupBy, GroupProperty } from '../common/types.js';
import type { TypeInstance } from '../logical/type.js';
import type { DVJSON } from '../property/types.js';
import { createTraitKey } from '../traits/key.js';
import type { Property } from '../view-manager/property.js';
import type { SingleView } from '../view-manager/single-view.js';
@@ -19,7 +18,7 @@ export type GroupData = {
key: string;
name: string;
type: TypeInstance;
value: DVJSON;
value: unknown;
rows: string[];
};
@@ -235,7 +234,7 @@ export class GroupTrait {
}
const remove = this.config$.value?.removeFromGroup ?? (() => null);
const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined;
let newValue: DVJSON = null;
let newValue: unknown = null;
if (group) {
newValue = remove(
group.value,
@@ -284,7 +283,7 @@ export class GroupTrait {
this.view.cellValueSet(rowId, propertyId, newValue);
}
updateValue(rows: string[], value: DVJSON) {
updateValue(rows: string[], value: unknown) {
const propertyId = this.propertyId;
if (!propertyId) {
return;

View File

@@ -1,34 +1,35 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance } from '../logical/type.js';
import type { DVJSON } from '../property/types.js';
export interface GroupRenderProps<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = DVJSON,
JsonValue = unknown,
> {
data: Data;
updateData?: (data: Data) => void;
value: Value;
updateValue?: (value: Value) => void;
value: JsonValue;
updateValue?: (value: JsonValue) => void;
readonly: boolean;
}
export type GroupByConfig = {
export type GroupByConfig<
JsonValue = unknown,
Data extends NonNullable<unknown> = NonNullable<unknown>,
> = {
name: string;
groupName: (type: TypeInstance, value: unknown) => string;
defaultKeys: (type: TypeInstance) => {
key: string;
value: DVJSON;
value: JsonValue;
}[];
valuesGroup: (
value: unknown,
type: TypeInstance
) => {
key: string;
value: DVJSON;
value: JsonValue;
}[];
addToGroup?: (value: DVJSON, oldValue: DVJSON) => DVJSON;
removeFromGroup?: (value: DVJSON, oldValue: DVJSON) => DVJSON;
view: UniComponent<GroupRenderProps>;
addToGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue;
removeFromGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue;
view: UniComponent<GroupRenderProps<Data, JsonValue>>;
};

View File

@@ -11,7 +11,6 @@ export const SelectTagSchema = Zod.object({
id: Zod.string(),
color: Zod.string(),
value: Zod.string(),
parentId: Zod.string().optional(),
});
export const unknown = defineDataType('Unknown', zod.never(), zod.unknown());
export const dt = {

View File

@@ -8,18 +8,19 @@ import type { Cell } from '../view-manager/cell.js';
import type { CellRenderProps, DataViewCellLifeCycle } from './manager.js';
export abstract class BaseCellRenderer<
Value,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
>
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements DataViewCellLifeCycle, CellRenderProps<Data, Value>
implements DataViewCellLifeCycle, CellRenderProps<Data, RawValue, JsonValue>
{
get expose() {
return this;
}
@property({ attribute: false })
accessor cell!: Cell<Value, Data>;
accessor cell!: Cell<RawValue, JsonValue, Data>;
readonly$ = computed(() => {
return this.cell.property.readonly$.value;
@@ -101,11 +102,11 @@ export abstract class BaseCellRenderer<
this.requestUpdate();
}
valueSetImmediate(value: Value | undefined): void {
valueSetImmediate(value: RawValue | undefined): void {
this.cell.valueSet(value);
}
valueSetNextTick(value: Value | undefined) {
valueSetNextTick(value: RawValue | undefined) {
requestAnimationFrame(() => {
this.cell.valueSet(value);
});

View File

@@ -1,7 +1,7 @@
import type { PropertyModel } from './property-config.js';
import type {
GetCellDataFromConfig,
GetPropertyDataFromConfig,
GetRawValueFromConfig,
} from './types.js';
export type ConvertFunction<
@@ -9,14 +9,14 @@ export type ConvertFunction<
To extends PropertyModel = PropertyModel,
> = (
property: GetPropertyDataFromConfig<From['config']>,
cells: (GetCellDataFromConfig<From['config']> | undefined)[]
cells: (GetRawValueFromConfig<From['config']> | undefined)[]
) => {
property: GetPropertyDataFromConfig<To['config']>;
cells: (GetCellDataFromConfig<To['config']> | undefined)[];
cells: (GetRawValueFromConfig<To['config']> | undefined)[];
};
export const createPropertyConvert = <
From extends PropertyModel<any, any, any>,
To extends PropertyModel<any, any, any>,
From extends PropertyModel<any, any, any, any>,
To extends PropertyModel<any, any, any, any>,
>(
from: From,
to: To,

View File

@@ -5,9 +5,10 @@ import type { Cell } from '../view-manager/cell.js';
export interface CellRenderProps<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
> {
cell: Cell<Value, Data>;
cell: Cell<RawValue, JsonValue, Data>;
isEditing$: ReadonlySignal<boolean>;
selectCurrentCell: (editing: boolean) => void;
}
@@ -27,12 +28,17 @@ export interface DataViewCellLifeCycle {
export type DataViewCellComponent<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = unknown,
> = UniComponent<CellRenderProps<Data, Value>, DataViewCellLifeCycle>;
RawValue = unknown,
JsonValue = unknown,
> = UniComponent<
CellRenderProps<Data, RawValue, JsonValue>,
DataViewCellLifeCycle
>;
export type CellRenderer<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
> = {
view: DataViewCellComponent<Data, Value>;
view: DataViewCellComponent<Data, RawValue, JsonValue>;
};

View File

@@ -4,20 +4,22 @@ import type { PropertyConfig } from './types.js';
export type PropertyMetaConfig<
Type extends string = string,
PropertyData extends NonNullable<unknown> = NonNullable<unknown>,
CellData = unknown,
RawValue = unknown,
JsonValue = unknown,
> = {
type: Type;
config: PropertyConfig<PropertyData, CellData>;
config: PropertyConfig<PropertyData, RawValue, JsonValue>;
create: Create<PropertyData>;
renderer: Renderer<PropertyData, CellData>;
renderer: Renderer<PropertyData, RawValue, JsonValue>;
};
type CreatePropertyMeta<
Type extends string = string,
PropertyData extends Record<string, unknown> = Record<string, never>,
CellData = unknown,
RawValue = unknown,
JsonValue = unknown,
> = (
renderer: Omit<Renderer<PropertyData, CellData>, 'type'>
) => PropertyMetaConfig<Type, PropertyData, CellData>;
renderer: Omit<Renderer<PropertyData, RawValue, JsonValue>, 'type'>
) => PropertyMetaConfig<Type, PropertyData, RawValue, JsonValue>;
type Create<
PropertyData extends Record<string, unknown> = Record<string, never>,
> = (
@@ -32,26 +34,33 @@ type Create<
export type PropertyModel<
Type extends string = string,
PropertyData extends Record<string, unknown> = Record<string, unknown>,
CellData = unknown,
RawValue = unknown,
JsonValue = unknown,
> = {
type: Type;
config: PropertyConfig<PropertyData, CellData>;
config: PropertyConfig<PropertyData, RawValue, JsonValue>;
create: Create<PropertyData>;
createPropertyMeta: CreatePropertyMeta<Type, PropertyData, CellData>;
createPropertyMeta: CreatePropertyMeta<
Type,
PropertyData,
RawValue,
JsonValue
>;
};
export const propertyType = <Type extends string>(type: Type) => ({
type: type,
modelConfig: <
CellData,
PropertyData extends Record<string, unknown> = Record<string, never>,
RawValue = unknown,
JsonValue = unknown,
>(
ops: PropertyConfig<PropertyData, CellData>
): PropertyModel<Type, PropertyData, CellData> => {
ops: PropertyConfig<PropertyData, RawValue, JsonValue>
): PropertyModel<Type, PropertyData, RawValue, JsonValue> => {
const create: Create<PropertyData> = (name, data) => {
return {
type,
name,
data: data ?? ops.defaultData(),
data: data ?? ops.propertyData.default(),
};
};
return {

View File

@@ -6,18 +6,20 @@ import type { CellRenderer, DataViewCellComponent } from './manager.js';
export interface Renderer<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
> {
type: string;
icon?: UniComponent;
cellRenderer: CellRenderer<Data, Value>;
cellRenderer: CellRenderer<Data, RawValue, JsonValue>;
}
export const createFromBaseCellRenderer = <
Value,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
>(
renderer: new () => BaseCellRenderer<Value, Data>
renderer: new () => BaseCellRenderer<RawValue, JsonValue, Data>
): DataViewCellComponent => {
return createUniComponentFromWebComponent(renderer as never) as never;
};

View File

@@ -8,86 +8,84 @@ export type WithCommonPropertyConfig<T = {}> = T & {
dataSource: DataSource;
};
export type GetPropertyDataFromConfig<T> =
T extends PropertyConfig<infer R, any> ? R : never;
export type GetCellDataFromConfig<T> =
T extends PropertyConfig<any, infer R> ? R : never;
export type PropertyConfig<
Data extends NonNullable<unknown> = NonNullable<unknown>,
Value = unknown,
> = {
T extends PropertyConfig<infer R, any, any> ? R : never;
export type GetRawValueFromConfig<T> =
T extends PropertyConfig<any, infer R, any> ? R : never;
export type GetJsonValueFromConfig<T> =
T extends PropertyConfig<any, any, infer R> ? R : never;
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
name: string;
valueSchema: ZodType<Value>;
hide?: boolean;
propertyData: {
schema: ZodType<Data>;
default: () => Data;
};
rawValue: {
schema: ZodType<RawValue>;
default: () => RawValue;
toString: (config: { value: RawValue; data: Data }) => string;
fromString: (
config: WithCommonPropertyConfig<{
value: string;
data: Data;
}>
) => {
value: unknown;
data?: Record<string, unknown>;
};
toJson: (
config: WithCommonPropertyConfig<{
value: RawValue;
data: Data;
}>
) => JsonValue;
fromJson: (
config: WithCommonPropertyConfig<{
value: JsonValue;
data: Data;
}>
) => RawValue | undefined;
setValue?: (
config: WithCommonPropertyConfig<{
data: Data;
value: RawValue;
newValue: RawValue;
setValue: (value: RawValue) => void;
}>
) => void;
onUpdate?: (
config: WithCommonPropertyConfig<{
value: RawValue;
data: Data;
callback: () => void;
}>
) => Disposable;
};
jsonValue: {
schema: ZodType<JsonValue>;
type: (
config: WithCommonPropertyConfig<{
data: Data;
}>
) => TypeInstance;
isEmpty: (
config: WithCommonPropertyConfig<{
value: JsonValue;
}>
) => boolean;
};
fixed?: {
defaultData: Data;
defaultOrder?: string;
defaultShow?: boolean;
};
defaultData: () => Data;
type: (
config: WithCommonPropertyConfig<{
data: Data;
}>
) => TypeInstance;
formatValue?: (
config: WithCommonPropertyConfig<{
value: Value;
data: Data;
}>
) => Value;
isEmpty: (
config: WithCommonPropertyConfig<{
value?: Value;
}>
) => boolean;
minWidth?: number;
values?: (
config: WithCommonPropertyConfig<{
value?: Value;
}>
) => unknown[];
cellToString: (config: { value: Value; data: Data }) => string;
cellFromString: (
config: WithCommonPropertyConfig<{
value: string;
data: Data;
}>
) => {
value: unknown;
data?: Record<string, unknown>;
};
cellToJson: (
config: WithCommonPropertyConfig<{
value?: Value;
data: Data;
}>
) => DVJSON;
cellFromJson: (
config: WithCommonPropertyConfig<{
value: DVJSON;
data: Data;
}>
) => Value | undefined;
addGroup?: (
config: WithCommonPropertyConfig<{
text: string;
oldData: Data;
}>
) => Data;
onUpdate?: (
config: WithCommonPropertyConfig<{
value: Value;
data: Data;
callback: () => void;
}>
) => Disposable;
valueUpdate?: (
config: WithCommonPropertyConfig<{
value: Value;
data: Data;
newValue: Value;
}>
) => Value;
};
export type DVJSON =

View File

@@ -0,0 +1,30 @@
import type { DataSource } from '../data-source/base';
import type { PropertyConfig } from './types';
export const fromJson = <Data, RawValue, JsonValue>(
config: PropertyConfig<Data, RawValue, JsonValue>,
{
value,
data,
dataSource,
}: {
value: unknown;
data: Data;
dataSource: DataSource;
}
): RawValue | undefined => {
const fromJson = config.rawValue.fromJson;
const jsonSchema = config.jsonValue.schema;
if (!fromJson || !jsonSchema) {
return;
}
const jsonResult = jsonSchema.safeParse(value);
if (!jsonResult.success) {
return;
}
return fromJson({
value: jsonResult.data,
data,
dataSource,
});
};

View File

@@ -19,16 +19,14 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
displayName: 'Values',
type: 'count-values',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
const values = data
.flatMap(v => {
if (meta.config.values) {
return meta.config.values({ value: v, dataSource });
}
return v;
})
.filter(v => v != null);
return values.length.toString();
impl: data => {
const values = data.reduce((acc: number, v) => {
if (Array.isArray(v)) {
return acc + v.length;
}
return acc + (v == null ? 0 : 1);
}, 0);
return values.toString();
},
}),
createStatisticConfig({
@@ -37,13 +35,13 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
displayName: 'Unique Values',
type: 'count-unique-values',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
impl: data => {
const values = data
.flatMap(v => {
if (meta.config.values) {
return meta.config.values({ value: v, dataSource });
if (Array.isArray(v)) {
return v;
}
return v;
return [v];
})
.filter(v => v != null);
return new Set(values).size.toString();
@@ -57,7 +55,7 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
const emptyList = data.filter(value =>
meta.config.isEmpty({ value, dataSource })
meta.config.jsonValue.isEmpty({ value, dataSource })
);
return emptyList.length.toString();
},
@@ -70,7 +68,7 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
const notEmptyList = data.filter(
value => !meta.config.isEmpty({ value, dataSource })
value => !meta.config.jsonValue.isEmpty({ value, dataSource })
);
return notEmptyList.length.toString();
},
@@ -84,7 +82,7 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
impl: (data, { meta, dataSource }) => {
if (data.length === 0) return '';
const emptyList = data.filter(value =>
meta.config.isEmpty({ value, dataSource })
meta.config.jsonValue.isEmpty({ value, dataSource })
);
return ((emptyList.length / data.length) * 100).toFixed(2) + '%';
},
@@ -98,7 +96,7 @@ export const anyTypeStatsFunctions: StatisticsConfig[] = [
impl: (data, { meta, dataSource }) => {
if (data.length === 0) return '';
const notEmptyList = data.filter(
value => !meta.config.isEmpty({ value, dataSource })
value => !meta.config.jsonValue.isEmpty({ value, dataSource })
);
return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%';
},

View File

@@ -5,26 +5,29 @@ import type { Row } from './row.js';
import type { SingleView } from './single-view.js';
export interface Cell<
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
readonly rowId: string;
readonly view: SingleView;
readonly row: Row;
readonly propertyId: string;
readonly property: Property<Value, Data>;
readonly property: Property<RawValue, JsonValue, Data>;
readonly isEmpty$: ReadonlySignal<boolean>;
readonly stringValue$: ReadonlySignal<string>;
readonly jsonValue$: ReadonlySignal<unknown>;
readonly jsonValue$: ReadonlySignal<JsonValue>;
readonly value$: ReadonlySignal<Value | undefined>;
valueSet(value: Value | undefined): void;
readonly value$: ReadonlySignal<RawValue | undefined>;
valueSet(value: RawValue | undefined): void;
}
export class CellBase<
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> implements Cell<Value, Data>
> implements Cell<RawValue, JsonValue, Data>
{
meta$ = computed(() => {
return this.view.manager.dataSource.propertyMetaGet(
@@ -36,29 +39,35 @@ export class CellBase<
return this.view.manager.dataSource.cellValueGet(
this.rowId,
this.propertyId
) as Value;
) as RawValue;
});
isEmpty$: ReadonlySignal<boolean> = computed(() => {
return this.meta$.value.config.isEmpty({
value: this.value$.value,
dataSource: this.view.manager.dataSource,
});
return (
this.meta$.value?.config.jsonValue.isEmpty({
value: this.jsonValue$.value,
dataSource: this.view.manager.dataSource,
}) ?? true
);
});
jsonValue$: ReadonlySignal<unknown> = computed(() => {
return this.view.cellJsonValueGet(this.rowId, this.propertyId);
jsonValue$: ReadonlySignal<JsonValue> = computed(() => {
return this.view.cellJsonValueGet(this.rowId, this.propertyId) as JsonValue;
});
property$ = computed(() => {
return this.view.propertyGet(this.propertyId) as Property<Value, Data>;
return this.view.propertyGet(this.propertyId) as Property<
RawValue,
JsonValue,
Data
>;
});
stringValue$: ReadonlySignal<string> = computed(() => {
return this.view.cellStringValueGet(this.rowId, this.propertyId)!;
});
get property(): Property<Value, Data> {
get property(): Property<RawValue, JsonValue, Data> {
return this.property$.value;
}
@@ -72,7 +81,7 @@ export class CellBase<
public rowId: string
) {}
valueSet(value: unknown | undefined): void {
valueSet(value: RawValue | undefined): void {
this.view.manager.dataSource.cellValueChange(
this.rowId,
this.propertyId,

View File

@@ -8,7 +8,8 @@ import type { Cell } from './cell.js';
import type { SingleView } from './single-view.js';
export interface Property<
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
readonly id: string;
@@ -28,7 +29,7 @@ export interface Property<
readonly duplicate?: () => void;
get canDuplicate(): boolean;
cellGet(rowId: string): Cell<Value>;
cellGet(rowId: string): Cell<RawValue, JsonValue, Data>;
readonly data$: ReadonlySignal<Data>;
dataUpdate(updater: PropertyDataUpdater<Data>): void;
@@ -44,17 +45,18 @@ export interface Property<
hideSet(hide: boolean): void;
get hideCanSet(): boolean;
valueGet(rowId: string): Value | undefined;
valueSet(rowId: string, value: Value | undefined): void;
valueGet(rowId: string): RawValue | undefined;
valueSet(rowId: string, value: RawValue | undefined): void;
stringValueGet(rowId: string): string;
valueSetFromString(rowId: string, value: string): void;
}
export abstract class PropertyBase<
Value = unknown,
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> implements Property<Value, Data>
> implements Property<RawValue, JsonValue, Data>
{
cells$ = computed(() => {
return this.view.rows$.value.map(id => this.cellGet(id));
@@ -141,8 +143,8 @@ export abstract class PropertyBase<
return this.view.propertyCanHide(this.id);
}
cellGet(rowId: string): Cell<Value> {
return this.view.cellGet(rowId, this.id) as Cell<Value>;
cellGet(rowId: string): Cell<RawValue, JsonValue, Data> {
return this.view.cellGet(rowId, this.id) as Cell<RawValue, JsonValue, Data>;
}
dataUpdate(updater: PropertyDataUpdater<Data>): void {
@@ -165,11 +167,11 @@ export abstract class PropertyBase<
return this.cellGet(rowId).stringValue$.value;
}
valueGet(rowId: string): Value | undefined {
valueGet(rowId: string): RawValue | undefined {
return this.cellGet(rowId).value$.value;
}
valueSet(rowId: string, value: Value | undefined): void {
valueSet(rowId: string, value: RawValue | undefined): void {
return this.cellGet(rowId).valueSet(value);
}
@@ -181,6 +183,6 @@ export abstract class PropertyBase<
if (data.data) {
this.dataUpdate(() => data.data as Data);
}
this.valueSet(rowId, data.value as Value);
this.valueSet(rowId, data.value as RawValue);
}
}

View File

@@ -4,9 +4,9 @@ import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import type { DataViewContextKey } from '../data-source/context.js';
import type { Variable } from '../expression/types.js';
import type { DVJSON } from '../index.js';
import type { TypeInstance } from '../logical/type.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
import { fromJson } from '../property/utils';
import type { TraitKey } from '../traits/key.js';
import type { DatabaseFlags } from '../types.js';
import type { DataViewDataType, ViewMeta } from '../view/data-view.js';
@@ -50,9 +50,9 @@ export interface SingleView {
cellValueSet(rowId: string, propertyId: string, value: unknown): void;
cellJsonValueGet(rowId: string, propertyId: string): DVJSON;
cellJsonValueGet(rowId: string, propertyId: string): unknown | null;
cellJsonValueSet(rowId: string, propertyId: string, value: DVJSON): void;
cellJsonValueSet(rowId: string, propertyId: string, value: unknown): void;
cellStringValueGet(rowId: string, propertyId: string): string | undefined;
@@ -82,12 +82,17 @@ export interface SingleView {
readonly propertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
propertyAdd(toAfterOfProperty: InsertToPosition, type?: string): string;
propertyAdd(
toAfterOfProperty: InsertToPosition,
type?: string
): string | undefined;
propertyDelete(propertyId: string): void;
propertyCanDelete(propertyId: string): boolean;
propertyDuplicate(propertyId: string): void;
propertyCanDuplicate(propertyId: string): boolean;
propertyGet(propertyId: string): Property;
@@ -105,11 +110,13 @@ export interface SingleView {
propertyTypeGet(propertyId: string): string | undefined;
propertyTypeSet(propertyId: string, type: string): void;
propertyTypeCanSet(propertyId: string): boolean;
propertyHideGet(propertyId: string): boolean;
propertyHideSet(propertyId: string, hide: boolean): void;
propertyCanHide(propertyId: string): boolean;
propertyDataGet(propertyId: string): Record<string, unknown>;
@@ -187,13 +194,16 @@ export abstract class SingleViewBase<
});
vars$ = computed(() => {
return this.propertiesWithoutFilter$.value.map(id => {
return this.propertiesWithoutFilter$.value.flatMap(id => {
const v = this.propertyGet(id);
const propertyMeta = this.dataSource.propertyMetaGet(v.type$.value);
if (!propertyMeta) {
return [];
}
return {
id: v.id,
name: v.name$.value,
type: propertyMeta.config.type({
type: propertyMeta.config.jsonValue.type({
data: v.data$.value,
dataSource: this.dataSource,
}),
@@ -229,15 +239,19 @@ export abstract class SingleViewBase<
public manager: ViewManager,
public id: string
) {}
propertyCanDelete(propertyId: string): boolean {
return this.dataSource.propertyCanDelete(propertyId);
}
propertyCanDuplicate(propertyId: string): boolean {
return this.dataSource.propertyCanDuplicate(propertyId);
}
propertyTypeCanSet(propertyId: string): boolean {
return this.dataSource.propertyTypeCanSet(propertyId);
}
propertyCanHide(propertyId: string): boolean {
return this.propertyTypeGet(propertyId) !== 'title';
}
@@ -264,33 +278,35 @@ export abstract class SingleViewBase<
return new CellBase(this, propertyId, rowId);
}
cellJsonValueGet(rowId: string, propertyId: string): DVJSON {
cellJsonValueGet(rowId: string, propertyId: string): unknown | null {
const type = this.propertyTypeGet(propertyId);
if (!type) {
return null;
}
return this.dataSource.propertyMetaGet(type).config.cellToJson({
value: this.dataSource.cellValueGet(rowId, propertyId),
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
});
return (
this.dataSource.propertyMetaGet(type)?.config.rawValue.toJson({
value: this.dataSource.cellValueGet(rowId, propertyId),
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
}) ?? null
);
}
cellJsonValueSet(rowId: string, propertyId: string, value: DVJSON): void {
cellJsonValueSet(rowId: string, propertyId: string, value: unknown): void {
const type = this.propertyTypeGet(propertyId);
if (!type) {
return;
}
const fromJson = this.dataSource.propertyMetaGet(type).config.cellFromJson;
this.dataSource.cellValueChange(
rowId,
propertyId,
fromJson({
value,
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
})
);
const config = this.dataSource.propertyMetaGet(type)?.config;
if (!config) {
return;
}
const rawValue = fromJson(config, {
value: value,
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
});
this.dataSource.cellValueChange(rowId, propertyId, rawValue);
}
cellStringValueGet(rowId: string, propertyId: string): string | undefined {
@@ -299,7 +315,7 @@ export abstract class SingleViewBase<
return;
}
return (
this.dataSource.propertyMetaGet(type).config.cellToString({
this.dataSource.propertyMetaGet(type)?.config.rawValue.toString({
value: this.dataSource.cellValueGet(rowId, propertyId),
data: this.propertyDataGet(propertyId),
}) ?? ''
@@ -311,14 +327,7 @@ export abstract class SingleViewBase<
if (!type) {
return;
}
const cellValue = this.dataSource.cellValueGet(rowId, propertyId);
return (
this.dataSource.propertyMetaGet(type).config.formatValue?.({
value: cellValue,
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
}) ?? cellValue
);
return this.dataSource.cellValueGet(rowId, propertyId);
}
cellValueSet(rowId: string, propertyId: string, value: unknown): void {
@@ -355,8 +364,11 @@ export abstract class SingleViewBase<
});
}
propertyAdd(position: InsertToPosition, type?: string): string {
propertyAdd(position: InsertToPosition, type?: string): string | undefined {
const id = this.dataSource.propertyAdd(position, type);
if (!id) {
return;
}
this.propertyMove(id, position);
return id;
}
@@ -374,7 +386,11 @@ export abstract class SingleViewBase<
if (!type) {
return;
}
return this.dataSource.propertyMetaGet(type).config.type({
const meta = this.dataSource.propertyMetaGet(type);
if (!meta) {
return;
}
return meta.config.jsonValue.type({
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
});
@@ -402,7 +418,7 @@ export abstract class SingleViewBase<
abstract propertyHideSet(propertyId: string, hide: boolean): void;
propertyIconGet(type: string): UniComponent | undefined {
return this.dataSource.propertyMetaGet(type).renderer.icon;
return this.dataSource.propertyMetaGet(type)?.renderer.icon;
}
propertyIdGetByIndex(index: number): string | undefined {
@@ -413,7 +429,7 @@ export abstract class SingleViewBase<
return this.propertyIds$.value.indexOf(propertyId);
}
propertyMetaGet(type: string): PropertyMetaConfig {
propertyMetaGet(type: string): PropertyMetaConfig | undefined {
return this.dataSource.propertyMetaGet(type);
}
@@ -439,13 +455,16 @@ export abstract class SingleViewBase<
if (!type) {
return;
}
return (
this.dataSource.propertyMetaGet(type).config.cellFromString({
value: cellData,
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
}) ?? ''
);
const fromString =
this.dataSource.propertyMetaGet(type)?.config.rawValue.fromString;
if (!fromString) {
return;
}
return fromString({
value: cellData,
data: this.propertyDataGet(propertyId),
dataSource: this.dataSource,
});
}
propertyPreGet(propertyId: string): Property | undefined {

View File

@@ -15,7 +15,7 @@ import { AffineLitIcon, UniAnyRender, UniLit } from './core/index.js';
import { AnyRender } from './core/utils/uni-component/render-template.js';
import { CheckboxCell } from './property-presets/checkbox/cell-renderer.js';
import { DateCell } from './property-presets/date/cell-renderer.js';
import { TextCell as ImageTextCell } from './property-presets/image/cell-renderer.js';
import { ImageCell } from './property-presets/image/cell-renderer.js';
import { MultiSelectCell } from './property-presets/multi-select/cell-renderer.js';
import { NumberCell } from './property-presets/number/cell-renderer.js';
import { ProgressCell } from './property-presets/progress/cell-renderer.js';
@@ -74,7 +74,7 @@ export function effects() {
customElements.define('mobile-table-cell', MobileTableCell);
customElements.define('affine-data-view-renderer', DataViewRenderer);
customElements.define('any-render', AnyRender);
customElements.define('affine-database-image-cell', ImageTextCell);
customElements.define('affine-database-image-cell', ImageCell);
customElements.define('affine-database-date-cell', DateCell);
customElements.define(
'data-view-properties-setting',

View File

@@ -21,15 +21,24 @@ const FALSE_VALUES = new Set([
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
name: 'Checkbox',
valueSchema: zod.boolean().optional(),
type: () => t.boolean.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => (value ? 'True' : 'False'),
cellFromString: ({ value }) => ({
value: !FALSE_VALUES.has((value?.trim() ?? '').toLowerCase()),
}),
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'boolean' ? undefined : value),
isEmpty: () => false,
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.boolean(),
isEmpty: () => false,
type: () => t.boolean.instance(),
},
rawValue: {
schema: zod.boolean(),
default: () => false,
fromString: ({ value }) => ({
value: !FALSE_VALUES.has((value?.trim() ?? '').toLowerCase()),
}),
toString: ({ value }) => (value ? 'True' : 'False'),
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
minWidth: 34,
});

View File

@@ -20,7 +20,7 @@ import {
} from './cell-renderer.css.js';
import { datePropertyModelConfig } from './define.js';
export class DateCell extends BaseCellRenderer<number> {
export class DateCell extends BaseCellRenderer<number, number> {
private _prevPortalAbortController: AbortController | null = null;
private readonly openDatePicker = () => {

View File

@@ -7,19 +7,24 @@ import { propertyType } from '../../core/property/property-config.js';
export const datePropertyType = propertyType('date');
export const datePropertyModelConfig = datePropertyType.modelConfig({
name: 'Date',
type: () => t.date.instance(),
valueSchema: zod.number().optional(),
defaultData: () => ({}),
cellToString: ({ value }) =>
value != null ? format(value, 'yyyy-MM-dd') : '',
cellFromString: ({ value }) => {
const date = parse(value, 'yyyy-MM-dd', new Date());
return {
value: +date,
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.number().nullable(),
isEmpty: () => false,
type: () => t.date.instance(),
},
rawValue: {
schema: zod.number().nullable(),
default: () => null,
toString: ({ value }) => (value != null ? format(value, 'yyyy-MM-dd') : ''),
fromString: ({ value }) => {
const date = parse(value, 'yyyy-MM-dd', new Date());
return { value: +date };
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'number' ? undefined : value),
isEmpty: ({ value }) => value == null,
});

View File

@@ -5,7 +5,7 @@ import { createFromBaseCellRenderer } from '../../core/property/renderer.js';
import { createIcon } from '../../core/utils/uni-icon.js';
import { imagePropertyModelConfig } from './define.js';
export class TextCell extends BaseCellRenderer<string> {
export class ImageCell extends BaseCellRenderer<string, string> {
static override styles = css`
affine-database-image-cell {
width: 100%;
@@ -27,6 +27,6 @@ export class TextCell extends BaseCellRenderer<string> {
export const imagePropertyConfig = imagePropertyModelConfig.createPropertyMeta({
icon: createIcon('ImageIcon'),
cellRenderer: {
view: createFromBaseCellRenderer(TextCell),
view: createFromBaseCellRenderer(ImageCell),
},
});

View File

@@ -2,21 +2,31 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
export const imagePropertyType = propertyType('image');
export const imagePropertyModelConfig = imagePropertyType.modelConfig({
name: 'image',
valueSchema: zod.string().optional(),
hide: true,
type: () => t.image.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value ?? '',
cellFromString: ({ value }) => {
return {
value: value,
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
isEmpty: ({ value }) => value == null,
jsonValue: {
schema: zod.string().nullable(),
isEmpty: ({ value }) => value == null,
type: () => t.image.instance(),
},
rawValue: {
schema: zod.string().nullable(),
default: () => null,
toString: ({ value }) => value ?? '',
fromString: ({ value }) => {
return {
value: value,
};
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
hide: true,
});

View File

@@ -13,6 +13,7 @@ import { multiSelectStyle } from './cell-renderer.css.js';
import { multiSelectPropertyModelConfig } from './define.js';
export class MultiSelectCell extends BaseCellRenderer<
string[],
string[],
SelectPropertyData
> {

View File

@@ -4,40 +4,29 @@ import zod from 'zod';
import { getTagColor } from '../../core/component/tags/colors.js';
import { type SelectTag, t } from '../../core/index.js';
import { propertyType } from '../../core/property/property-config.js';
import type { SelectPropertyData } from '../select/define.js';
import { SelectPropertySchema } from '../select/define.js';
export const multiSelectPropertyType = propertyType('multi-select');
export const multiSelectPropertyModelConfig =
multiSelectPropertyType.modelConfig<string[] | undefined, SelectPropertyData>(
{
name: 'Multi-select',
valueSchema: zod.array(zod.string()).optional(),
type: ({ data }) => t.array.instance(t.tag.instance(data.options)),
defaultData: () => ({
multiSelectPropertyType.modelConfig({
name: 'Multi-select',
propertyData: {
schema: SelectPropertySchema,
default: () => ({
options: [],
}),
addGroup: ({ text, oldData }) => {
return {
options: [
...(oldData.options ?? []),
{
id: nanoid(),
value: text,
color: getTagColor(),
},
],
};
},
formatValue: ({ value }) => {
if (Array.isArray(value)) {
return value.filter(v => v != null);
}
return [];
},
cellToString: ({ value, data }) =>
value
?.map(id => data.options.find(v => v.id === id)?.value)
.join(',') ?? '',
cellFromString: ({ value: oldValue, data }) => {
},
jsonValue: {
schema: zod.array(zod.string()),
isEmpty: ({ value }) => value.length === 0,
type: ({ data }) => t.array.instance(t.tag.instance(data.options)),
},
rawValue: {
schema: zod.array(zod.string()),
default: () => [],
toString: ({ value, data }) =>
value.map(id => data.options.find(v => v.id === id)?.value).join(','),
fromString: ({ value: oldValue, data }) => {
const optionMap = Object.fromEntries(
data.options.map(v => [v.value, v])
);
@@ -66,11 +55,22 @@ export const multiSelectPropertyModelConfig =
data: data,
};
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) =>
toJson: ({ value }) => value ?? null,
fromJson: ({ value }) =>
Array.isArray(value) && value.every(v => typeof v === 'string')
? value
: undefined,
isEmpty: ({ value }) => value == null || value.length === 0,
}
);
},
addGroup: ({ text, oldData }) => {
return {
options: [
...(oldData.options ?? []),
{
id: nanoid(),
value: text,
color: getTagColor(),
},
],
};
},
});

View File

@@ -16,6 +16,7 @@ import {
} from './utils/formatter.js';
export class NumberCell extends BaseCellRenderer<
number,
number,
NumberPropertyDataType
> {

View File

@@ -2,25 +2,29 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
import type { NumberPropertyDataType } from './types.js';
import { NumberPropertySchema } from './types.js';
export const numberPropertyType = propertyType('number');
export const numberPropertyModelConfig = numberPropertyType.modelConfig<
number | undefined,
NumberPropertyDataType
>({
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
name: 'Number',
valueSchema: zod.number().optional(),
type: () => t.number.instance(),
defaultData: () => ({ decimal: 0, format: 'number' }),
cellToString: ({ value }) => value?.toString() ?? '',
cellFromString: ({ value }) => {
const num = value ? Number(value) : NaN;
return {
value: isNaN(num) ? null : num,
};
propertyData: {
schema: NumberPropertySchema,
default: () => ({ decimal: 0, format: 'number' }) as const,
},
jsonValue: {
schema: zod.number().nullable(),
isEmpty: ({ value }) => value == null,
type: () => t.number.instance(),
},
rawValue: {
schema: zod.number().nullable(),
default: () => null,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
const num = value ? Number(value) : NaN;
return { value: isNaN(num) ? null : num };
},
toJson: ({ value }) => value ?? null,
fromJson: ({ value }) => (typeof value !== 'number' ? null : value),
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'number' ? undefined : value),
isEmpty: ({ value }) => value == null,
});

View File

@@ -1,6 +1,9 @@
import type { NumberFormat } from './utils/formatter.js';
import zod from 'zod';
export type NumberPropertyDataType = {
decimal?: number;
format?: NumberFormat;
};
import { NumberFormatSchema } from './utils/formatter.js';
export const NumberPropertySchema = zod.object({
decimal: zod.number().optional(),
format: NumberFormatSchema,
});
export type NumberPropertyDataType = zod.infer<typeof NumberPropertySchema>;

View File

@@ -1,13 +1,16 @@
export type NumberFormat =
| 'number'
| 'numberWithCommas'
| 'percent'
| 'currencyYen'
| 'currencyINR'
| 'currencyCNY'
| 'currencyUSD'
| 'currencyEUR'
| 'currencyGBP';
import zod from 'zod';
export const NumberFormatSchema = zod.enum([
'number',
'numberWithCommas',
'percent',
'currencyYen',
'currencyINR',
'currencyCNY',
'currencyUSD',
'currencyEUR',
'currencyGBP',
]);
export type NumberFormat = zod.infer<typeof NumberFormatSchema>;
const currency = (currency: string): Intl.NumberFormatOptions => ({
style: 'currency',

View File

@@ -23,7 +23,7 @@ const progressColors = {
success: 'var(--affine-success-color)',
};
export class ProgressCell extends BaseCellRenderer<number> {
export class ProgressCell extends BaseCellRenderer<number, number> {
startDrag = (event: MouseEvent) => {
if (!this.isEditing$.value) return;

View File

@@ -6,20 +6,24 @@ export const progressPropertyType = propertyType('progress');
export const progressPropertyModelConfig = progressPropertyType.modelConfig({
name: 'Progress',
valueSchema: zod.number().optional(),
type: () => t.number.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value?.toString() ?? '',
cellFromString: ({ value }) => {
const num = value ? Number(value) : NaN;
return {
value: isNaN(num) ? null : num,
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => {
if (typeof value !== 'number') return undefined;
return value;
jsonValue: {
schema: zod.number(),
isEmpty: () => false,
type: () => t.number.instance(),
},
rawValue: {
schema: zod.number(),
default: () => 0,
toString: ({ value }) => value.toString(),
fromString: ({ value }) => {
const num = value ? Number(value) : NaN;
return { value: isNaN(num) ? 0 : num };
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
isEmpty: () => false,
});

View File

@@ -13,7 +13,11 @@ import {
selectPropertyModelConfig,
} from './define.js';
export class SelectCell extends BaseCellRenderer<string, SelectPropertyData> {
export class SelectCell extends BaseCellRenderer<
string,
string,
SelectPropertyData
> {
closePopup?: () => void;
private readonly popTagSelect = () => {
this.closePopup = popTagSelect(popupTargetFromElement(this), {

View File

@@ -2,23 +2,66 @@ import { nanoid } from '@blocksuite/store';
import zod from 'zod';
import { getTagColor } from '../../core/component/tags/colors.js';
import { type SelectTag, t } from '../../core/index.js';
import { type SelectTag, SelectTagSchema, t } from '../../core/index.js';
import { propertyType } from '../../core/property/property-config.js';
export const selectPropertyType = propertyType('select');
export type SelectPropertyData = {
options: SelectTag[];
};
export const selectPropertyModelConfig = selectPropertyType.modelConfig<
string | undefined,
SelectPropertyData
>({
export const SelectPropertySchema = zod.object({
options: zod.array(SelectTagSchema),
});
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
name: 'Select',
valueSchema: zod.string().optional(),
type: ({ data }) => t.tag.instance(data.options),
defaultData: () => ({
options: [],
}),
propertyData: {
schema: SelectPropertySchema,
default: () => ({
options: [],
}),
},
jsonValue: {
schema: zod.string().nullable(),
isEmpty: ({ value }) => value == null,
type: ({ data }) => t.tag.instance(data.options),
},
rawValue: {
schema: zod.string().nullable(),
default: () => null,
toString: ({ value, data }) =>
data.options.find(v => v.id === value)?.value ?? '',
fromString: ({ value: oldValue, data }) => {
if (!oldValue) {
return { value: null, data: data };
}
const optionMap = Object.fromEntries(data.options.map(v => [v.value, v]));
const name = oldValue
.split(',')
.map(v => v.trim())
.filter(v => v)[0];
if (!name) {
return { value: null, data: data };
}
let value: string | undefined;
const option = optionMap[name];
if (!option) {
const newOption: SelectTag = {
id: nanoid(),
value: name,
color: getTagColor(),
};
data.options.push(newOption);
value = newOption.id;
} else {
value = option.id;
}
return {
value,
data: data,
};
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
addGroup: ({ text, oldData }) => {
return {
options: [
@@ -27,41 +70,4 @@ export const selectPropertyModelConfig = selectPropertyType.modelConfig<
],
};
},
cellToString: ({ value, data }) =>
data.options.find(v => v.id === value)?.value ?? '',
cellFromString: ({ value: oldValue, data }) => {
if (!oldValue) {
return { value: null, data: data };
}
const optionMap = Object.fromEntries(data.options.map(v => [v.value, v]));
const name = oldValue
.split(',')
.map(v => v.trim())
.filter(v => v)[0];
if (!name) {
return { value: null, data: data };
}
let value: string | undefined;
const option = optionMap[name];
if (!option) {
const newOption: SelectTag = {
id: nanoid(),
value: name,
color: getTagColor(),
};
data.options.push(newOption);
value = newOption.id;
} else {
value = option.id;
}
return {
value,
data: data,
};
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
isEmpty: ({ value }) => value == null,
});

View File

@@ -7,7 +7,7 @@ import { createIcon } from '../../core/utils/uni-icon.js';
import { textInputStyle, textStyle } from './cell-renderer.css.js';
import { textPropertyModelConfig } from './define.js';
export class TextCell extends BaseCellRenderer<string> {
export class TextCell extends BaseCellRenderer<string, string> {
@query('input')
private accessor _inputEle!: HTMLInputElement;

View File

@@ -6,16 +6,24 @@ export const textPropertyType = propertyType('text');
export const textPropertyModelConfig = textPropertyType.modelConfig({
name: 'Plain-Text',
valueSchema: zod.string().optional(),
type: () => t.string.instance(),
defaultData: () => ({}),
cellToString: ({ value }) => value ?? '',
cellFromString: ({ value }) => {
return {
value: value,
};
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
cellToJson: ({ value }) => value ?? null,
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
isEmpty: ({ value }) => value == null || value.length === 0,
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,
},
hide: true,
});

View File

@@ -14,6 +14,7 @@ import {
groupTraitKey,
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { PropertyBase } from '../../core/view-manager/property.js';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { KanbanViewData } from './define.js';
@@ -183,14 +184,15 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGet(propertyId);
const propertyMeta = this.propertyMetaGet(property.type$.value);
if (propertyMeta?.config.cellFromJson) {
const value = propertyMeta.config.cellFromJson({
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellValueSet(id, propertyId, value);
if (!propertyMeta) {
return;
}
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellValueSet(id, propertyId, value);
});
}

View File

@@ -23,7 +23,6 @@ import { html } from 'lit/static-html.js';
import { inputConfig, typeConfig } from '../../../core/common/property-menu.js';
import type { Property } from '../../../core/view-manager/property.js';
import type { NumberPropertyDataType } from '../../../property-presets/index.js';
import { numberFormats } from '../../../property-presets/number/utils/formats.js';
import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../consts.js';
import type { TableColumn, TableSingleView } from '../table-view-manager.js';
@@ -91,12 +90,7 @@ export class MobileTableColumnHeader extends SignalWatcher(
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = (
this.column as Property<
number,
NumberPropertyDataType
>
).data$.value;
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span

View File

@@ -39,7 +39,6 @@ import {
droppable,
} from '../../../../core/utils/wc-dnd/dnd-context.js';
import type { Property } from '../../../../core/view-manager/property.js';
import type { NumberPropertyDataType } from '../../../../property-presets/index.js';
import { numberFormats } from '../../../../property-presets/number/utils/formats.js';
import { ShowQuickSettingBarContextKey } from '../../../../widget-presets/quick-setting-bar/context.js';
import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../consts.js';
@@ -222,12 +221,7 @@ export class DatabaseHeaderColumn extends SignalWatcher(
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = (
this.column as Property<
number,
NumberPropertyDataType
>
).data$.value;
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span

View File

@@ -81,7 +81,7 @@ export class DatabaseColumnStatsCell extends SignalWatcher(
return this.column.valueGet(id);
});
}
return this.column.cells$.value.map(cell => cell.value$.value);
return this.column.cells$.value.map(cell => cell.jsonValue$.value);
});
groups$ = computed(() => {

View File

@@ -14,6 +14,7 @@ import {
groupTraitKey,
sortByManually,
} from '../../core/group-by/trait.js';
import { fromJson } from '../../core/property/utils';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import {
@@ -327,8 +328,8 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGet(propertyId);
const propertyMeta = this.propertyMetaGet(property.type$.value);
if (propertyMeta?.config.cellFromJson) {
const value = propertyMeta.config.cellFromJson({
if (propertyMeta) {
const value = fromJson(propertyMeta.config, {
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,

View File

@@ -79,11 +79,13 @@ export const database: InitFn = (collection: Workspace, id: string) => {
{ type: type, text: new Text(`Paragraph type ${type}`) },
databaseId
);
datasource.cellValueChange(
id,
richTextId,
new Text(`Paragraph type ${type}`)
);
if (richTextId) {
datasource.cellValueChange(
id,
richTextId,
new Text(`Paragraph type ${type}`)
);
}
});
const listTypes: ListType[] = ['numbered', 'bulleted', 'todo', 'toggle'];
@@ -93,11 +95,13 @@ export const database: InitFn = (collection: Workspace, id: string) => {
{ type: type, text: new Text(`List type ${type}`) },
databaseId
);
datasource.cellValueChange(
id,
richTextId,
new Text(`List type ${type}`)
);
if (richTextId) {
datasource.cellValueChange(
id,
richTextId,
new Text(`List type ${type}`)
);
}
});
// Add a paragraph after database
doc.addBlock('affine:paragraph', {}, noteId);

View File

@@ -531,8 +531,11 @@ test.describe('readonly mode', () => {
});
await cell.click();
await pressEnter(page);
await waitNextFrame(page, 100);
await type(page, '123');
await waitNextFrame(page, 100);
await pressEnter(page);
await waitNextFrame(page, 100);
await assertDatabaseCellRichTexts(page, { text: '123' });
await switchReadonly(page);

View File

@@ -386,6 +386,9 @@ export async function initKanbanViewState(
});
config.columns.forEach(column => {
const columnId = datasource.propertyAdd('end', column.type);
if (!columnId) {
return;
}
datasource.propertyNameSet(columnId, column.type);
rowIds.forEach((rowId, index) => {
const value = column.value?.[index];