mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
refactor(editor): add runtime type checks to database cell values (#10770)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
30
blocksuite/affine/data-view/src/core/property/utils.ts
Normal file
30
blocksuite/affine/data-view/src/core/property/utils.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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) + '%';
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user