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
@@ -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,
});
@@ -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 = () => {
@@ -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,
});
@@ -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),
},
});
@@ -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,
});
@@ -13,6 +13,7 @@ import { multiSelectStyle } from './cell-renderer.css.js';
import { multiSelectPropertyModelConfig } from './define.js';
export class MultiSelectCell extends BaseCellRenderer<
string[],
string[],
SelectPropertyData
> {
@@ -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(),
},
],
};
},
});
@@ -16,6 +16,7 @@ import {
} from './utils/formatter.js';
export class NumberCell extends BaseCellRenderer<
number,
number,
NumberPropertyDataType
> {
@@ -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,
});
@@ -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>;
@@ -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',
@@ -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;
@@ -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,
});
@@ -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), {
@@ -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,
});
@@ -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;
@@ -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,
});