snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
{
"name": "@blocksuite/data-view",
"description": "Views of database in affine",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@emotion/css": "^11.13.5",
"@emotion/hash": "^0.9.2",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
"./property-presets": "./src/property-presets/index.ts",
"./property-pure-presets": "./src/property-presets/pure-index.ts",
"./view-presets": "./src/view-presets/index.ts",
"./widget-presets": "./src/widget-presets/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,371 @@
import { describe, expect, it } from 'vitest';
import {
type CalendarEntry,
createCalendarMonthLayout,
getCalendarDayContentSlots,
getCalendarVisibleMonthRange,
} from '../view-presets/calendar/index.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
describe('calendar month layout', () => {
it('buckets single day entries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('splits range external entries across weeks', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Trip',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 6, span: 1 },
{ weekIndex: 2, startIndex: 0, span: 3 },
]);
});
it('treats all-day external midnight end as exclusive', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'All day',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
allDay: true,
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toEqual([entry]);
});
it('treats row midnight end date as inclusive', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Task',
startAt: day('2026-05-15'),
endAt: day('2026-05-16'),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 2, startIndex: 5, span: 2 },
]);
});
it('clips range entries to visible month range', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Long trip',
startAt: day('2026-04-01'),
endAt: day('2026-06-30'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments[0]).toMatchObject({
weekIndex: 0,
startIndex: 0,
span: 7,
});
expect(layout.segments.at(-1)).toMatchObject({
weekIndex: layout.weeks.length - 1,
startIndex: 0,
span: 7,
});
});
it('pads month view to full weeks', () => {
const range = getCalendarVisibleMonthRange(day('2026-05-01'));
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [],
});
expect(new Date(range.from).getDay()).toBe(0);
expect(new Date(range.to).getDay()).toBe(6);
expect(layout.days).toHaveLength(layout.weeks.length * 7);
});
it('keeps day buckets on local midnight across DST boundaries', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'DST task',
startAt: day('2026-03-09'),
cardProperties: [],
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(
layout.days.every(item => {
const date = new Date(item.date);
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
})
).toBe(true);
expect(
layout.days.find(item => item.date === day('2026-03-09'))?.entries
).toEqual([entry]);
});
it('keeps range segment offsets across DST boundaries', () => {
const entry = {
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'DST range',
startAt: day('2026-03-09'),
endAt: new Date('2026-03-10T12:00:00').getTime(),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-03-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{ weekIndex: 1, startIndex: 1, span: 2 },
]);
});
it('keeps all same-day entries in the day bucket', () => {
const entries = Array.from(
{ length: 4 },
(_, index) =>
({
kind: 'row',
id: `database:row-${index}`,
sourceId: 'database',
rowId: `row-${index}`,
title: `Task ${index}`,
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
}) satisfies CalendarEntry
);
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
expect(
layout.days.find(item => item.date === day('2026-05-15'))?.entries
).toHaveLength(4);
});
it('assigns each overlapping range segment to its own slot', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:full-${index}`,
sourceId: 'workspace-calendar',
externalId: `full-${index}`,
title: `Full ${index}`,
startAt: day('2026-05-15'),
endAt: new Date('2026-05-17T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'external',
id: 'external:short',
sourceId: 'workspace-calendar',
externalId: 'short',
title: 'Short',
startAt: day('2026-05-18'),
endAt: new Date('2026-05-19T12:00:00').getTime(),
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may15 = layout.days.find(item => item.date === day('2026-05-15'))!;
const may18 = layout.days.find(item => item.date === day('2026-05-18'))!;
expect(getCalendarDayContentSlots(may15)).toBe(3);
expect(may15.segments.map(segment => segment.slot)).toEqual([0, 1, 2]);
expect(getCalendarDayContentSlots(may18)).toBe(1);
expect(may18.segments.map(segment => segment.slot)).toEqual([0]);
});
it('counts segment and same-day slots for drag preview placement', () => {
const entries: CalendarEntry[] = [
...Array.from(
{ length: 3 },
(_, index) =>
({
kind: 'external',
id: `external:range-${index}`,
sourceId: 'workspace-calendar',
externalId: `range-${index}`,
title: `Range ${index}`,
startAt: day('2026-05-08'),
endAt: new Date('2026-05-09T12:00:00').getTime(),
canResizeRange: false,
}) as const
),
{
kind: 'row',
id: 'database:moving',
sourceId: 'database',
rowId: 'moving',
title: 'Moving',
startAt: day('2026-05-06'),
endAt: new Date('2026-05-08T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
},
{
kind: 'row',
id: 'database:single',
sourceId: 'database',
rowId: 'single',
title: 'Single',
startAt: day('2026-05-08'),
cardProperties: [],
canResizeRange: false,
},
];
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries,
});
const may8 = layout.days.find(item => item.date === day('2026-05-08'))!;
expect(getCalendarDayContentSlots(may8, 'database:moving')).toBe(4);
});
it('splits row range entries across weeks with continuation metadata', () => {
const entry = {
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Project',
startAt: day('2026-05-09'),
endAt: new Date('2026-05-12T12:00:00').getTime(),
cardProperties: [],
canResizeRange: true,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toMatchObject([
{
weekIndex: 1,
startIndex: 6,
span: 1,
startsBeforeWeek: false,
endsAfterWeek: true,
},
{
weekIndex: 2,
startIndex: 0,
span: 3,
startsBeforeWeek: true,
endsAfterWeek: false,
},
]);
});
it('skips range entries completely outside the visible month range', () => {
const entry = {
kind: 'external',
id: 'external:outside',
sourceId: 'workspace-calendar',
externalId: 'outside',
title: 'Outside',
startAt: day('2026-06-10'),
endAt: day('2026-06-12'),
canResizeRange: false,
} satisfies CalendarEntry;
const layout = createCalendarMonthLayout({
month: day('2026-05-01'),
entries: [entry],
});
expect(layout.segments).toEqual([]);
expect(layout.days.every(day => day.segments.length === 0)).toBe(true);
});
});

View File

@@ -0,0 +1,812 @@
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from '../core/data-source/base.js';
import {
CalendarSingleView,
type CalendarStoredViewData,
calendarViewModel,
} from '../view-presets/calendar/index.js';
import {
formatEntryTime,
openCalendarEntry,
} from '../view-presets/calendar/pc/actions.js';
import { getCalendarDndEntity } from '../view-presets/calendar/pc/dnd.js';
import { viewConverts } from '../view-presets/convert.js';
const day = (value: string) => new Date(`${value}T00:00:00`).getTime();
const createCalendarView = (options?: {
startColumnId?: string;
endColumnId?: string;
datePropertyType?: string;
rows?: string[];
filterValue?: string;
titleValue?: unknown;
linkedDocTitles?: Record<string, string>;
visiblePropertyIds?: string[];
externalFactories?: Map<unknown, unknown>;
}) => {
const rows = signal(options?.rows ?? ['row-1']);
const columns = signal(['title', 'date', 'end-date', 'status']);
const viewData = signal<CalendarStoredViewData>({
id: 'view-1',
name: 'Calendar',
mode: 'calendar',
filter: options?.filterValue
? {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: { type: 'ref', name: 'status' },
function: 'is',
args: [{ type: 'literal', value: options.filterValue }],
},
],
}
: {
type: 'group',
op: 'and',
conditions: [],
},
date: {
startColumnId: options?.startColumnId,
endColumnId: options?.endColumnId,
},
card: {
titleColumnId: 'title',
visiblePropertyIds: options?.visiblePropertyIds ?? [],
},
sources: {
workspaceCalendar: {
enabled: true,
},
},
});
const values = new Map<string, unknown>([
['row-1:date', day('2026-05-15')],
['row-1:end-date', day('2026-05-17')],
['row-1:status', 'Done'],
['row-1:title', options?.titleValue ?? 'Task'],
['row-2:date', day('2026-05-16')],
['row-2:end-date', day('2026-05-14')],
['row-2:status', 'Todo'],
['row-2:title', 'Hidden'],
]);
const types = new Map<string, string>([
['title', 'title'],
['date', options?.datePropertyType ?? 'date'],
['end-date', 'date'],
['status', 'text'],
]);
const dataSource = {
rows$: rows,
properties$: columns,
readonly$: signal(false),
featureFlags$: signal({ enable_table_virtual_scroll: false }),
provider: {
getAll: () => options?.externalFactories ?? new Map(),
},
viewDataGet: () => viewData.value,
viewDataUpdate: (
_id: string,
updater: (data: CalendarStoredViewData) => Partial<CalendarStoredViewData>
) => {
viewData.value = { ...viewData.value, ...updater(viewData.value) };
},
cellValueGet: (rowId: string, propertyId: string) =>
values.get(`${rowId}:${propertyId}`),
cellValueChange: (rowId: string, propertyId: string, value: unknown) => {
values.set(`${rowId}:${propertyId}`, value);
},
rowAdd: () => {
const rowId = `row-${rows.value.length + 1}`;
rows.value = [...rows.value, rowId];
return rowId;
},
propertyTypeGet: (propertyId: string) => types.get(propertyId),
propertyNameGet: (propertyId: string) => propertyId,
propertyDataGet: () => ({}),
propertyReadonlyGet: () => false,
serviceGet: (key: unknown) => {
if (key !== DocDisplayMetaProvider) {
return null;
}
return {
title: (pageId: string, referenceInfo?: { title?: string }) =>
signal(referenceInfo?.title ?? options?.linkedDocTitles?.[pageId]),
};
},
propertyMetaGet: (type: string) => ({
type,
config: {
rawValue: {
toJson: ({ value }: { value: unknown }) => {
const deltas =
typeof value === 'object' && value != null && 'deltas$' in value
? (value as { deltas$?: { value?: unknown } }).deltas$?.value
: undefined;
if (!Array.isArray(deltas)) {
return value;
}
return deltas
.map(delta => {
const item = delta as {
insert?: unknown;
attributes?: {
reference?: {
type?: string;
pageId?: unknown;
};
};
};
const pageId = item.attributes?.reference?.pageId;
if (
item.attributes?.reference?.type === 'LinkedPage' &&
typeof pageId === 'string'
) {
return (
options?.linkedDocTitles?.[pageId] ?? item.insert ?? ''
);
}
return item.insert ?? '';
})
.join('');
},
fromJson: ({ value }: { value: unknown }) => value,
toString: ({ value }: { value: unknown }) =>
typeof value === 'string' ? value : '',
},
jsonValue: {
schema: {
safeParse: (value: unknown) => ({ success: true, data: value }),
},
isEmpty: () => false,
type: () => undefined,
},
},
renderer: {},
}),
propertyAdd: () => {
columns.value = [...columns.value, 'created-date'];
types.set('created-date', 'date');
return 'created-date';
},
propertyCanDelete: () => true,
propertyCanDuplicate: () => true,
propertyTypeCanSet: () => true,
} as unknown as DataSource;
const manager = {
dataSource,
readonly$: signal(false),
};
return {
view: new CalendarSingleView(manager as any, 'view-1'),
viewData,
values,
types,
columns,
};
};
describe('CalendarSingleView', () => {
it('creates default view data without selecting a start date', () => {
const data = calendarViewModel.model.defaultData({
dataSource: {
properties$: signal(['title', 'date']),
propertyTypeGet: (id: string) => (id === 'title' ? 'title' : 'date'),
},
} as any);
expect(data.date).toEqual({});
expect(data.card).toEqual({
titleColumnId: 'title',
visiblePropertyIds: [],
});
});
it('enters setup state without a start date property', () => {
const { view } = createCalendarView();
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state when start date column is not date', () => {
const { view } = createCalendarView({
startColumnId: 'date',
datePropertyType: 'text',
});
expect(view.dateMapping$.value.status).toBe('setup');
});
it('enters setup state after date property deletion', () => {
const { view, columns } = createCalendarView({ startColumnId: 'date' });
columns.value = ['title', 'status'];
expect(view.dateMapping$.value.status).toBe('setup');
});
it('creates row entries after filtering rows', () => {
const { view } = createCalendarView({
startColumnId: 'date',
rows: ['row-1', 'row-2'],
filterValue: 'Done',
});
expect(view.rowEntries$.value.map(entry => entry.rowId)).toEqual(['row-1']);
});
it('updates entry date after row date value changes', () => {
const { view, values } = createCalendarView({ startColumnId: 'date' });
values.set('row-1:date', day('2026-05-20'));
expect(view.rowEntries$.value[0]?.startAt).toBe(day('2026-05-20'));
});
it('creates row range entries and falls back when end date is invalid', () => {
const { view } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
rows: ['row-1', 'row-2'],
});
expect(
view.rowEntries$.value.map(entry => [
entry.rowId,
entry.startAt,
entry.endAt,
])
).toEqual([
['row-1', day('2026-05-15'), day('2026-05-17')],
['row-2', day('2026-05-16'), undefined],
]);
expect(view.rowEntries$.value[0]?.canResizeRange).toBe(true);
});
it('moves row range while preserving duration', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.moveRowToDate('row-1', day('2026-05-20'));
expect(values.get('row-1:date')).toBe(day('2026-05-20'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-22'));
});
it('resizes row range without crossing start and end', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
view.resizeRowRange('row-1', 'start', day('2026-05-18'));
expect(values.get('row-1:date')).toBe(day('2026-05-17'));
view.resizeRowRange('row-1', 'end', day('2026-05-14'));
expect(values.get('row-1:end-date')).toBe(day('2026-05-17'));
});
it('creates a row with default filter values and target date', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createRowOnDate(day('2026-05-25'));
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(view.emptyMonthHintDismissed$.value).toBe(true);
});
it('creates a dated linked-doc row', () => {
const { view, values } = createCalendarView({
startColumnId: 'date',
filterValue: 'Done',
});
const rowId = view.createLinkedDocRowOnDate(day('2026-05-25'), 'doc-1');
const title = values.get('row-2:title') as
| { toDelta?: () => unknown[] }
| undefined;
expect(rowId).toBe('row-2');
expect(values.get('row-2:date')).toBe(day('2026-05-25'));
expect(values.get('row-2:status')).toBe('Done');
expect(title?.toDelta?.()).toEqual([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
]);
});
it('dismisses the empty month hint on the current calendar view', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
expect(view.emptyMonthHintDismissed$.value).toBe(false);
view.dismissEmptyMonthHint();
expect(view.emptyMonthHintDismissed$.value).toBe(true);
expect('ui' in viewData.value && viewData.value.ui).toEqual({
emptyMonthHintDismissed: true,
});
});
it('updates workspace calendar settings when legacy view data has no sources', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
});
viewData.value = {
...viewData.value,
sources: undefined as unknown as CalendarStoredViewData['sources'],
};
view.setWorkspaceCalendarEnabled(false);
expect(viewData.value.sources.workspaceCalendar).toEqual({
enabled: false,
});
});
it('enters setup state when legacy view data has no date config', () => {
const { view, viewData } = createCalendarView({
startColumnId: 'date',
endColumnId: 'end-date',
});
viewData.value = {
...viewData.value,
date: undefined as unknown as CalendarStoredViewData['date'],
};
expect(view.dateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
expect(view.endDateMapping$.value).toEqual({
status: 'setup',
propertyId: undefined,
});
});
it('generates card properties from visible property ids', () => {
const { view } = createCalendarView({
startColumnId: 'date',
visiblePropertyIds: ['status'],
});
expect(view.rowEntries$.value[0]?.cardProperties).toEqual([
{
propertyId: 'status',
value: 'Done',
},
]);
});
it('parses single linked doc id from title cell', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Linked doc title',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Linked doc title', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Linked doc title');
});
it('uses normal title text for multiple linked doc titles', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: 'Doc 1',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: 'Doc 2',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
expect(view.rowEntries$.value[0]?.title).toBe('Doc 1Doc 2');
});
it('falls back to the resolved title when linked doc deltas only contain placeholders', () => {
const { view } = createCalendarView({
startColumnId: 'date',
linkedDocTitles: {
'doc-1': 'Doc 1',
'doc-2': 'Doc 2',
},
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-1',
},
},
},
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: 'doc-2',
},
},
},
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'Doc 1', linkedDoc: true },
{ text: 'Doc 2', linkedDoc: true },
]);
});
it('merges linked doc placeholders with the following plain title text', () => {
const { view } = createCalendarView({
startColumnId: 'date',
titleValue: {
deltas$: {
value: [
{
insert: ' ',
attributes: {
reference: { type: 'LinkedPage', pageId: 'doc-1' },
},
},
{ insert: 'How to use folder and Tags' },
],
},
},
});
expect(view.rowEntries$.value[0]?.titleSegments).toEqual([
{ text: 'How to use folder and Tags', linkedDoc: true },
]);
});
it('updates date mapping through setup APIs', () => {
const { view, viewData, values } = createCalendarView({
startColumnId: 'date',
});
view.moveRowToDate('row-1', day('2026-05-21'));
expect(values.get('row-1:date')).toBe(day('2026-05-21'));
view.setDateColumn('date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'date'
);
expect(view.createDateColumn()).toBe('created-date');
expect('date' in viewData.value && viewData.value.date.startColumnId).toBe(
'created-date'
);
});
it('aggregates external source entries without mutating view data', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const anotherExternalEntry = {
kind: 'external',
id: 'external:2',
sourceId: 'another-source',
externalId: '2',
title: 'Another external',
startAt: day('2026-05-16'),
canResizeRange: false,
} as const;
const { view, viewData } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'another-source',
{
create: () => ({
id: 'another-source',
getEntries: () => Promise.resolve([anotherExternalEntry]),
}),
},
],
]),
});
const viewDataBefore = JSON.stringify(viewData.value);
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry, anotherExternalEntry]);
expect(JSON.stringify(viewData.value)).toBe(viewDataBefore);
});
it('keeps successful external entries when another source fails', async () => {
const externalEntry = {
kind: 'external',
id: 'external:1',
sourceId: 'source',
externalId: '1',
title: 'External',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries: () => [externalEntry],
}),
},
],
[
'failing-source',
{
create: () => ({
id: 'failing-source',
getEntries: () => Promise.reject(new Error('denied')),
}),
},
],
]),
});
await expect(
view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
})
).resolves.toEqual([externalEntry]);
});
it('does not let stale external entry loads overwrite newer entries', async () => {
const oldEntry = {
kind: 'external',
id: 'external:old',
sourceId: 'source',
externalId: 'old',
title: 'Old',
startAt: day('2026-05-15'),
canResizeRange: false,
} as const;
const newEntry = {
kind: 'external',
id: 'external:new',
sourceId: 'source',
externalId: 'new',
title: 'New',
startAt: day('2026-06-15'),
canResizeRange: false,
} as const;
let resolveOld!: (entries: [typeof oldEntry]) => void;
let resolveNew!: (entries: [typeof newEntry]) => void;
const oldRequest = new Promise<[typeof oldEntry]>(resolve => {
resolveOld = resolve;
});
const newRequest = new Promise<[typeof newEntry]>(resolve => {
resolveNew = resolve;
});
const getEntries = vi
.fn()
.mockReturnValueOnce(oldRequest)
.mockReturnValueOnce(newRequest);
const { view } = createCalendarView({
startColumnId: 'date',
externalFactories: new Map([
[
'source',
{
create: () => ({
id: 'source',
getEntries,
}),
},
],
]),
});
const firstLoad = view.loadExternalEntries({
from: day('2026-05-01'),
to: day('2026-05-31'),
});
const secondLoad = view.loadExternalEntries({
from: day('2026-06-01'),
to: day('2026-06-30'),
});
resolveNew([newEntry]);
await expect(secondLoad).resolves.toEqual([newEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
resolveOld([oldEntry]);
await expect(firstLoad).resolves.toEqual([oldEntry]);
expect(
view.entries$.value.filter(entry => entry.kind === 'external')
).toEqual([newEntry]);
});
});
describe('calendar entry actions', () => {
it('formats external event popover time ranges with end time', () => {
const label = formatEntryTime({
kind: 'external',
id: 'external:1',
sourceId: 'workspace-calendar',
externalId: '1',
title: 'Planning',
startAt: new Date('2026-05-15T10:00:00').getTime(),
endAt: new Date('2026-05-15T11:00:00').getTime(),
canResizeRange: false,
});
expect(label).toContain(' - ');
expect(label).toContain('2026');
});
it('opens row entries through the detail panel hook', () => {
const openDetailPanel = vi.fn();
const { view } = createCalendarView({ startColumnId: 'date' });
const target = {} as HTMLElement;
openCalendarEntry(
{ openDetailPanel } as any,
view,
{
kind: 'row',
id: 'database:row-1',
sourceId: 'database',
rowId: 'row-1',
title: 'Doc',
startAt: day('2026-05-15'),
cardProperties: [],
canResizeRange: false,
},
target
);
expect(openDetailPanel).toHaveBeenCalledWith(
expect.objectContaining({ view, rowId: 'row-1' })
);
});
});
describe('calendar view converts', () => {
it('converts header/card semantics without date mapping', () => {
const tableToCalendar = viewConverts.find(
convert => convert.from === 'table' && convert.to === 'calendar'
);
const calendarToKanban = viewConverts.find(
convert => convert.from === 'calendar' && convert.to === 'kanban'
);
const filter = { type: 'group', op: 'and', conditions: [] } as const;
const sort = { columns: [] };
const header = { titleColumn: 'title' };
expect(tableToCalendar?.convert({ filter, sort, header } as any)).toEqual({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: [] },
});
expect(
calendarToKanban?.convert({
filter,
sort,
card: { titleColumnId: 'title', visiblePropertyIds: ['status'] },
date: { startColumnId: 'date' },
} as any)
).toEqual({ filter, sort, header });
});
});
describe('calendar dnd payload', () => {
it('reads calendar entry payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({
bsEntity: { type: 'calendar-entry', entryId: 'database:row-1' },
})
).toEqual({ type: 'calendar-entry', entryId: 'database:row-1' });
});
it('normalizes affine doc entities for future document drops', () => {
expect(
getCalendarDndEntity({
entity: { type: 'doc', id: 'doc-1' },
})
).toEqual({ type: 'doc', docId: 'doc-1' });
});
it('reads document payloads from blocksuite dnd data', () => {
expect(
getCalendarDndEntity({ bsEntity: { type: 'doc', docId: 'doc-1' } })
).toEqual({ type: 'doc', docId: 'doc-1' });
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { compareDateKeys } from '../core/group-by/compare-date-keys.js';
describe('compareDateKeys', () => {
it('sorts relative keys ascending', () => {
const cmp = compareDateKeys('date-relative', true);
const keys = ['today', 'last7', 'yesterday', 'last30'];
const sorted = [...keys].sort(cmp);
expect(sorted).toEqual(['last30', 'last7', 'yesterday', 'today']);
});
it('sorts relative keys descending', () => {
const cmp = compareDateKeys('date-relative', false);
const keys = ['today', 'last7', 'yesterday', 'last30'];
const sorted = [...keys].sort(cmp);
expect(sorted).toEqual(['today', 'yesterday', 'last7', 'last30']);
});
it('sorts numeric keys correctly', () => {
const asc = compareDateKeys('date-day', true);
const desc = compareDateKeys('date-day', false);
const keys = ['3', '1', '2'];
expect([...keys].sort(asc)).toEqual(['1', '2', '3']);
expect([...keys].sort(desc)).toEqual(['3', '2', '1']);
});
it('handles mixed relative and numeric keys', () => {
const cmp = compareDateKeys('date-relative', true);
const keys = ['today', '1', 'yesterday', '2'];
const sorted = [...keys].sort(cmp);
expect(sorted[0]).toBe('1');
expect(sorted[sorted.length - 1]).toBe('today');
});
});

View File

@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest';
import { multiSelectPropertyType } from '../property-presets/multi-select/define.js';
import { selectPropertyType } from '../property-presets/select/define.js';
import { TableHotkeysController } from '../view-presets/table/pc/controller/hotkeys.js';
import { TableHotkeysController as VirtualHotkeysController } from '../view-presets/table/pc-virtual/controller/hotkeys.js';
import {
TableViewAreaSelection,
TableViewRowSelection,
} from '../view-presets/table/selection';
const TAG_COLUMN_TYPES = [
selectPropertyType.type,
multiSelectPropertyType.type,
] as const;
function createLogic() {
const view = {
rowsDelete: vi.fn(),
rows$: { value: [] },
groupTrait: { groupsDataList$: { value: [] } },
};
const ui = { disposables: { add: vi.fn() }, requestUpdate: vi.fn() };
const selectionController = {
selection: undefined as any,
getCellContainer: vi.fn(),
insertRowAfter: vi.fn(),
focusToCell: vi.fn(),
rowSelectionChange: vi.fn(),
areaToRows: vi.fn().mockReturnValue([]),
rowsToArea: vi.fn(),
navigateRowSelection: vi.fn(),
selectionAreaUp: vi.fn(),
selectionAreaDown: vi.fn(),
selectionAreaLeft: vi.fn(),
selectionAreaRight: vi.fn(),
isRowSelection: vi.fn().mockReturnValue(false),
};
const logic: any = {
view,
ui$: { value: ui },
selectionController,
bindHotkey: vi.fn((hotkeys: any) => {
logic.hotkeys = hotkeys;
return { dispose: vi.fn() };
}),
handleEvent: vi.fn((name: string, handler: any) => {
if (name === 'keyDown') logic.keyDown = handler;
return { dispose: vi.fn() };
}),
};
return { logic, view, ui, selectionController };
}
describe('TableHotkeysController', () => {
it('deletes rows on Backspace', () => {
const { logic, view, ui, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
selectionController.selection = TableViewRowSelection.create({
rows: [{ id: 'r1' }],
});
logic.hotkeys.Backspace();
expect(selectionController.selection).toBeUndefined();
expect(view.rowsDelete).toHaveBeenCalledWith(['r1']);
expect(ui.requestUpdate).toHaveBeenCalled();
});
it('starts editing on character key', () => {
const { logic, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column: {
valueSetFromString: vi.fn(),
type$: { value: 'text' },
},
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 0, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'A',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column.valueSetFromString).toHaveBeenCalledWith('r1', 'A');
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
it.each(TAG_COLUMN_TYPES)(
'stages draft for %s column instead of valueSetFromString',
columnType => {
const { logic, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
const setTagDraft = vi.fn();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column: {
valueSetFromString: vi.fn(),
type$: { value: columnType },
},
setTagDraft,
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 0, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'C',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column.valueSetFromString).not.toHaveBeenCalled();
expect(setTagDraft).toHaveBeenCalledWith('C');
expect(selectionController.selection.isEditing).toBe(true);
}
);
});
describe('Virtual TableHotkeysController', () => {
it('writes character to cell', () => {
const { logic, selectionController } = createLogic();
const ctrl = new VirtualHotkeysController(logic as any);
ctrl.hostConnected();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column$: {
value: {
valueSetFromString: vi.fn(),
type$: { value: 'text' },
},
},
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 1, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'b',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column$.value.valueSetFromString).toHaveBeenCalledWith(
'r1',
'b'
);
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
it.each(TAG_COLUMN_TYPES)(
'stages draft for %s column instead of valueSetFromString',
columnType => {
const { logic, selectionController } = createLogic();
const ctrl = new VirtualHotkeysController(logic as any);
ctrl.hostConnected();
const setTagDraft = vi.fn();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column$: {
value: {
valueSetFromString: vi.fn(),
type$: { value: columnType },
},
},
setTagDraft,
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 1, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'C',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column$.value.valueSetFromString).not.toHaveBeenCalled();
expect(setTagDraft).toHaveBeenCalledWith('C');
expect(selectionController.selection.isEditing).toBe(true);
}
);
});

View File

@@ -0,0 +1,806 @@
import { signal } from '@preact/signals-core';
import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import type { FilterGroup } from '../core/filter/types.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { GroupTrait, sortByManually } from '../core/group-by/trait.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import type { Row } from '../core/view-manager/row.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
import { textPropertyModelConfig } from '../property-presets/text/define.js';
import {
canGroupable,
ensureKanbanGroupColumn,
pickKanbanGroupColumn,
resolveKanbanGroupBy,
} from '../view-presets/kanban/group-by-utils.js';
import {
KanbanSingleView,
materializeKanbanColumns,
} from '../view-presets/kanban/kanban-view-manager.js';
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
type Column = {
id: string;
type: string;
data?: Record<string, unknown>;
};
type TestPropertyMeta = {
type: string;
config: {
kanbanGroup?: {
enabled: boolean;
mutable?: boolean;
};
propertyData: {
default: () => Record<string, unknown>;
};
jsonValue: {
type: (options: {
data: Record<string, unknown>;
dataSource: DataSource;
}) => unknown;
};
};
};
type MockDataSource = {
properties$: ReturnType<typeof signal<string[]>>;
provider: {
getAll: () => Map<unknown, unknown>;
};
serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown;
propertyTypeGet: (propertyId: string) => string | undefined;
propertyMetaGet: (type: string) => TestPropertyMeta | undefined;
propertyDataGet: (propertyId: string) => Record<string, unknown>;
propertyDataTypeGet: (propertyId: string) => unknown;
propertyAdd: (
_position: unknown,
ops?: {
type?: string;
}
) => string;
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => void;
};
const asDataSource = (dataSource: object): DataSource =>
dataSource as DataSource;
const toTestMeta = <TData extends Record<string, unknown>>(
type: string,
config: {
kanbanGroup?: {
enabled: boolean;
mutable?: boolean;
};
propertyData: {
default: () => TData;
};
jsonValue: {
type: (options: { data: TData; dataSource: DataSource }) => unknown;
};
}
): TestPropertyMeta => ({
type,
config: {
kanbanGroup: config.kanbanGroup,
propertyData: {
default: () => config.propertyData.default(),
},
jsonValue: {
type: ({ data, dataSource }) =>
config.jsonValue.type({
data: data as TData,
dataSource,
}),
},
},
});
const immutableBooleanMeta = toTestMeta('immutable-boolean', {
...checkboxPropertyModelConfig.config,
kanbanGroup: {
enabled: true,
mutable: false,
},
});
const createMockDataSource = (columns: Column[]): MockDataSource => {
const properties$ = signal(columns.map(column => column.id));
const typeById = new Map(columns.map(column => [column.id, column.type]));
const dataById = new Map(
columns.map(column => [column.id, column.data ?? {}])
);
const services = new Map<unknown, unknown>();
const metaEntries: Array<[string, TestPropertyMeta]> = [
[
checkboxPropertyModelConfig.type,
toTestMeta(
checkboxPropertyModelConfig.type,
checkboxPropertyModelConfig.config
),
],
[
selectPropertyModelConfig.type,
toTestMeta(
selectPropertyModelConfig.type,
selectPropertyModelConfig.config
),
],
[
multiSelectPropertyModelConfig.type,
toTestMeta(
multiSelectPropertyModelConfig.type,
multiSelectPropertyModelConfig.config
),
],
[
textPropertyModelConfig.type,
toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config),
],
[immutableBooleanMeta.type, immutableBooleanMeta],
];
const metaByType = new Map(metaEntries);
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value != null
? (value as Record<string, unknown>)
: {};
let autoColumnId = 0;
const dataSource = {
properties$,
provider: {
getAll: () => new Map<unknown, unknown>(),
},
serviceGetOrCreate: (key: unknown, create: () => unknown) => {
if (!services.has(key)) {
services.set(key, create());
}
return services.get(key);
},
propertyTypeGet: (propertyId: string) => typeById.get(propertyId),
propertyMetaGet: (type: string) => metaByType.get(type),
propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)),
propertyDataTypeGet: (propertyId: string) => {
const type = typeById.get(propertyId);
if (!type) {
return;
}
const meta = metaByType.get(type);
if (!meta) {
return;
}
return meta.config.jsonValue.type({
data: asRecord(dataById.get(propertyId)),
dataSource: asDataSource(dataSource),
});
},
propertyAdd: (
_position: unknown,
ops?: {
type?: string;
}
) => {
const type = ops?.type ?? selectPropertyModelConfig.type;
const id = `auto-${++autoColumnId}`;
const meta = metaByType.get(type);
const data = meta?.config.propertyData.default() ?? {};
typeById.set(id, type);
dataById.set(id, data);
properties$.value = [...properties$.value, id];
return id;
},
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => {
dataById.set(propertyId, data);
},
};
return dataSource;
};
const createDragController = () => {
type DragLogic = ConstructorParameters<typeof KanbanDragController>[0];
return new KanbanDragController({} as DragLogic);
};
const createTestRow = (rowId: string): Row => ({
rowId,
cells$: signal([]) as Row['cells$'],
index$: signal<Row['index$']['value']>(undefined),
prev$: signal<Row | undefined>(undefined),
next$: signal<Row | undefined>(undefined),
delete: vi.fn(),
move: vi.fn(),
});
const createGroupTraitHarness = (options?: {
groupProperties?: Array<{
key: string;
hide: boolean;
manuallyCardSort: string[];
}>;
rowIds?: string[];
values?: Record<string, boolean>;
}) => {
const dataSource = createMockDataSource([
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
const property = {
id: 'checkbox',
dataType$: signal(t.boolean.instance()),
meta$: signal({ config: {} }),
};
const groupProperties = options?.groupProperties ?? [
{
key: 'true',
hide: false,
manuallyCardSort: [],
},
{
key: 'false',
hide: false,
manuallyCardSort: [],
},
];
const rows = options?.rowIds ?? [];
const data$ = signal({
groupProperties,
});
const cellValues = new Map(
Object.entries(options?.values ?? {}).map(([rowId, value]) => [
`${rowId}:checkbox`,
value,
])
);
const cells = new Map<
string,
{
jsonValue$: ReturnType<typeof signal<boolean | undefined>>;
jsonValueSet: ReturnType<typeof vi.fn<(value: unknown) => void>>;
valueSet: ReturnType<typeof vi.fn<(value: unknown) => void>>;
}
>();
const cellGetOrCreate = (rowId: string, propertyId: string) => {
const key = `${rowId}:${propertyId}`;
const existing = cells.get(key);
if (existing) {
return existing;
}
const jsonValue$ = signal(cellValues.get(key));
const update = (value: unknown) => {
jsonValue$.value = value as boolean | undefined;
cellValues.set(key, value as boolean);
};
const cell = {
jsonValue$,
jsonValueSet: vi.fn(update),
valueSet: vi.fn(update),
};
cells.set(key, cell);
return cell;
};
const isLocked$ = signal(false);
const view = {
data$,
rows$: signal(rows.map(createTestRow)),
isLocked$,
lockRows: vi.fn((locked: boolean) => {
isLocked$.value = locked;
}),
manager: {
dataSource: asDataSource(dataSource),
},
propertyGetOrCreate: () => property,
cellGetOrCreate,
};
const groupBy$ = signal<GroupBy | undefined>({
type: 'groupBy',
columnId: 'checkbox',
name: 'boolean',
hideEmpty: false,
sort: { desc: false },
});
const ops = {
groupBySet: vi.fn(),
sortGroup: (keys: string[], asc?: boolean) => {
const sorted = sortByManually(
keys,
value => value,
data$.value.groupProperties.map(value => value.key)
);
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (groupKey: string, groupedRows: Row[]) => {
const group = data$.value.groupProperties.find(
value => value.key === groupKey
);
return sortByManually(
groupedRows,
row => row.rowId,
group?.manuallyCardSort ?? []
);
},
changeGroupSort: vi.fn(),
changeRowSort: vi.fn(),
changeGroupHide: vi.fn(),
};
return {
groupTrait: new GroupTrait(groupBy$, view as never, ops),
ops,
cells,
view,
};
};
describe('kanban', () => {
describe('group trait', () => {
it('reapplies manual card order when building grouped rows', () => {
const { groupTrait } = createGroupTraitHarness({
groupProperties: [
{
key: 'true',
hide: false,
manuallyCardSort: ['row-2', 'row-1'],
},
{
key: 'false',
hide: false,
manuallyCardSort: [],
},
],
rowIds: ['row-1', 'row-2'],
values: {
'row-1': true,
'row-2': true,
},
});
expect(
groupTrait.groupsDataList$.value
?.find(group => group.key === 'true')
?.rows.map(row => row.rowId)
).toEqual(['row-2', 'row-1']);
});
it('preserves manual group order when updating card sort', () => {
const { groupTrait, ops, cells, view } = createGroupTraitHarness({
groupProperties: [
{
key: 'false',
hide: false,
manuallyCardSort: ['row-1'],
},
{
key: 'true',
hide: false,
manuallyCardSort: ['row-2'],
},
],
rowIds: ['row-1', 'row-2'],
values: {
'row-1': false,
'row-2': true,
},
});
expect(
groupTrait.groupsDataList$.value
?.find(group => group.key === 'true')
?.rows.map(row => row.rowId)
).toEqual(['row-2']);
view.lockRows(true);
groupTrait.moveCardTo('row-1', 'false', 'true', 'end');
expect(view.lockRows).toHaveBeenLastCalledWith(false);
expect(
groupTrait.groupsDataList$.value
?.find(group => group.key === 'true')
?.rows.map(row => row.rowId)
).toEqual(['row-2', 'row-1']);
expect(ops.changeRowSort).toHaveBeenCalledWith(
['false', 'true'],
'true',
['row-2', 'row-1']
);
expect(cells.get('row-1:checkbox')?.jsonValueSet).toHaveBeenCalledWith(
true
);
});
});
describe('group-by define', () => {
it('boolean group should not include ungroup bucket', () => {
const booleanGroup = groupByMatchers.find(
group => group.name === 'boolean'
);
expect(booleanGroup).toBeDefined();
const keys = booleanGroup!
.defaultKeys(t.boolean.instance())
.map(group => group.key);
expect(keys).toEqual(['true', 'false']);
});
it('boolean group should fallback invalid values to false bucket', () => {
const booleanGroup = groupByMatchers.find(
group => group.name === 'boolean'
);
expect(booleanGroup).toBeDefined();
const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance());
expect(groups).toEqual([{ key: 'false', value: false }]);
});
});
describe('columns materialization', () => {
it('appends missing properties while preserving existing order and state', () => {
const columns = [{ id: 'status', hide: true }, { id: 'title' }];
const next = materializeKanbanColumns(columns, [
'title',
'status',
'date',
]);
expect(next).toEqual([
{ id: 'status', hide: true },
{ id: 'title' },
{ id: 'date' },
]);
});
it('drops stale columns that no longer exist in data source', () => {
const columns = [{ id: 'title' }, { id: 'removed', hide: true }];
const next = materializeKanbanColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title' }]);
});
it('returns original reference when columns are already materialized', () => {
const columns = [{ id: 'title' }, { id: 'status', hide: true }];
const next = materializeKanbanColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
});
describe('filtering', () => {
const sharedFilter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const sharedTitleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
it('evaluates filters with hidden columns', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
it('returns false when hidden filtered column does not match', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('drag indicator', () => {
it('shows drop preview when insert position exists', () => {
const controller = createDragController();
const position = {
group: {} as KanbanGroup,
position: 'end' as const,
};
controller.getInsertPosition = vi.fn().mockReturnValue(position);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
const result = controller.showIndicator({} as MouseEvent, undefined);
expect(result).toBe(position);
expect(displaySpy).toHaveBeenCalledWith(
position.group,
undefined,
undefined
);
expect(removeSpy).not.toHaveBeenCalled();
});
it('removes drop preview when insert position does not exist', () => {
const controller = createDragController();
controller.getInsertPosition = vi.fn().mockReturnValue(undefined);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
const result = controller.showIndicator({} as MouseEvent, undefined);
expect(result).toBeUndefined();
expect(displaySpy).not.toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalledOnce();
});
it('forwards hovered card to drop preview for precise insertion cursor', () => {
const controller = createDragController();
const hoveredCard = document.createElement(
'affine-data-view-kanban-card'
) as KanbanCard;
const positionCard = document.createElement(
'affine-data-view-kanban-card'
) as KanbanCard;
const position = {
group: {} as KanbanGroup,
card: positionCard,
position: { before: true, id: 'card-id' } as const,
};
controller.getInsertPosition = vi.fn().mockReturnValue(position);
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
controller.showIndicator({} as MouseEvent, hoveredCard);
expect(displaySpy).toHaveBeenCalledWith(
position.group,
hoveredCard,
position.card
);
});
});
describe('group-by utils', () => {
it('allows only kanban-enabled property types to group', () => {
const dataSource = createMockDataSource([
{ id: 'text', type: textPropertyModelConfig.type },
{ id: 'select', type: selectPropertyModelConfig.type },
{ id: 'multi-select', type: multiSelectPropertyModelConfig.type },
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
]);
expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false);
expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true);
expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true);
expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true);
});
it('prefers mutable group column over immutable ones', () => {
const dataSource = createMockDataSource([
{
id: 'immutable-bool',
type: 'immutable-boolean',
},
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox');
});
it('creates default status select column when no groupable column exists', () => {
const dataSource = createMockDataSource([
{
id: 'text',
type: textPropertyModelConfig.type,
},
]);
const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource));
expect(statusColumnId).toBeTruthy();
expect(dataSource.propertyTypeGet(statusColumnId!)).toBe(
selectPropertyModelConfig.type
);
const options =
(
dataSource.propertyDataGet(statusColumnId!) as {
options?: { value: string }[];
}
).options ?? [];
expect(options.map(option => option.value)).toEqual([
'Todo',
'In Progress',
'Done',
]);
});
it('defaults hideEmpty to true for non-option groups', () => {
const dataSource = createMockDataSource([
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource));
expect(next?.columnId).toBe('checkbox');
expect(next?.hideEmpty).toBe(true);
expect(next?.name).toBe('boolean');
});
it('defaults hideEmpty to false for select grouping', () => {
const dataSource = createMockDataSource([
{
id: 'select',
type: selectPropertyModelConfig.type,
},
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource));
expect(next?.columnId).toBe('select');
expect(next?.hideEmpty).toBe(false);
expect(next?.name).toBe('select');
});
it('preserves sort and explicit hideEmpty when resolving groupBy', () => {
const dataSource = createMockDataSource([
{
id: 'checkbox',
type: checkboxPropertyModelConfig.type,
},
]);
const current: GroupBy = {
type: 'groupBy',
columnId: 'checkbox',
name: 'boolean',
sort: { desc: true },
hideEmpty: true,
};
const next = resolveKanbanGroupBy(asDataSource(dataSource), current);
expect(next?.columnId).toBe('checkbox');
expect(next?.sort).toEqual({ desc: true });
expect(next?.hideEmpty).toBe(true);
});
it('replaces current non-groupable column with a valid kanban column', () => {
const dataSource = createMockDataSource([
{ id: 'text', type: textPropertyModelConfig.type },
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
]);
const next = resolveKanbanGroupBy(asDataSource(dataSource), {
type: 'groupBy',
columnId: 'text',
name: 'text',
});
expect(next?.columnId).toBe('checkbox');
expect(next?.name).toBe('boolean');
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -0,0 +1,247 @@
import { describe, expect, test } from 'vitest';
import type { FilterGroup } from '../core/filter/types.js';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
import {
materializeTableColumns,
TableSingleView,
} from '../view-presets/table/table-view-manager.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('table column materialization', () => {
test('appends missing properties while preserving existing order and state', () => {
const columns = [
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
];
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
expect(next).toEqual([
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
]);
});
test('drops stale columns that no longer exist in data source', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'removed', width: 200, hide: true },
];
const next = materializeTableColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title', width: 320 }]);
});
test('returns original reference when columns are already materialized', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'status', width: 240, hide: true },
];
const next = materializeTableColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
test('supports type-aware default width when materializing missing columns', () => {
const next = materializeTableColumns([], ['title', 'status'], id =>
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
);
expect(next).toEqual([
{ id: 'title', width: 260 },
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
]);
});
});
describe('table filtering', () => {
test('evaluates filters with hidden columns', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
test('returns false when hidden filtered column does not match', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -0,0 +1,66 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { unsafeCSS } from 'lit';
export const dataViewCssVariable = () => {
return `
--data-view-cell-text-size:14px;
--data-view-cell-text-line-height:22px;
`;
};
export const dataViewCommonStyle = (selector: string) => `
${selector}{
${dataViewCssVariable()}
}
.with-data-view-css-variable{
${dataViewCssVariable()}
font-family: var(--affine-font-family)
}
.dv-pd-2{
padding:2px;
}
.dv-pd-4{
padding:4px;
}
.dv-pd-8{
padding:8px;
}
.dv-hover:hover, .dv-hover.active{
background-color: var(--affine-hover-color);
cursor: pointer;
}
.dv-icon-16{
font-size: 16px;
}
.dv-icon-16 svg{
width: 16px;
height: 16px;
color: var(--affine-icon-color);
fill: var(--affine-icon-color);
}
.dv-icon-20 svg{
width: 20px;
height: 20px;
color: var(--affine-icon-color);
fill: var(--affine-icon-color);
}
.dv-border{
border: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.border)};
}
.dv-round-4{
border-radius: 4px;
}
.dv-round-8{
border-radius: 8px;
}
.dv-color-2{
color: var(--affine-text-secondary-color);
}
.dv-shadow-2{
box-shadow: var(--affine-shadow-2)
}
.dv-divider-h{
height: 1px;
background-color: var(--affine-divider-color);
margin: 8px 0;
}
`;

View File

@@ -0,0 +1,88 @@
import { css } from '@emotion/css';
import { cssVarV2 } from '@toeverything/theme/v2';
export const dataViewVars = {
cellTextSize: '--affine-data-view-cell-text-size',
cellTextLineHeight: '--affine-data-view-cell-text-line-height',
};
export const dataViewRoot = css({
fontFamily: 'var(--affine-font-family)',
[dataViewVars.cellTextSize]: '14px',
[dataViewVars.cellTextLineHeight]: '22px',
});
export const withDataViewCssVariable = css({
fontFamily: 'var(--affine-font-family)',
[dataViewVars.cellTextSize]: '14px',
[dataViewVars.cellTextLineHeight]: '22px',
});
export const p2 = css({
padding: '2px',
});
export const p4 = css({
padding: '4px',
});
export const p8 = css({
padding: '8px',
});
export const hover = css({
'&:hover, &.active': {
backgroundColor: 'var(--affine-hover-color)',
cursor: 'pointer',
},
});
export const icon16 = css({
fontSize: '16px',
color: cssVarV2.icon.primary,
});
export const icon20 = css({
fontSize: '20px',
color: cssVarV2.icon.primary,
});
export const border = css({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
});
export const round4 = css({
borderRadius: '4px',
});
export const round8 = css({
borderRadius: '8px',
});
export const color2 = css({
color: 'var(--affine-text-secondary-color)',
});
export const shadow2 = css({
boxShadow: 'var(--affine-shadow-2)',
});
export const dividerH = css({
height: '1px',
backgroundColor: 'var(--affine-divider-color)',
margin: '8px 0',
});
export const dividerV = css({
width: '1px',
backgroundColor: 'var(--affine-divider-color)',
margin: '0 8px',
});
export const dv = {
p2,
p4,
p8,
hover,
icon16,
icon20,
border,
round4,
round8,
color2,
shadow2,
dividerH,
dividerV,
};

View File

@@ -0,0 +1,8 @@
export * from '../data-source/index.js';
export * from '../detail/detail.js';
export * from '../group-by/default.js';
export * from '../group-by/matcher.js';
export type { GroupByConfig } from '../group-by/types.js';
export type { GroupRenderProps } from '../group-by/types.js';
export * from './css-variable.js';
export * from './types.js';

View File

@@ -0,0 +1,289 @@
import {
menu,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { dragHandler } from '../utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../utils/wc-dnd/sensors/index.js';
import {
createSortContext,
sortable,
} from '../utils/wc-dnd/sort/sort-context.js';
import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js';
import type { Property } from '../view-manager/property.js';
import type { SingleView } from '../view-manager/single-view.js';
export class DataViewPropertiesSettingView extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.properties-group-header {
user-select: none;
padding: 4px 12px 12px 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.border)};
}
.properties-group-title {
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
display: flex;
align-items: center;
gap: 8px;
}
.properties-group-op {
padding: 4px 8px;
font-size: 12px;
line-height: 20px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
color: ${unsafeCSS(cssVarV2.button.primary)};
}
.properties-group-op:hover {
background-color: var(--affine-hover-color);
}
.properties-group {
min-height: 40px;
}
.property-item {
padding: 4px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
cursor: pointer;
border-radius: 4px;
}
.property-item-drag-bar {
width: 4px;
height: 12px;
border-radius: 1px;
background-color: #efeff0;
}
.property-item:hover .property-item-drag-bar {
background-color: #c0bfc1;
}
.property-item-icon {
display: flex;
align-items: center;
}
.property-item-icon svg {
color: var(--affine-icon-color);
fill: var(--affine-icon-color);
width: 20px;
height: 20px;
}
.property-item-op-icon {
display: flex;
align-items: center;
border-radius: 4px;
}
.property-item-op-icon:hover {
background-color: var(--affine-hover-color);
}
.property-item-op-icon.disabled:hover {
background-color: transparent;
}
.property-item-op-icon svg {
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
width: 20px;
height: 20px;
}
.property-item-op-icon.disabled svg {
fill: var(--affine-text-disable-color);
color: var(--affine-text-disable-color);
}
.property-item-name {
font-size: 14px;
line-height: 22px;
flex: 1;
}
`;
@property({ attribute: false })
accessor view!: SingleView;
items$ = computed(() => {
return this.view.propertiesRaw$.value.map(property => property.id);
});
renderProperty = (property: Property) => {
const icon = property.hide$.value ? InvisibleIcon() : ViewIcon();
const changeVisible = () => {
if (property.hideCanSet) {
property.hideSet(!property.hide$.value);
}
};
const classList = classMap({
'property-item-op-icon': true,
disabled: !property.hideCanSet,
});
return html` <div
${dragHandler(property.id)}
${sortable(property.id)}
class="property-item"
>
<div class="property-item-drag-bar"></div>
<uni-lit class="property-item-icon" .uni="${property.icon}"></uni-lit>
<div class="property-item-name">${property.name$.value}</div>
<div class="${classList}" @click="${changeVisible}">${icon}</div>
</div>`;
};
sortContext = createSortContext({
activators: defaultActivators,
container: this,
onDragEnd: evt => {
const over = evt.over;
const activeId = evt.active.id;
if (over && over.id !== activeId) {
const properties = this.items$.value;
const activeIndex = properties.findIndex(id => id === activeId);
const overIndex = properties.findIndex(id => id === over.id);
this.view.propertyGetOrCreate(activeId).move(
activeIndex > overIndex
? {
before: true,
id: over.id,
}
: {
before: false,
id: over.id,
}
);
}
},
modifiers: [
({ transform }) => {
return {
...transform,
x: 0,
};
},
],
items: this.items$,
strategy: verticalListSortingStrategy,
});
private itemsGroup() {
return this.view.propertiesRaw$.value;
}
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(this, 'pointerdown', e => {
e.stopPropagation();
});
}
override render() {
const items = this.itemsGroup();
return html`
<div class="properties-group">
${repeat(items, v => v.id, this.renderProperty)}
</div>
`;
}
@query('.properties-group')
accessor groupContainer!: HTMLElement;
@property({ attribute: false })
accessor onBack: (() => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-properties-setting': DataViewPropertiesSettingView;
}
}
export const popPropertiesSetting = (
target: PopupTarget,
props: {
view: SingleView;
onClose?: () => void;
onBack?: () => void;
},
middleware?: Array<Middleware | null | undefined | false>
) => {
const handler = popMenu(target, {
middleware,
options: {
title: {
text: 'Properties',
onBack: props.onBack,
onClose: props.onClose,
postfix: () => {
const items = props.view.propertiesRaw$.value;
const isAllShowed = items.every(property => !property.hide$.value);
const clickChangeAll = () => {
items.forEach(property => {
if (property.hideCanSet) {
property.hideSet(isAllShowed);
}
});
};
return html` <div
class="properties-group-op"
@click="${clickChangeAll}"
>
${isAllShowed ? 'Hide All' : 'Show All'}
</div>`;
},
},
items: [
menu.group({
items: [
() =>
html` <data-view-properties-setting
.view="${props.view}"
></data-view-properties-setting>`,
],
}),
],
onClose: props.onClose,
},
});
handler.menu.menuElement.style.minHeight = '550px';
// const view = new DataViewPropertiesSettingView();
// view.view = props.view;
// view.onBack = () => {
// close();
// props.onBack?.();
// };
// const close = createPopup(target, view, { onClose: props.onClose });
};

View File

@@ -0,0 +1,63 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { html } from 'lit/static-html.js';
import { renderUniLit } from '../utils/uni-component/index.js';
import type { Property } from '../view-manager/property.js';
export const inputConfig = (property: Property) => {
return menu.input({
prefix: html`
<div class="affine-database-column-type-menu-icon">
${renderUniLit(property.icon)}
</div>
`,
initialValue: property.name$.value,
placeholder: 'Property name',
onBlur: text => {
property.nameSet(text);
},
});
};
export const typeConfig = (property: Property) => {
return menu.group({
items: [
menu.subMenu({
name: 'Type',
hide: () => !property.typeCanSet,
postfix: html` <div
class="affine-database-column-type-icon"
style="color: var(--affine-text-secondary-color);gap:4px;font-size: 14px;"
>
${renderUniLit(property.icon)}
${
property.view.propertyMetas$.value.find(
v => v.type === property.type$.value
)?.config.name
}
</div>`,
options: {
title: {
text: 'Property type',
},
items: [
menu.group({
items: property.view.propertyMetas$.value.map(config => {
return menu.action({
isSelected: config.type === property.type$.value,
name: config.config.name,
prefix: renderUniLit(config.renderer.icon),
select: () => {
if (property.type$.value === config.type) {
return;
}
property.typeSet?.(config.type);
},
});
}),
}),
],
},
}),
],
});
};

View File

@@ -0,0 +1,14 @@
export type GroupBy = {
type: 'groupBy';
columnId: string;
name: string;
hideEmpty?: boolean;
sort?: {
desc: boolean;
};
};
export type GroupProperty = {
key: string;
hide?: boolean;
manuallyCardSort: string[];
};

View File

@@ -0,0 +1,89 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
export class Button extends SignalWatcher(WithDisposable(ShadowlessElement)) {
static override styles = css`
data-view-component-button {
border-radius: 4px;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
display: flex;
padding: 4px 8px;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: ${unsafeCSSVarV2('text/primary')};
cursor: pointer;
transition:
color 0.2s,
background-color 0.2s,
border-color 0.2s;
white-space: nowrap;
}
data-view-component-button.border:hover,
data-view-component-button.border.active {
color: ${unsafeCSSVarV2('text/emphasis')};
border-color: ${unsafeCSSVarV2('icon/activated')};
}
data-view-component-button.background:hover,
data-view-component-button.background.active {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.button-icon {
font-size: 16px;
display: flex;
align-items: center;
transition: color 0.2s;
color: ${unsafeCSSVarV2('icon/primary')};
}
data-view-component-button.border:hover .button-icon,
data-view-component-button.border.active .button-icon {
color: ${unsafeCSSVarV2('icon/activated')};
}
`;
override connectedCallback() {
super.connectedCallback();
this.classList.add(this.hoverType);
if (this.onClick) {
this.disposables.addFromEvent(this, 'click', this.onClick);
}
}
override render() {
return html`
<div class="button-icon">${this.icon}</div>
${this.text}
<div class="button-icon">${this.postfix}</div>
`;
}
@property()
accessor hoverType: 'background' | 'border' = 'background';
@property({ attribute: false })
accessor icon: TemplateResult | undefined;
@property({ attribute: false })
accessor onClick: ((event: MouseEvent) => void) | undefined;
@property({ attribute: false })
accessor postfix: TemplateResult | string | undefined;
@property({ attribute: false })
accessor text: TemplateResult | string | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-component-button': Button;
}
}

View File

@@ -0,0 +1,3 @@
export * from './button/button.js';
export * from './overflow/overflow.js';
export * from './tags/index.js';

View File

@@ -0,0 +1,109 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html, type PropertyValues, type TemplateResult } from 'lit';
import { property, query, queryAll, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
export class Overflow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
static override styles = css`
component-overflow {
display: flex;
flex-wrap: wrap;
width: 100%;
position: relative;
}
.component-overflow-item {
}
.component-overflow-item.hidden {
opacity: 0;
pointer-events: none;
position: absolute;
}
`;
protected frameId: number | undefined = undefined;
protected widthList: number[] = [];
adjustStyle() {
if (this.frameId) {
cancelAnimationFrame(this.frameId);
}
this.frameId = requestAnimationFrame(() => {
this.doAdjustStyle();
});
}
override connectedCallback() {
super.connectedCallback();
const resize = new ResizeObserver(() => {
this.adjustStyle();
});
resize.observe(this);
this.disposables.add(() => {
resize.unobserve(this);
});
}
protected doAdjustStyle() {
const moreWidth = this.more.getBoundingClientRect().width;
this.widthList[this.renderCount] = moreWidth;
const containerWidth = this.getBoundingClientRect().width;
let width = 0;
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (!item) continue;
const itemWidth = item.getBoundingClientRect().width;
// Try to calculate the width occupied by rendering n+1 items;
// if it exceeds the limit, render n items(in i++ round).
const totalWidth =
width + itemWidth + (this.widthList[i + 1] ?? moreWidth);
if (totalWidth > containerWidth) {
this.renderCount = i;
return;
}
width += itemWidth;
}
this.renderCount = this.items.length;
}
override render() {
return html`
${repeat(this.renderItem, (render, index) => {
const className = classMap({
'component-overflow-item': true,
hidden: index >= this.renderCount,
});
return html`<div class="${className}">${render()}</div>`;
})}
<div class="component-overflow-more">
${this.renderMore(this.renderCount)}
</div>
`;
}
protected override updated(_changedProperties: PropertyValues) {
super.updated(_changedProperties);
this.adjustStyle();
}
@queryAll(':scope > .component-overflow-item')
accessor items!: HTMLDivElement[] & NodeList;
@query(':scope > .component-overflow-more')
accessor more!: HTMLDivElement;
@state()
accessor renderCount = 0;
@property({ attribute: false })
accessor renderItem!: Array<() => TemplateResult>;
@property({ attribute: false })
accessor renderMore!: (count: number) => TemplateResult;
}

View File

@@ -0,0 +1,88 @@
import { cssVarV2 } from '@toeverything/theme/v2';
export type SelectOptionColor = {
oldColor: string;
color: string;
name: string;
};
export const selectOptionColors: SelectOptionColor[] = [
{
oldColor: 'var(--affine-tag-red)',
color: cssVarV2('chip/label/red'),
name: 'Red',
},
{
oldColor: 'var(--affine-tag-pink)',
color: cssVarV2('chip/label/magenta'),
name: 'Magenta',
},
{
oldColor: 'var(--affine-tag-orange)',
color: cssVarV2('chip/label/orange'),
name: 'Orange',
},
{
oldColor: 'var(--affine-tag-yellow)',
color: cssVarV2('chip/label/yellow'),
name: 'Yellow',
},
{
oldColor: 'var(--affine-tag-green)',
color: cssVarV2('chip/label/green'),
name: 'Green',
},
{
oldColor: 'var(--affine-tag-teal)',
color: cssVarV2('chip/label/teal'),
name: 'Teal',
},
{
oldColor: 'var(--affine-tag-blue)',
color: cssVarV2('chip/label/blue'),
name: 'Blue',
},
{
oldColor: 'var(--affine-tag-purple)',
color: cssVarV2('chip/label/purple'),
name: 'Purple',
},
{
oldColor: 'var(--affine-tag-gray)',
color: cssVarV2('chip/label/grey'),
name: 'Grey',
},
{
oldColor: 'var(--affine-tag-white)',
color: cssVarV2('chip/label/white'),
name: 'White',
},
];
const oldColorMap = Object.fromEntries(
selectOptionColors.map(tag => [tag.oldColor, tag.color])
);
export const getColorByColor = (color: string) => {
if (color.startsWith('--affine-tag')) {
return oldColorMap[color] ?? color;
}
return color;
};
/** select tag color poll */
const selectTagColorPoll = selectOptionColors.map(color => color.color);
function tagColorHelper() {
let colors = [...selectTagColorPoll];
return (): string => {
if (colors.length === 0) {
colors = [...selectTagColorPoll];
}
const index = Math.floor(Math.random() * colors.length);
const color = colors.splice(index, 1)[0];
if (!color) return '';
return color;
};
}
export const getTagColor = tagColorHelper();

View File

@@ -0,0 +1,3 @@
export * from './colors.js';
export * from './multi-tag-select.js';
export * from './multi-tag-view.js';

View File

@@ -0,0 +1,666 @@
import {
createPopup,
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { rangeWrap } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
CloseIcon,
DeleteIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { nanoid } from '@blocksuite/store';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { SelectTag } from '../../logical/index.js';
import { stopPropagation } from '../../utils/event.js';
import { dragHandler } from '../../utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../../utils/wc-dnd/sensors/index.js';
import {
createSortContext,
sortable,
} from '../../utils/wc-dnd/sort/sort-context.js';
import { verticalListSortingStrategy } from '../../utils/wc-dnd/sort/strategies/index.js';
import { arrayMove } from '../../utils/wc-dnd/utils/array-move.js';
import { getTagColor, selectOptionColors } from './colors.js';
import {
selectedStyle,
selectOptionContentStyle,
selectOptionDragHandlerStyle,
selectOptionIconStyle,
selectOptionNewIconStyle,
selectOptionsContainerStyle,
selectOptionsTipsStyle,
selectOptionStyle,
tagContainerStyle,
tagDeleteIconStyle,
tagSelectContainerStyle,
tagSelectInputContainerStyle,
tagSelectInputStyle,
tagTextStyle,
} from './styles-css.js';
type RenderOption = {
value: string;
id: string;
color: string;
isCreate: boolean;
select: () => void;
};
export type TagManagerOptions = {
mode?: 'single' | 'multi';
value: ReadonlySignal<string[]>;
onChange: (value: string[]) => void;
options: ReadonlySignal<SelectTag[]>;
onOptionsChange: (options: SelectTag[]) => void;
onComplete?: () => void;
initialDraftText?: string;
};
// parent elements that can consume tag draft
const TABLE_CELL_HOST_SELECTOR =
'dv-table-view-cell-container, affine-database-virtual-cell-container';
export function consumeTagDraftFromTableCellHost(
fromElement: Element
): string | undefined {
const host = fromElement.closest(TABLE_CELL_HOST_SELECTOR) as any;
return host?.consumeTagDraft?.();
}
class TagManager {
changeTag = (option: Partial<SelectTag>) => {
this.ops.onOptionsChange(
this.ops.options.value.map(item => {
if (item.id === option.id) {
return {
...item,
...option,
};
}
return item;
})
);
};
color$ = signal(getTagColor());
createOption = () => {
const value = this.text$.value.trim();
if (value === '') return;
const id = nanoid();
this.ops.onOptionsChange([
{
id: id,
value: value,
color: this.color$.value,
},
...this.ops.options.value,
]);
this.selectTag(id);
this.text$.value = '';
this.color$.value = getTagColor();
if (this.isSingleMode) {
this.ops.onComplete?.();
}
};
deleteOption = (id: string) => {
this.ops.onOptionsChange(
this.ops.options.value.filter(item => item.id !== id)
);
};
filteredOptions$ = computed(() => {
let matched = false;
const options: RenderOption[] = [];
for (const option of this.options$.value) {
if (
!this.text$.value ||
option.value
.toLocaleLowerCase()
.includes(this.text$.value.toLocaleLowerCase())
) {
options.push({
...option,
isCreate: false,
select: () => this.selectTag(option.id),
});
}
if (option.value === this.text$.value) {
matched = true;
}
}
if (this.text$.value && !matched) {
options.push({
id: 'create',
color: this.color$.value,
value: this.text$.value,
isCreate: true,
select: this.createOption,
});
}
return options;
});
optionsMap$ = computed(() => {
return new Map<string, SelectTag>(
this.ops.options.value.map(v => [v.id, v])
);
});
text$ = signal('');
get isSingleMode() {
return this.ops.mode === 'single';
}
get options$() {
return this.ops.options;
}
get value$() {
return this.ops.value;
}
constructor(private readonly ops: TagManagerOptions) {}
deleteTag(id: string) {
this.ops.onChange(this.value$.value.filter(item => item !== id));
}
isSelected(id: string) {
return this.value$.value.includes(id);
}
selectTag(id: string) {
if (!this.isSingleMode && this.isSelected(id)) {
return;
}
const newValue = this.isSingleMode ? [id] : [...this.value$.value, id];
this.ops.onChange(newValue);
this.text$.value = '';
if (this.isSingleMode) {
requestAnimationFrame(() => {
this.ops.onComplete?.();
});
}
}
}
export class MultiTagSelect extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private readonly _clickItemOption = (e: MouseEvent, id: string) => {
e.stopPropagation();
const option = this.options.value.find(v => v.id === id);
if (!option) {
return;
}
popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), {
options: {
items: [
menu.input({
initialValue: option.value,
onChange: text => {
this.tagManager.changeTag({
id: option.id,
value: text,
});
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
class: {
'delete-item': true,
},
select: () => {
this.tagManager.deleteOption(id);
},
}),
menu.group({
name: 'color',
items: selectOptionColors.map(item => {
const styles = styleMap({
backgroundColor: item.color,
borderRadius: '50%',
width: '20px',
height: '20px',
});
return menu.action({
name: item.name,
prefix: html` <div style=${styles}></div>`,
isSelected: option.color === item.color,
select: () => {
this.tagManager.changeTag({
id: option.id,
color: item.color,
});
},
});
}),
}),
],
},
});
};
private readonly _onInput = (event: KeyboardEvent) => {
this.tagManager.text$.value = (event.target as HTMLInputElement).value;
};
private readonly _onInputKeydown = (event: KeyboardEvent) => {
event.stopPropagation();
const inputValue = this.text.value.trim();
if (event.key === 'Backspace' && inputValue === '') {
const lastId = this.value.value[this.value.value.length - 1];
if (lastId) {
this.tagManager.deleteTag(lastId);
}
} else if (event.key === 'Enter' && !event.isComposing) {
this.selectedTag$.value?.select();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.setSelectedOption(this.selectedIndex$.value - 1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.setSelectedOption(this.selectedIndex$.value + 1);
} else if (event.key === 'Escape') {
this.onComplete();
}
};
private readonly tagManager = new TagManager(this);
private readonly selectedTag$ = computed(() => {
return this.tagManager.filteredOptions$.value[this.selectedIndex$.value];
});
sortContext = createSortContext({
activators: defaultActivators,
container: this,
onDragEnd: evt => {
const over = evt.over;
const activeId = evt.active.id;
if (over && over.id !== activeId) {
this.onOptionsChange(
arrayMove(
this.options.value,
this.options.value.findIndex(v => v.id === activeId),
this.options.value.findIndex(v => v.id === over.id)
)
);
this.requestUpdate();
// const properties = this.filteredOptions$.value.map(v=>v.id);
// const activeIndex = properties.findIndex(id => id === activeId);
// const overIndex = properties.findIndex(id => id === over.id);
}
},
modifiers: [
({ transform }) => {
return {
...transform,
x: 0,
};
},
],
items: computed(() => {
return this.tagManager.filteredOptions$.value.map(v => v.id);
}),
strategy: verticalListSortingStrategy,
});
private get text() {
return this.tagManager.text$;
}
private renderInput() {
return html`
<div class="${tagSelectInputContainerStyle}">
${this.value.value.map(id => {
const option = this.tagManager.optionsMap$.value.get(id);
if (!option) {
return null;
}
return this.renderTag(option.value, option.color, () =>
this.tagManager.deleteTag(id)
);
})}
<input
class="${tagSelectInputStyle}"
${ref(this._selectInput)}
placeholder="Type here..."
.value="${this.text.value}"
@input="${this._onInput}"
@keydown="${this._onInputKeydown}"
@pointerdown="${stopPropagation}"
/>
</div>
`;
}
private renderTag(name: string, color: string, onDelete?: () => void) {
const style = styleMap({
backgroundColor: color,
});
return html` <div class="${tagContainerStyle}" style=${style}>
<div data-testid="tag-name" class="${tagTextStyle}">${name}</div>
${
onDelete
? html` <div class="${tagDeleteIconStyle}" @click="${onDelete}">
${CloseIcon()}
</div>`
: nothing
}
</div>`;
}
private renderTags() {
return html`
<div
style="height: 0.5px;background-color: ${cssVarV2(
'layer/insideBorder/border'
)};margin: 4px 0;"
></div>
<div class="${selectOptionsTipsStyle}">Select tag or create one</div>
<div data-testid="tag-option-list" class="${selectOptionsContainerStyle}">
${repeat(
this.tagManager.filteredOptions$.value,
select => select.id,
(select, index) => {
const isSelected = index === this.selectedIndex$.value;
const mouseenter = () => {
this.setSelectedOption(index);
};
const classes = classMap({
[selectOptionStyle]: true,
[selectedStyle]: isSelected,
});
const clickOption = (e: MouseEvent) => {
e.stopPropagation();
this._clickItemOption(e, select.id);
};
return html`
<div
${!select.isCreate ? sortable(select.id) : nothing}
class="${classes}"
@mouseenter="${mouseenter}"
@click="${select.select}"
>
<div class="${selectOptionContentStyle}">
${
select.isCreate
? html` <div class="${selectOptionNewIconStyle}">
Create
</div>`
: html`
<div
${dragHandler(select.id)}
class="${selectOptionDragHandlerStyle}"
></div>
`
}
${this.renderTag(select.value, select.color)}
</div>
${
!select.isCreate
? html` <div
class="${selectOptionIconStyle}"
@click="${clickOption}"
data-testid="option-more"
>
${MoreHorizontalIcon()}
</div>`
: null
}
</div>
`;
}
)}
</div>
`;
}
private setSelectedOption(index: number) {
this.selectedIndex$.value = rangeWrap(
index,
0,
this.tagManager.filteredOptions$.value.length
);
}
override connectedCallback() {
super.connectedCallback();
const draft = this.initialDraftText;
if (draft != null && draft !== '') {
this.tagManager.text$.value = draft;
this.initialDraftText = undefined;
}
}
protected override firstUpdated() {
const disposables = this.disposables;
this.classList.add(tagSelectContainerStyle);
requestAnimationFrame(() => {
this._selectInput.value?.focus();
});
disposables.addFromEvent(this, 'click', () => {
this._selectInput.value?.focus();
});
disposables.addFromEvent(this._selectInput.value, 'copy', e => {
e.stopPropagation();
});
disposables.addFromEvent(this._selectInput.value, 'cut', e => {
e.stopPropagation();
});
}
override render() {
this.setSelectedOption(this.selectedIndex$.value);
return html` ${this.renderInput()} ${this.renderTags()} `;
}
private readonly _selectInput = createRef<HTMLInputElement>();
@property()
accessor mode: 'multi' | 'single' = 'multi';
@property({ attribute: false })
accessor onChange!: (value: string[]) => void;
@property({ attribute: false })
accessor onComplete!: () => void;
@property({ attribute: false })
accessor onOptionsChange!: (options: SelectTag[]) => void;
@property({ attribute: false })
accessor options!: ReadonlySignal<SelectTag[]>;
private readonly selectedIndex$ = signal(0);
@property({ attribute: false })
accessor value!: ReadonlySignal<string[]>;
@property({ attribute: false })
accessor initialDraftText: string | undefined;
}
declare global {
interface HTMLElementTagNameMap {
'affine-multi-tag-select': MultiTagSelect;
}
}
const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
const tagManager = new TagManager(ops);
if (ops.initialDraftText) {
tagManager.text$.value = ops.initialDraftText;
}
const onInput = (e: InputEvent) => {
tagManager.text$.value = (e.target as HTMLInputElement).value;
};
const onKeydown = (e: KeyboardEvent) => {
e.stopPropagation();
const inputValue = (e.target as HTMLInputElement).value.trim();
if (e.key === 'Backspace' && inputValue === '') {
const values = tagManager.value$.value;
const lastId = values[values.length - 1];
if (lastId) {
e.preventDefault();
tagManager.deleteTag(lastId);
}
}
};
return popMenu(target, {
options: {
onClose: () => {
ops.onComplete?.();
},
title: {
text: ops.name,
},
items: [
() => {
return html`
<div
style="padding: 12px;border-radius: 12px;background-color: ${unsafeCSSVarV2(
'layer/background/primary'
)};display: flex;gap:8px 12px;"
>
${ops.value.value.map(id => {
const option = ops.options.value.find(v => v.id === id);
if (!option) {
return null;
}
const style = styleMap({
backgroundColor: option.color,
width: 'max-content',
});
return html` <div class="${tagContainerStyle}" style=${style}>
<div class="${tagTextStyle}">${option.value}</div>
<div
class="${tagDeleteIconStyle}"
@click="${(e: MouseEvent) => {
e.stopPropagation();
tagManager.deleteTag(id);
}}"
>
${CloseIcon()}
</div>
</div>`;
})}
<input
.value="${tagManager.text$.value}"
@input="${onInput}"
@keydown="${onKeydown}"
placeholder="Type here..."
type="text"
style="outline: none;border: none;flex:1;min-width: 10px"
/>
</div>
`;
},
menu.group({
items: [
menu.dynamic(() => {
const options = tagManager.filteredOptions$.value;
return options.map(option =>
menu.action({
name: option.value,
label: () => {
const style = styleMap({
backgroundColor: option.color,
width: 'max-content',
});
return html`
<div style="display: flex; align-items:center;">
${
option.isCreate
? html` <div style="margin-right: 8px;">
Create
</div>`
: ''
}
<div class="${tagContainerStyle}" style=${style}>
<div class="${tagTextStyle}">${option.value}</div>
</div>
</div>
`;
},
select: () => {
option.select();
return false;
},
})
);
}),
],
}),
],
},
});
};
export type TagSelectOptions = {
name: string;
minWidth?: number;
container?: HTMLElement;
} & TagManagerOptions;
export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
if (IS_MOBILE) {
const handler = popMobileTagSelect(target, ops);
return () => {
handler.close();
};
}
const component = new MultiTagSelect();
if (ops.mode) {
component.mode = ops.mode;
}
const width = target.targetRect.getBoundingClientRect().width;
component.style.width = `${Math.max(ops.minWidth ?? width, width)}px`;
component.value = ops.value;
component.onChange = ops.onChange;
component.options = ops.options;
component.onOptionsChange = ops.onOptionsChange;
component.initialDraftText = ops.initialDraftText;
component.onComplete = () => {
ops.onComplete?.();
remove();
};
const remove = createPopup(target, component, {
onClose: ops.onComplete,
middleware: [
autoPlacement({
allowedPlacements: [
'bottom-start',
'bottom-end',
'top-start',
'top-end',
],
}),
offset({ mainAxis: -36 }),
shift(),
],
container: ops.container,
});
return remove;
};

View File

@@ -0,0 +1,88 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { SelectTag } from '../../logical/index.js';
import { getColorByColor } from './colors.js';
export class MultiTagView extends WithDisposable(ShadowlessElement) {
static override styles = css`
affine-multi-tag-view {
display: flex;
align-items: center;
width: 100%;
height: 100%;
min-height: 22px;
}
.affine-select-cell-container * {
box-sizing: border-box;
}
.affine-select-cell-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
width: 100%;
font-size: var(--affine-font-sm);
}
.affine-select-cell-container .select-selected {
height: 22px;
font-size: 14px;
line-height: 20px;
padding: 0 8px;
border-radius: 4px;
white-space: nowrap;
background: var(--affine-tag-white);
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid ${unsafeCSSVarV2('database/border')};
}
`;
override render() {
const values = this.value;
const map = new Map<string, SelectTag>(this.options?.map(v => [v.id, v]));
return html`
<div contenteditable="false" class="affine-select-cell-container">
${repeat(values, id => {
const option = map.get(id);
if (!option) {
return;
}
const style = styleMap({
backgroundColor: getColorByColor(option.color),
});
return html`<span
data-testid="tag-selected"
class="select-selected"
style=${style}
>${option.value}</span
>`;
})}
</div>
`;
}
@property({ attribute: false })
accessor options: SelectTag[] = [];
@query('.affine-select-cell-container')
accessor selectContainer!: HTMLElement;
@property({ attribute: false })
accessor value: string[] = [];
}
declare global {
interface HTMLElementTagNameMap {
'affine-multi-tag-view': MultiTagView;
}
}

View File

@@ -0,0 +1,143 @@
import { css } from '@emotion/css';
import { baseTheme } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
export const tagSelectContainerStyle = css({
position: 'absolute',
zIndex: 2,
color: cssVarV2('text/primary'),
border: `0.5px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
borderRadius: '8px',
backgroundColor: cssVarV2.layer.background.overlayPanel,
boxShadow: 'var(--affine-shadow-1)',
fontFamily: 'var(--affine-font-family)',
maxWidth: '400px',
padding: '8px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
'@media print': {
display: 'none',
},
});
export const tagSelectInputContainerStyle = css({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '6px',
padding: '4px',
});
export const tagSelectInputStyle = css({
flex: '1 1 0',
border: 'none',
fontFamily: baseTheme.fontSansFamily,
color: cssVarV2('text/primary'),
backgroundColor: 'transparent',
lineHeight: '22px',
fontSize: '14px',
outline: 'none',
'::placeholder': {
color: 'var(--affine-placeholder-color)',
},
});
export const selectOptionsTipsStyle = css({
padding: '4px',
color: cssVarV2('text/secondary'),
fontSize: '14px',
fontWeight: 500,
lineHeight: '22px',
userSelect: 'none',
});
export const selectOptionsContainerStyle = css({
maxHeight: '400px',
overflowY: 'auto',
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
gap: '4px',
});
export const selectOptionStyle = css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px 4px 4px 0',
borderRadius: '4px',
cursor: 'pointer',
});
export const selectedStyle = css({
background: cssVarV2('layer/background/hoverOverlay'),
});
export const tagContainerStyle = css({
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: '4px',
borderRadius: '4px',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
border: `1px solid ${cssVarV2('database/border')}`,
userSelect: 'none',
});
export const tagTextStyle = css({
fontSize: '14px',
lineHeight: '22px',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const tagDeleteIconStyle = css({
display: 'flex',
alignItems: 'center',
color: cssVarV2('chip/label/text'),
});
export const selectOptionContentStyle = css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
});
export const selectOptionIconStyle = css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '20px',
borderRadius: '4px',
cursor: 'pointer',
visibility: 'hidden',
color: cssVarV2('icon/primary'),
marginLeft: '4px',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
[`.${selectedStyle} &`]: {
visibility: 'visible',
},
});
export const selectOptionDragHandlerStyle = css({
width: '4px',
height: '12px',
borderRadius: '1px',
backgroundColor: cssVarV2('button/grabber/default'),
marginRight: '4px',
cursor: '-webkit-grab',
flexShrink: 0,
});
export const selectOptionNewIconStyle = css({
fontSize: '14px',
lineHeight: '22px',
color: cssVarV2('text/primary'),
marginRight: '8px',
marginLeft: '4px',
});

View File

@@ -0,0 +1,305 @@
import type { ColumnDataType } from '@blocksuite/affine-model';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import {
Container,
createScope,
type GeneralServiceIdentifier,
type ServiceProvider,
} from '@blocksuite/global/di';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { TypeInstance } from '../logical/type.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
import type { DatabaseFlags } from '../types.js';
import type { ViewConvertConfig } from '../view/convert.js';
import type { DataViewDataType, ViewMeta } from '../view/data-view.js';
import type { ViewManager } from '../view-manager/view-manager.js';
export interface DataSource {
readonly$: ReadonlySignal<boolean>;
properties$: ReadonlySignal<string[]>;
featureFlags$: ReadonlySignal<DatabaseFlags>;
cellValueGet(rowId: string, propertyId: string): unknown;
cellValueGet$(
rowId: string,
propertyId: string
): ReadonlySignal<unknown | undefined>;
cellValueChange(rowId: string, propertyId: string, value: unknown): void;
rows$: ReadonlySignal<string[]>;
rowAdd(InsertToPosition: InsertToPosition | number): string;
rowDelete(ids: string[]): void;
rowMove(rowId: string, position: InsertToPosition): void;
propertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
allPropertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
propertyNameGet$(propertyId: string): ReadonlySignal<string | undefined>;
propertyNameGet(propertyId: string): string;
propertyNameSet(propertyId: string, name: string): void;
propertyTypeGet(propertyId: string): string | undefined;
propertyTypeGet$(propertyId: string): ReadonlySignal<string | undefined>;
propertyTypeSet(propertyId: string, type: string): void;
propertyTypeCanSet(propertyId: string): boolean;
propertyDataGet(propertyId: string): Record<string, unknown>;
propertyDataGet$(
propertyId: string
): ReadonlySignal<Record<string, unknown> | undefined>;
propertyDataSet(propertyId: string, data: Record<string, unknown>): void;
propertyDataTypeGet(propertyId: string): TypeInstance | undefined;
propertyDataTypeGet$(
propertyId: string
): ReadonlySignal<TypeInstance | undefined>;
propertyReadonlyGet(propertyId: string): boolean;
propertyReadonlyGet$(propertyId: string): ReadonlySignal<boolean>;
propertyMetaGet(type: string): PropertyMetaConfig | undefined;
propertyAdd(
insertToPosition: InsertToPosition,
ops?: {
type?: string;
name?: string;
}
): string | undefined;
propertyDuplicate(propertyId: string): string | undefined;
propertyCanDuplicate(propertyId: string): boolean;
propertyDelete(id: string): void;
propertyCanDelete(propertyId: string): boolean;
provider: ServiceProvider;
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null;
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T;
viewConverts: ViewConvertConfig[];
viewManager: ViewManager;
viewMetas: ViewMeta[];
viewDataList$: ReadonlySignal<DataViewDataType[]>;
viewDataGet(viewId: string): DataViewDataType | undefined;
viewDataGet$(viewId: string): ReadonlySignal<DataViewDataType | undefined>;
viewDataAdd(viewData: DataViewDataType): string;
viewDataDuplicate(id: string): string;
viewDataDelete(viewId: string): void;
viewDataMoveTo(id: string, position: InsertToPosition): void;
viewDataUpdate<ViewData extends DataViewDataType>(
id: string,
updater: (data: ViewData) => Partial<ViewData>
): void;
viewMetaGet(type: string): ViewMeta;
viewMetaGet$(type: string): ReadonlySignal<ViewMeta | undefined>;
viewMetaGetById(viewId: string): ViewMeta | undefined;
viewMetaGetById$(viewId: string): ReadonlySignal<ViewMeta | undefined>;
}
export const DataSourceScope = createScope('data-source');
export abstract class DataSourceBase implements DataSource {
propertyTypeCanSet(propertyId: string): boolean {
return !this.isFixedProperty(propertyId);
}
propertyCanDuplicate(propertyId: string): boolean {
return !this.isFixedProperty(propertyId);
}
propertyCanDelete(propertyId: string): boolean {
return !this.isFixedProperty(propertyId);
}
protected container = new Container();
abstract get parentProvider(): ServiceProvider;
abstract featureFlags$: ReadonlySignal<DatabaseFlags>;
abstract properties$: ReadonlySignal<string[]>;
abstract propertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
abstract allPropertyMetas$: ReadonlySignal<PropertyMetaConfig[]>;
abstract readonly$: ReadonlySignal<boolean>;
abstract rows$: ReadonlySignal<string[]>;
abstract viewConverts: ViewConvertConfig[];
abstract viewDataList$: ReadonlySignal<DataViewDataType[]>;
abstract viewManager: ViewManager;
abstract viewMetas: ViewMeta[];
abstract cellValueChange(
rowId: string,
propertyId: string,
value: unknown
): void;
abstract cellValueChange(
rowId: string,
propertyId: string,
value: unknown
): void;
abstract cellValueGet(rowId: string, propertyId: string): unknown;
cellValueGet$(
rowId: string,
propertyId: string
): ReadonlySignal<unknown | undefined> {
return computed(() => this.cellValueGet(rowId, propertyId));
}
get provider() {
return this.container.provider(DataSourceScope, this.parentProvider);
}
serviceGet<T>(key: GeneralServiceIdentifier<T>): T | null {
return this.provider.getOptional(key);
}
serviceSet<T>(key: GeneralServiceIdentifier<T>, value: T): void {
this.container.addValue(key, value, { scope: DataSourceScope });
}
serviceGetOrCreate<T>(key: GeneralServiceIdentifier<T>, create: () => T): T {
const result = this.serviceGet(key);
if (result != null) {
return result;
}
const value = create();
this.serviceSet(key, value);
return value;
}
abstract propertyAdd(
insertToPosition: InsertToPosition,
ops?: {
type?: string;
name?: string;
}
): string | undefined;
abstract propertyDataGet(propertyId: string): Record<string, unknown>;
propertyDataGet$(
propertyId: string
): ReadonlySignal<Record<string, unknown> | undefined> {
return computed(() => this.propertyDataGet(propertyId));
}
abstract propertyDataSet(
propertyId: string,
data: Record<string, unknown>
): void;
abstract propertyDataTypeGet(propertyId: string): TypeInstance | undefined;
propertyDataTypeGet$(
propertyId: string
): ReadonlySignal<TypeInstance | undefined> {
return computed(() => this.propertyDataTypeGet(propertyId));
}
abstract propertyDelete(id: string): void;
abstract propertyDuplicate(propertyId: string): string | undefined;
abstract propertyMetaGet(type: string): PropertyMetaConfig | undefined;
abstract propertyNameGet(propertyId: string): string;
propertyNameGet$(propertyId: string): ReadonlySignal<string | undefined> {
return computed(() => this.propertyNameGet(propertyId));
}
abstract propertyNameSet(propertyId: string, name: string): void;
propertyReadonlyGet(_propertyId: string): boolean {
return false;
}
propertyReadonlyGet$(propertyId: string): ReadonlySignal<boolean> {
return computed(() => this.propertyReadonlyGet(propertyId));
}
abstract propertyTypeGet(propertyId: string): string | undefined;
propertyTypeGet$(propertyId: string): ReadonlySignal<string | undefined> {
return computed(() => this.propertyTypeGet(propertyId));
}
abstract propertyTypeSet(propertyId: string, type: string): void;
abstract rowAdd(InsertToPosition: InsertToPosition | number): string;
abstract rowDelete(ids: string[]): void;
abstract rowMove(rowId: string, position: InsertToPosition): void;
abstract viewDataAdd(viewData: DataViewDataType): string;
abstract viewDataDelete(viewId: string): void;
abstract viewDataDuplicate(id: string): string;
abstract viewDataGet(viewId: string): DataViewDataType | undefined;
viewDataGet$(viewId: string): ReadonlySignal<DataViewDataType | undefined> {
return computed(() => this.viewDataGet(viewId));
}
abstract viewDataMoveTo(id: string, position: InsertToPosition): void;
abstract viewDataUpdate<ViewData extends DataViewDataType>(
id: string,
updater: (data: ViewData) => Partial<ViewData>
): void;
abstract viewMetaGet(type: string): ViewMeta;
viewMetaGet$(type: string): ReadonlySignal<ViewMeta | undefined> {
return computed(() => this.viewMetaGet(type));
}
abstract viewMetaGetById(viewId: string): ViewMeta | undefined;
viewMetaGetById$(viewId: string): ReadonlySignal<ViewMeta | undefined> {
return computed(() => this.viewMetaGetById(viewId));
}
fixedProperties$ = computed(() => {
return this.allPropertyMetas$.value
.filter(v => v.config.fixed)
.map(v => v.type);
});
fixedPropertySet = computed(() => {
return new Set(this.fixedProperties$.value);
});
protected abstract getNormalPropertyAndIndex(propertyId: string):
| {
column: ColumnDataType<Record<string, unknown>>;
index: number;
}
| undefined;
isFixedProperty(propertyId: string) {
if (this.fixedPropertySet.value.has(propertyId)) {
return true;
}
const result = this.getNormalPropertyAndIndex(propertyId);
if (result) {
return this.fixedPropertySet.value.has(result.column.type);
}
return false;
}
}

View File

@@ -0,0 +1 @@
export * from './base.js';

View File

@@ -0,0 +1,238 @@
import type {
DatabaseAllEvents,
EventTraceFn,
} from '@blocksuite/affine-shared/services';
import type { DisposableMember } from '@blocksuite/global/disposable';
import { IS_MOBILE } from '@blocksuite/global/env';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
type Clipboard,
type DndController,
type EventName,
ShadowlessElement,
type UIEventHandler,
} from '@blocksuite/std';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { css, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ref } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
import type { DataViewWidget } from './widget/index.js';
export type DataViewRendererConfig = {
clipboard: Clipboard;
dnd?: DndController;
onDrag?: (evt: MouseEvent, id: string) => () => void;
notification: {
toast: (message: string) => void;
};
virtualPadding$: ReadonlySignal<number>;
headerWidget: DataViewWidget | undefined;
handleEvent: (name: EventName, handler: UIEventHandler) => DisposableMember;
bindHotkey: (hotkeys: Record<string, UIEventHandler>) => DisposableMember;
dataSource: DataSource;
selection$: ReadonlySignal<DataViewSelection | undefined>;
setSelection: (selection: DataViewSelection | undefined) => void;
eventTrace: EventTraceFn<DatabaseAllEvents>;
detailPanelConfig: {
openDetailPanel: (
target: HTMLElement,
data: {
view: SingleView;
rowId: string;
}
) => Promise<void>;
};
};
export class DataViewRootUILogic {
private get dataSource() {
return this.config.dataSource;
}
private get viewManager() {
return this.dataSource.viewManager;
}
private createDataViewUILogic(viewId: string): DataViewUILogicBase {
const view = this.viewManager.viewGet(viewId);
if (!view) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.DatabaseBlockError,
`View ${viewId} not found`
);
}
const pcLogic = view.meta.renderer.pcLogic;
const mobileLogic = view.meta.renderer.mobileLogic;
const logic = (IS_MOBILE ? mobileLogic : pcLogic) ?? pcLogic;
return new (logic(view))(this, view);
}
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();
get selection$() {
return this.config.selection$;
}
setSelection(selection?: DataViewSelection) {
this.config.setSelection(selection);
}
constructor(public readonly config: DataViewRendererConfig) {}
get dataViewRenderer() {
return this._uiRef.value;
}
readonly currentViewId$ = computed(() => {
return this.dataSource.viewManager.currentViewId$.value;
});
readonly currentView$ = computed(() => {
const currentViewId = this.currentViewId$.value;
if (!currentViewId) {
return;
}
return this.viewsMap$.value[currentViewId];
});
focusFirstCell = () => {
this.currentView$.value?.focusFirstCell();
};
openDetailPanel = (ops: {
view: SingleView;
rowId: string;
onClose?: () => void;
}) => {
const openDetailPanel = this.config.detailPanelConfig.openDetailPanel;
const target = this.dataViewRenderer;
if (openDetailPanel && target) {
openDetailPanel(target, {
view: ops.view,
rowId: ops.rowId,
})
.catch(console.error)
.finally(ops.onClose);
}
};
setupViewChangeListener() {
let preId: string | undefined = undefined;
return this.currentViewId$.subscribe(current => {
if (current !== preId) {
this.config.setSelection(undefined);
}
preId = current;
});
}
render() {
return html` <affine-data-view-renderer
${ref(this._uiRef)}
.logic="${this}"
></affine-data-view-renderer>`;
}
}
export class DataViewRootUI extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
${unsafeCSS(dataViewCommonStyle('affine-data-view-renderer'))}
affine-data-view-renderer {
background-color: var(--affine-background-primary-color);
display: contents;
}
`;
@property({ attribute: false })
accessor logic!: DataViewRootUILogic;
@state()
accessor currentView: string | undefined = undefined;
focusFirstCell = () => {
this.logic.focusFirstCell();
};
openDetailPanel = (ops: {
view: SingleView;
rowId: string;
onClose?: () => void;
}) => {
this.logic.openDetailPanel(ops);
};
override connectedCallback() {
super.connectedCallback();
this.disposables.add(this.logic.setupViewChangeListener());
}
override render() {
const containerClass = classMap({
'toolbar-hover-container': true,
'data-view-root': true,
'prevent-reference-popup': true,
});
const currentView = this.logic.currentView$.value;
if (!currentView) {
return;
}
return html`
<div style="display: contents" class="${containerClass}">
${renderUniLit(currentView.renderer, {
logic: currentView,
})}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-renderer': DataViewRootUI;
}
}

View File

@@ -0,0 +1,307 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import { css, nothing, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from '../common/css-variable.js';
import { renderUniLit } from '../utils/uni-component/uni-component.js';
import type { SingleView } from '../view-manager/single-view.js';
import { DetailSelection } from './selection.js';
export type DetailSlotProps = {
view: SingleView;
rowId: string;
openDoc: (docId: string) => void;
};
export interface DetailSlots {
header?: UniComponent<DetailSlotProps>;
note?: UniComponent<DetailSlotProps>;
}
const styles = css`
${unsafeCSS(dataViewCommonStyle('affine-data-view-record-detail'))}
affine-data-view-record-detail {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
padding: 20px;
gap: 12px;
background-color: var(--affine-background-primary-color);
border-radius: 8px;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.add-property {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--data-view-cell-text-size);
font-style: normal;
font-weight: 400;
line-height: var(--data-view-cell-text-line-height);
color: var(--affine-text-disable-color);
border-radius: 4px;
padding: 6px 8px 6px 4px;
cursor: pointer;
margin-top: 8px;
width: max-content;
}
.add-property:hover {
background-color: var(--affine-hover-color);
}
.add-property .icon {
display: flex;
align-items: center;
}
.add-property .icon svg {
fill: var(--affine-icon-color);
width: 20px;
height: 20px;
}
.switch-row {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
cursor: pointer;
font-size: 22px;
color: var(--affine-icon-color);
}
.switch-row:hover {
background-color: var(--affine-hover-color);
}
.switch-row.disable {
cursor: default;
background: none;
opacity: 0.5;
}
`;
export class RecordDetail extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
_clickAddProperty = () => {
popMenu(popupTargetFromElement(this.addPropertyButton), {
options: {
title: {
text: 'Add property',
},
items: [
menu.group({
items: this.view.propertyMetas$.value.map(meta => {
return menu.action({
name: meta.config.name,
prefix: renderUniLit(meta.renderer.icon),
select: () => {
this.view.propertyAdd('end', {
type: meta.type,
name: meta.config.name,
});
},
});
}),
}),
],
},
});
};
@property({ attribute: false })
accessor view!: SingleView;
properties$ = computed(() => {
return this.view.detailProperties$.value;
});
selection = new DetailSelection(this);
private get readonly() {
return this.view.readonly$.value;
}
private renderHeader() {
const header = this.detailSlots?.header;
if (header) {
const props: DetailSlotProps = {
view: this.view,
rowId: this.rowId,
openDoc: this.openDoc,
};
return renderUniLit(header, props);
}
return undefined;
}
private renderNote() {
const note = this.detailSlots?.note;
if (note) {
const props: DetailSlotProps = {
view: this.view,
rowId: this.rowId,
openDoc: this.openDoc,
};
return renderUniLit(note, props);
}
return undefined;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
this.selection.selection = undefined;
});
//FIXME: simulate as a widget
this.dataset.widgetId = 'affine-detail-widget';
}
row$ = computed(() => {
return this.view.rowGetOrCreate(this.rowId);
});
hasNext() {
return this.row$.value.next$.value != null;
}
hasPrev() {
return this.row$.value.prev$.value != null;
}
nextRow() {
const rowId = this.row$.value.next$.value?.rowId;
if (rowId == null) {
return;
}
this.rowId = rowId;
this.requestUpdate();
}
prevRow() {
const rowId = this.row$.value.prev$.value?.rowId;
if (rowId == null) {
return;
}
this.rowId = rowId;
this.requestUpdate();
}
override render() {
const properties = this.properties$.value;
const upClass = classMap({
'switch-row': true,
disable: !this.hasPrev(),
});
const downClass = classMap({
'switch-row': true,
disable: !this.hasNext(),
});
return html`
<div
style="position: absolute;left: 20px;top:20px;display: flex;align-items:center;gap:4px;"
>
<div @click="${this.prevRow}" class="${upClass}">
${ArrowUpBigIcon()}
</div>
<div @click="${this.nextRow}" class="${downClass}">
${ArrowDownBigIcon()}
</div>
</div>
<div style="flex:1;overflow-y: auto;overflow-x: hidden">
<div
style="width: 100%;max-width: var(--affine-editor-width);display: flex;flex-direction: column;margin: 0 auto;box-sizing: border-box;"
>
${keyed(this.rowId, this.renderHeader())}
${repeat(
properties,
v => v.id,
property => {
return keyed(
this.rowId,
html` <affine-data-view-record-field
.view="${this.view}"
.column="${property}"
.rowId="${this.rowId}"
data-column-id="${property.id}"
></affine-data-view-record-field>`
);
}
)}
${
!this.readonly
? html` <div
class="add-property"
@click="${this._clickAddProperty}"
>
<div class="icon">${PlusIcon()}</div>
Add Property
</div>`
: nothing
}
</div>
${keyed(this.rowId, this.renderNote())}
</div>
`;
}
@query('.add-property')
accessor addPropertyButton!: HTMLElement;
@property({ attribute: false })
accessor detailSlots: DetailSlots | undefined;
@property({ attribute: false })
accessor openDoc!: (docId: string) => void;
@property({ attribute: false })
accessor rowId!: string;
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-record-detail': RecordDetail;
}
}
export const createRecordDetail = (ops: {
view: SingleView;
rowId: string;
detail: DetailSlots;
openDoc: (docId: string) => void;
}) => {
return html` <affine-data-view-record-detail
.view=${ops.view}
.rowId=${ops.rowId}
.detailSlots=${ops.detail}
.openDoc=${ops.openDoc}
class="data-view-popup-container"
></affine-data-view-record-detail>`;
};

View File

@@ -0,0 +1,286 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import {
DeleteIcon,
DuplicateIcon,
MoveLeftIcon,
MoveRightIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { computed, signal } from '@preact/signals-core';
import { css } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit/static-html.js';
import { inputConfig, typeConfig } from '../common/property-menu.js';
import type {
CellRenderProps,
DataViewCellLifeCycle,
} from '../property/index.js';
import { renderUniLit } from '../utils/uni-component/uni-component.js';
import type { Property } from '../view-manager/property.js';
import type { SingleView } from '../view-manager/single-view.js';
export class RecordField extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-data-view-record-field {
display: flex;
gap: 12px;
}
.field-left {
padding: 6px;
display: flex;
height: max-content;
align-items: center;
gap: 6px;
font-size: var(--data-view-cell-text-size);
line-height: var(--data-view-cell-text-line-height);
color: var(--affine-text-secondary-color);
width: 160px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.field-left:hover {
background-color: var(--affine-hover-color);
}
affine-data-view-record-field .icon {
display: flex;
align-items: center;
width: 16px;
height: 16px;
}
affine-data-view-record-field .icon svg {
width: 16px;
height: 16px;
fill: var(--affine-icon-color);
}
.filed-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-content {
padding: 6px 8px;
border-radius: 4px;
flex: 1;
cursor: pointer;
display: flex;
align-items: center;
border: 1px solid transparent;
}
.field-content affine-database-number-cell .number {
text-align: left;
justify-content: flex-start;
}
.field-content:hover {
background-color: var(--affine-hover-color);
}
.field-content.is-editing {
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
}
.field-content.is-focus {
border: 1px solid var(--affine-primary-color);
}
.field-content.empty::before {
content: 'Empty';
color: var(--affine-text-disable-color);
font-size: 14px;
line-height: 22px;
}
`;
private readonly _cell = signal<DataViewCellLifeCycle>();
_click = (e: MouseEvent) => {
e.stopPropagation();
if (this.readonly) return;
this.changeEditing(true);
};
_clickLeft = (e: MouseEvent) => {
if (this.readonly) return;
const ele = e.currentTarget as HTMLElement;
const properties = this.view.detailProperties$.value;
popMenu(popupTargetFromElement(ele), {
options: {
title: {
text: 'Property settings',
},
items: [
menu.group({
items: [inputConfig(this.column), typeConfig(this.column)],
}),
menu.group({
items: [
menu.action({
name: 'Move Up',
prefix: html` <div
style="transform: rotate(90deg);display:flex;align-items:center;"
>
${MoveLeftIcon()}
</div>`,
hide: () =>
properties.findIndex(
property => property.id === this.column.id
) === 0,
select: () => {
const prev = this.column.prev$.value;
if (!prev) {
return;
}
this.column.move({
id: prev.id,
before: true,
});
},
}),
menu.action({
name: 'Move Down',
prefix: html` <div
style="transform: rotate(90deg);display:flex;align-items:center;"
>
${MoveRightIcon()}
</div>`,
hide: () =>
properties.findIndex(
property => property.id === this.column.id
) ===
properties.length - 1,
select: () => {
const next = this.column.next$.value;
if (!next) {
return;
}
this.column.move({
id: next.id,
before: false,
});
},
}),
],
}),
menu.group({
name: 'operation',
items: [
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
hide: () => !this.column.canDuplicate,
select: () => {
this.column.duplicate?.();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
hide: () => !this.column.canDelete,
select: () => {
this.column.delete?.();
},
class: { 'delete-item': true },
}),
],
}),
],
},
});
};
@property({ attribute: false })
accessor column!: Property;
@property({ attribute: false })
accessor rowId!: string;
cell$ = computed(() => {
return this.column.cellGetOrCreate(this.rowId);
});
changeEditing = (editing: boolean) => {
const selection = this.closest('affine-data-view-record-detail')?.selection;
if (selection) {
selection.selection = {
propertyId: this.column.id,
isEditing: editing,
};
}
};
get cell(): DataViewCellLifeCycle | undefined {
return this._cell.value;
}
private get readonly() {
return this.view.readonly$.value;
}
override render() {
const column = this.column;
const props: CellRenderProps = {
cell: this.cell$.value,
isEditing$: this.isEditing$,
selectCurrentCell: this.changeEditing,
};
const renderer = this.column.renderer$.value;
if (!renderer) {
return;
}
const { view } = renderer;
const contentClass = classMap({
'field-content': true,
empty: !this.isEditing$.value && this.cell$.value.isEmpty$.value,
'is-editing': this.isEditing$.value,
'is-focus': this.isFocus$.value,
});
return html`
<div>
<div class="field-left" @click="${this._clickLeft}">
<div class="icon">
<uni-lit .uni="${this.column.icon}"></uni-lit>
</div>
<div class="filed-name">${column.name$.value}</div>
</div>
</div>
<div @click="${this._click}" class="${contentClass}">
${renderUniLit(view, props, {
ref: this._cell,
class: 'kanban-cell',
})}
</div>
`;
}
isEditing$ = signal(false);
isFocus$ = signal(false);
@property({ attribute: false })
accessor view!: SingleView;
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-record-field': RecordField;
}
}

View File

@@ -0,0 +1,179 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
propertyId: string;
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
if (selection && isSameDetailSelection(this._selection, selection)) {
this.focus(selection);
}
};
get selection(): DetailViewSelection | undefined {
return this._selection;
}
set selection(selection: DetailViewSelection | undefined) {
if (!selection) {
this.onSelect();
return;
}
if (selection.isEditing) {
const container = this.getFocusCellContainer(selection);
const cell = container?.cell;
const isEditing = cell
? cell.beforeEnterEditMode()
? selection.isEditing
: false
: false;
this.onSelect({
propertyId: selection.propertyId,
isEditing,
});
} else {
this.onSelect(selection);
}
}
constructor(private readonly viewEle: DetailSelectionHost) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
if (!container) {
return;
}
container.isFocus$.value = false;
const cell = container.cell;
if (selection.isEditing) {
cell?.beforeExitEditingMode();
if (cell?.blurCell()) {
container.blur();
}
container.isEditing$.value = false;
} else {
container.blur();
}
}
deleteProperty() {
//
}
focus(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
if (!container) {
return;
}
container.isFocus$.value = true;
const cell = container.cell;
if (selection.isEditing) {
if (cell?.focusCell()) {
container.focus();
}
container.isEditing$.value = true;
requestAnimationFrame(() => {
cell?.afterEnterEditingMode();
});
} else {
container.focus();
}
}
focusDown() {
const selection = this.selection;
if (!selection || selection?.isEditing) {
return;
}
const nextContainer =
this.getFocusCellContainer(selection)?.nextElementSibling;
if (nextContainer instanceof KanbanCell) {
this.selection = {
propertyId: nextContainer.column.id,
isEditing: false,
};
}
}
focusFirstCell() {
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
isEditing: true,
};
}
}
focusUp() {
const selection = this.selection;
if (!selection || selection?.isEditing) {
return;
}
const preContainer =
this.getFocusCellContainer(selection)?.previousElementSibling;
if (preContainer instanceof RecordField) {
this.selection = {
propertyId: preContainer.column.id,
isEditing: false,
};
}
}
getFocusCellContainer(selection: DetailViewSelection) {
return this.viewEle.querySelector(
`affine-data-view-record-field[data-column-id="${selection.propertyId}"]`
) as RecordField | undefined;
}
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -0,0 +1,39 @@
import { DataViewPropertiesSettingView } from './common/properties.js';
import { Button } from './component/button/button.js';
import { Overflow } from './component/overflow/overflow.js';
import { MultiTagSelect, MultiTagView } from './component/tags/index.js';
import { DataViewRootUI } from './data-view.js';
import { RecordDetail } from './detail/detail.js';
import { RecordField } from './detail/field.js';
import { VariableRefView } from './expression/ref/ref-view.js';
import { BooleanGroupView } from './group-by/renderer/boolean-group.js';
import { NumberGroupView } from './group-by/renderer/number-group.js';
import { SelectGroupView } from './group-by/renderer/select-group.js';
import { StringGroupView } from './group-by/renderer/string-group.js';
import { GroupSetting } from './group-by/setting.js';
import { AffineLitIcon, UniAnyRender, UniLit } from './index.js';
import { AnyRender } from './utils/uni-component/render-template.js';
export function coreEffects() {
customElements.define('affine-data-view-renderer', DataViewRootUI);
customElements.define('any-render', AnyRender);
customElements.define(
'data-view-properties-setting',
DataViewPropertiesSettingView
);
customElements.define('affine-data-view-record-field', RecordField);
customElements.define('data-view-component-button', Button);
customElements.define('component-overflow', Overflow);
customElements.define('data-view-group-title-select-view', SelectGroupView);
customElements.define('data-view-group-title-string-view', StringGroupView);
customElements.define('data-view-group-title-number-view', NumberGroupView);
customElements.define('affine-lit-icon', AffineLitIcon);
customElements.define('data-view-group-setting', GroupSetting);
customElements.define('affine-multi-tag-select', MultiTagSelect);
customElements.define('data-view-group-title-boolean-view', BooleanGroupView);
customElements.define('affine-multi-tag-view', MultiTagView);
customElements.define('uni-lit', UniLit);
customElements.define('uni-any-render', UniAnyRender);
customElements.define('variable-ref-view', VariableRefView);
customElements.define('affine-data-view-record-detail', RecordDetail);
}

View File

@@ -0,0 +1,3 @@
export * from '../filter/literal/index.js';
export * from './ref/index.js';
export * from './types.js';

View File

@@ -0,0 +1,2 @@
export * from './ref.js';
export * from './ref-view.js';

View File

@@ -0,0 +1,94 @@
import {
menu,
popFilterableSimpleMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { renderUniLit } from '../../utils/uni-component/uni-component.js';
import type { Variable, VariableRef } from '../types.js';
export class VariableRefView extends WithDisposable(ShadowlessElement) {
static override styles = css`
variable-ref-view {
font-size: 12px;
line-height: 20px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
border-radius: 4px;
cursor: pointer;
}
variable-ref-view:hover {
background-color: var(--affine-hover-color);
}
variable-ref-view svg {
width: 16px;
height: 16px;
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
}
`;
get field() {
if (!this.data) {
return;
}
return this.data.name;
}
get fieldData() {
const id = this.field;
if (!id) {
return;
}
return this.vars.find(v => v.id === id);
}
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', e => {
popFilterableSimpleMenu(
popupTargetFromElement(e.target as HTMLElement),
this.vars.map(v =>
menu.action({
name: v.name,
prefix: renderUniLit(v.icon, {}),
select: () => {
this.setData({
type: 'ref',
name: v.id,
});
},
})
)
);
});
}
override render() {
const data = this.fieldData;
return html` ${renderUniLit(data?.icon, {})} ${data?.name} `;
}
@property({ attribute: false })
accessor data: VariableRef | undefined = undefined;
@property({ attribute: false })
accessor setData!: (filter: VariableRef) => void;
@property({ attribute: false })
accessor vars!: Variable[];
}
declare global {
interface HTMLElementTagNameMap {
'variable-ref-view': VariableRefView;
}
}

View File

@@ -0,0 +1,5 @@
import type { Variable, VariableRef } from '../types.js';
export const getRefType = (vars: Variable[], ref: VariableRef) => {
return vars.find(v => v.id === ref.name)?.type;
};

View File

@@ -0,0 +1,22 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance } from '../logical/type.js';
export type VariableRef = {
type: 'ref';
name: string;
};
export type Variable = {
name: string;
type: TypeInstance;
propertyType: string;
id: string;
icon?: UniComponent;
};
export type Literal = {
type: 'literal';
value: unknown;
};
// TODO support VariableRef
export type Value = Literal;

View File

@@ -0,0 +1,68 @@
import {
menu,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { AddCursorIcon } from '@blocksuite/icons/lit';
import type { Middleware } from '@floating-ui/dom';
import type { ReadonlySignal } from '@preact/signals-core';
import type { Variable } from '../expression/index.js';
import { renderUniLit } from '../utils/index.js';
import type { Filter } from './types.js';
import { firstFilterByRef, firstFilterInGroup } from './utils.js';
export const popCreateFilter = (
target: PopupTarget,
props: {
vars: ReadonlySignal<Variable[]>;
onSelect: (filter: Filter) => void;
onClose?: () => void;
onBack?: () => void;
},
ops?: {
middleware?: Middleware[];
}
) => {
const subHandler = popMenu(target, {
middleware: ops?.middleware,
options: {
onClose: props.onClose,
title: {
onBack: props.onBack,
text: 'New filter',
},
items: [
menu.group({
items: props.vars.value.map(v =>
menu.action({
name: v.name,
prefix: renderUniLit(v.icon, {}),
select: () => {
props.onSelect(
firstFilterByRef(props.vars.value, {
type: 'ref',
name: v.id,
})
);
},
})
),
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Add filter group',
prefix: AddCursorIcon(),
select: () => {
props.onSelect(firstFilterInGroup(props.vars.value));
},
}),
],
}),
],
},
});
subHandler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -0,0 +1,53 @@
import type { Value, VariableRef } from '../expression/types.js';
import { filterMatcher } from './filter-fn/matcher.js';
import type { Filter } from './types.js';
const evalRef = (ref: VariableRef, row: Record<string, unknown>): unknown => {
return row[ref.name];
};
const evalValue = (value?: Value): unknown => {
return value?.value;
};
export const evalFilter = (
filterGroup: Filter,
row: Record<string, unknown>
): boolean => {
const evalF = (filter: Filter): boolean => {
if (filter.type === 'filter') {
const value = evalRef(filter.left, row);
const func = filterMatcher.getFilterByName(filter.function);
if (!func) {
return true;
}
const expectArgLen = func.args.length;
const args: unknown[] = [];
for (let i = 0; i < expectArgLen; i++) {
const argValue = evalValue(filter.args[i]);
const argType = func.args[i];
if (argValue == null || argType == null) {
return true;
}
if (!argType.valueValidate(argValue)) {
return true;
}
args.push(argValue);
}
const impl = func.impl;
try {
return impl(value ?? undefined, ...args);
} catch (e) {
console.error(e);
return true;
}
} else if (filter.type === 'group') {
if (filter.op === 'and') {
return filter.conditions.every(f => evalF(f));
} else if (filter.op === 'or') {
return filter.conditions.some(f => evalF(f));
}
}
return true;
};
return evalF(filterGroup);
};

View File

@@ -0,0 +1,27 @@
import { t } from '../../logical/type-presets.js';
import { createFilter } from './create.js';
export const booleanFilter = [
createFilter({
name: 'isChecked',
self: t.boolean.instance(),
args: [],
label: 'Is checked',
shortString: () => ': Checked',
impl: value => {
return !!value;
},
defaultValue: () => true,
}),
createFilter({
name: 'isUnchecked',
self: t.boolean.instance(),
args: [],
label: 'Is unchecked',
shortString: () => ': Unchecked',
impl: value => {
return !value;
},
defaultValue: () => false,
}),
];

View File

@@ -0,0 +1,64 @@
import type { ArrayTypeInstance } from '../../logical/composite-type.js';
import type { DTInstance } from '../../logical/data-type.js';
import type { TypeInstance, ValueTypeOf } from '../../logical/type.js';
import type {
TypeVarDefinitionInstance,
TypeVarReferenceInstance,
} from '../../logical/type-variable.js';
export type FilterConfig<
Self extends TypeInstance = TypeInstance,
Args extends TypeInstance[] = TypeInstance[],
Vars extends TypeVarDefinitionInstance[] = TypeVarDefinitionInstance[],
> = {
name: string;
label: string;
shortString: (
...args: {
[K in keyof Args]:
| {
value: ValueTypeOf<ReplaceVar<Args[K], Vars>>;
type: ReplaceVar<Args[K], Vars>;
}
| undefined;
}
) => string | undefined;
self: Self;
vars?: Vars;
args: Args;
impl: (
self: ValueTypeOf<ReplaceVar<Self, Vars>> | undefined,
...args: { [K in keyof Args]: ValueTypeOf<ReplaceVar<Args[K], Vars>> }
) => boolean;
defaultValue?: (args: {
[K in keyof Args]: ValueTypeOf<ReplaceVar<Args[K], Vars>>;
}) => ValueTypeOf<ReplaceVar<Self, Vars>> | undefined;
};
type FindVar<
Vars extends TypeVarDefinitionInstance[],
Name extends string,
> = Vars[number] extends infer Var
? Var extends TypeVarDefinitionInstance<Name, infer R>
? R
: never
: never;
type ReplaceVar<
Arg,
Vars extends TypeVarDefinitionInstance[],
> = Arg extends TypeVarReferenceInstance
? FindVar<Vars, Arg['varName']>
: Arg extends ArrayTypeInstance<infer Ele>
? ArrayTypeInstance<ReplaceVar<Ele, Vars>>
: Arg extends DTInstance
? Arg
: Arg;
export const createFilter = <
Self extends TypeInstance,
Args extends TypeInstance[],
Vars extends TypeVarDefinitionInstance[],
>(
config: FilterConfig<Self, Args, Vars>
) => {
return config;
};

View File

@@ -0,0 +1,37 @@
import { addDays } from 'date-fns/addDays';
import { format } from 'date-fns/format';
import { subDays } from 'date-fns/subDays';
import { t } from '../../logical/type-presets.js';
import { createFilter } from './create.js';
export const dateFilter = [
createFilter({
name: 'before',
self: t.date.instance(),
args: [t.date.instance()] as const,
label: 'Before',
shortString: v => (v ? ` < ${format(v.value, 'yyyy/MM/dd')}` : undefined),
impl: (self, value) => {
if (self == null) {
return false;
}
return self < value;
},
defaultValue: args => subDays(args[0], 1).getTime(),
}),
createFilter({
name: 'after',
self: t.date.instance(),
args: [t.date.instance()] as const,
label: 'After',
shortString: v => (v ? ` > ${format(v.value, 'yyyy/MM/dd')}` : undefined),
impl: (self, value) => {
if (self == null) {
return false;
}
return self > value;
},
defaultValue: args => addDays(args[0], 1).getTime(),
}),
];

View File

@@ -0,0 +1,61 @@
import { ct } from '../../logical/composite-type.js';
import { t } from '../../logical/index.js';
import type { TypeInstance } from '../../logical/type.js';
import { typeSystem } from '../../logical/type-system.js';
import { booleanFilter } from './boolean.js';
import type { FilterConfig } from './create.js';
import { dateFilter } from './date.js';
import { multiTagFilter } from './multi-tag.js';
import { numberFilter } from './number.js';
import { stringFilter } from './string.js';
import { tagFilter } from './tag.js';
import { unknownFilter } from './unknown.js';
const allFilter = [
...dateFilter,
...multiTagFilter,
...numberFilter,
...stringFilter,
...tagFilter,
...booleanFilter,
...unknownFilter,
] as FilterConfig[];
const getPredicate = (selfType: TypeInstance) => (filter: FilterConfig) => {
const fn = ct.fn.instance(
[filter.self, ...filter.args],
t.boolean.instance(),
filter.vars
);
const staticType = fn.subst(
Object.fromEntries(
filter.vars?.map(v => [
v.varName,
{
define: v,
type: v.typeConstraint,
},
]) ?? []
)
);
if (!staticType) {
return false;
}
const firstArg = staticType.args[0];
return firstArg && typeSystem.unify(selfType, firstArg);
};
export const filterMatcher = {
filterListBySelfType: (selfType: TypeInstance) => {
return allFilter.filter(getPredicate(selfType));
},
firstMatchedBySelfType: (selfType: TypeInstance) => {
return allFilter.find(getPredicate(selfType));
},
getFilterByName: (name?: string) => {
if (!name) {
return;
}
return allFilter.find(v => v.name === name);
},
};

View File

@@ -0,0 +1,89 @@
import { ct } from '../../logical/composite-type.js';
import { t } from '../../logical/type-presets.js';
import { tRef, tVar } from '../../logical/type-variable.js';
import { createFilter } from './create.js';
import { tagToString } from './utils.js';
const optionName = 'option' as const;
export const multiTagFilter = [
createFilter({
name: 'containsOneOf',
vars: [tVar(optionName, t.tag.instance())] as const,
self: ct.array.instance(tRef(optionName)),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Contains one of',
shortString: v =>
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return false;
}
return value.some(v => self.includes(v));
},
defaultValue: args => {
const value = args[0][0];
if (value != null) {
return [value];
}
return;
},
}),
createFilter({
name: 'doesNotContainOneOf',
vars: [tVar(optionName, t.tag.instance())] as const,
self: ct.array.instance(tRef(optionName)),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Does not contains one of',
shortString: v =>
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return true;
}
return value.every(v => !self.includes(v));
},
}),
createFilter({
name: 'containsAll',
vars: [tVar(optionName, t.tag.instance())] as const,
self: ct.array.instance(tRef(optionName)),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Contains all',
shortString: v =>
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return false;
}
return value.every(v => self.includes(v));
},
defaultValue: args => args[0],
}),
createFilter({
name: 'doesNotContainAll',
vars: [tVar(optionName, t.tag.instance())] as const,
self: ct.array.instance(tRef(optionName)),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Does not contains all',
shortString: v =>
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return true;
}
return !value.every(v => self.includes(v));
},
}),
];

View File

@@ -0,0 +1,88 @@
import { t } from '../../logical/type-presets.js';
import { createFilter } from './create.js';
export const numberFilter = [
createFilter({
name: 'equal',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '=',
shortString: v => (v ? ` = ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self == target;
},
defaultValue: args => args[0],
}),
createFilter({
name: 'notEqual',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '≠',
shortString: v => (v ? ` ≠ ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self != target;
},
}),
createFilter({
name: 'greatThan',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '>',
shortString: v => (v ? ` > ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self > target;
},
defaultValue: args => args[0] + 1,
}),
createFilter({
name: 'lessThan',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '<',
shortString: v => (v ? ` < ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self < target;
},
defaultValue: args => args[0] - 1,
}),
createFilter({
name: 'greatThanOrEqual',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '≥',
shortString: v => (v ? ` ≥ ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self >= target;
},
defaultValue: args => args[0],
}),
createFilter({
name: 'lessThanOrEqual',
self: t.number.instance(),
args: [t.number.instance()] as const,
label: '≤',
shortString: v => (v ? ` ≤ ${v.value}` : undefined),
impl: (self, target) => {
if (self == null) {
return false;
}
return self <= target;
},
defaultValue: args => args[0],
}),
];

View File

@@ -0,0 +1,69 @@
import { t } from '../../logical/type-presets.js';
import { createFilter } from './create.js';
export const stringFilter = [
createFilter({
name: 'contains',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Contains',
shortString: v => (v ? `: ${v.value}` : undefined),
impl: (self = '', value) => {
return self.toLowerCase().includes(value.toLowerCase());
},
defaultValue: args => args[0],
}),
createFilter({
name: 'doesNoContains',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Does no contains',
shortString: v => (v ? `: Not ${v.value}` : undefined),
impl: (self = '', value) => {
return !self.toLowerCase().includes(value.toLowerCase());
},
}),
createFilter({
name: 'startsWith',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Starts with',
shortString: v => (v ? `: Starts with ${v.value}` : undefined),
impl: (self = '', value) => {
return self.toLowerCase().startsWith(value.toLowerCase());
},
defaultValue: args => args[0],
}),
createFilter({
name: 'endsWith',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Ends with',
shortString: v => (v ? `: Ends with ${v.value}` : undefined),
impl: (self = '', value) => {
return self.toLowerCase().endsWith(value.toLowerCase());
},
defaultValue: args => args[0],
}),
createFilter({
name: 'is',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Is',
shortString: v => (v ? `: ${v.value}` : undefined),
impl: (self = '', value) => {
return self.toLowerCase() == value.toLowerCase();
},
defaultValue: args => args[0],
}),
createFilter({
name: 'isNot',
self: t.string.instance(),
args: [t.string.instance()] as const,
label: 'Is not',
shortString: v => (v ? `: Not ${v.value}` : undefined),
impl: (self = '', value) => {
return self.toLowerCase() != value.toLowerCase();
},
}),
];

View File

@@ -0,0 +1,46 @@
import { ct } from '../../logical/composite-type.js';
import { t } from '../../logical/type-presets.js';
import { tRef, tVar } from '../../logical/type-variable.js';
import { createFilter } from './create.js';
import { tagToString } from './utils.js';
const optionName = 'options' as const;
export const tagFilter = [
createFilter({
name: 'isOneOf',
vars: [tVar(optionName, t.tag.instance())] as const,
self: tRef(optionName),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Is one of',
shortString: v =>
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return false;
}
return value.includes(self);
},
defaultValue: args => args[0][0],
}),
createFilter({
name: 'isNotOneOf',
vars: [tVar(optionName, t.tag.instance())] as const,
self: tRef(optionName),
args: [ct.array.instance(tRef(optionName))] as const,
label: 'Is not one of',
shortString: v =>
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
impl: (self, value) => {
if (!value.length) {
return true;
}
if (self == null) {
return true;
}
return !value.includes(self);
},
}),
];

View File

@@ -0,0 +1,37 @@
import { t } from '../../logical/type-presets.js';
import { createFilter } from './create.js';
export const unknownFilter = [
createFilter({
name: 'isNotEmpty',
self: t.unknown.instance(),
args: [] as const,
label: 'Is not empty',
shortString: () => ': Is not empty',
impl: self => {
if (Array.isArray(self)) {
return self.length > 0;
}
if (typeof self === 'string') {
return !!self;
}
return self != null;
},
}),
createFilter({
name: 'isEmpty',
self: t.unknown.instance(),
args: [] as const,
label: 'Is empty',
shortString: () => ': Is empty',
impl: self => {
if (Array.isArray(self)) {
return self.length === 0;
}
if (typeof self === 'string') {
return !self;
}
return self == null;
},
}),
];

View File

@@ -0,0 +1,20 @@
import type { DataTypeOf } from '../../logical/data-type.js';
import type { t } from '../../logical/index.js';
export const tagToString = (
value: (string | undefined)[],
type: DataTypeOf<typeof t.tag>
) => {
if (!type.data) {
return;
}
const map = new Map(type.data.map(v => [v.id, v.value]));
return value
.flatMap(id => {
if (id) {
return map.get(id);
}
return [];
})
.join(', ');
};

View File

@@ -0,0 +1,44 @@
import type { Variable } from '../expression/index.js';
import type { DVJSON } from '../property/types.js';
import { filterMatcher } from './filter-fn/matcher.js';
import type { FilterGroup, SingleFilter } from './types.js';
/**
* Generate default values for a new row based on current filter conditions.
* If a property has multiple conditions, no value will be set to avoid conflicts.
*/
export function generateDefaultValues(
filter: FilterGroup,
_vars: Variable[]
): Record<string, DVJSON> {
const defaultValues: Record<string, DVJSON> = {};
const propertyConditions = new Map<string, SingleFilter[]>();
// Only collect top-level filters
for (const condition of filter.conditions) {
if (condition.type === 'filter') {
const propertyId = condition.left.name;
if (!propertyConditions.has(propertyId)) {
propertyConditions.set(propertyId, []);
}
propertyConditions.get(propertyId)?.push(condition);
}
}
for (const [propertyId, conditions] of propertyConditions) {
if (conditions.length === 1) {
const condition = conditions[0];
if (!condition) continue;
const filterConfig = filterMatcher.getFilterByName(condition.function);
if (filterConfig?.defaultValue) {
const argValues = condition.args.map(arg => arg.value);
const defaultValue = filterConfig.defaultValue(argValues);
if (defaultValue != null) {
defaultValues[propertyId] = defaultValue;
}
}
}
}
return defaultValues;
}

View File

@@ -0,0 +1,5 @@
export * from './add-filter.js';
export * from './eval.js';
export * from './generate-default-values.js';
export * from './types.js';
export * from './utils.js';

View File

@@ -0,0 +1,5 @@
import type { CreateLiteralItemsConfig } from './types.js';
export const createLiteral: CreateLiteralItemsConfig = config => {
return config;
};

View File

@@ -0,0 +1,155 @@
import { menu } from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { CheckBoxCheckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { t } from '../../logical/type-presets.js';
import { createLiteral } from './create.js';
import type { LiteralItemsConfig } from './types.js';
export const allLiteralConfig: LiteralItemsConfig[] = [
createLiteral({
type: t.date.instance(),
getItems: (_type, value, onChange) => {
return [
() => {
return html` <date-picker
.padding="${8}"
.size="${20}"
.value="${value.value}"
.onChange="${(date: Date) => {
onChange(date.getTime());
}}"
></date-picker>`;
},
];
},
}),
createLiteral({
type: t.boolean.instance(),
getItems: (_type, _value, _onChange) => {
return [
// menu.action({
// name: 'Unchecked',
// isSelected: !value.value,
// select: () => {
// onChange(false);
// return false;
// },
// }),
// menu.action({
// name: 'Checked',
// isSelected: !!value.value,
// select: () => {
// onChange(true);
// return false;
// },
// }),
];
},
}),
createLiteral({
type: t.string.instance(),
getItems: (_type, value, onChange) => {
return [
menu.input({
initialValue: value.value ?? '',
onChange: onChange,
placeholder: 'Type a value...',
}),
];
},
}),
createLiteral({
type: t.number.instance(),
getItems: (_type, value, onChange) => {
return [
menu.input({
initialValue: value.value?.toString(10) ?? '',
placeholder: 'Type a value...',
onChange: text => {
const number = Number.parseFloat(text);
if (Number.isNaN(number)) {
return;
}
onChange(number);
},
}),
];
},
}),
createLiteral({
type: t.array.instance(t.tag.instance()),
getItems: (type, value, onChange) => {
const set = new Set(value.value);
return [
menu.group({
items:
type.element.data?.map(tag => {
const selected = set.has(tag.id);
const prefix = selected
? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` })
: CheckBoxUnIcon();
return menu.action({
name: tag.value,
prefix,
label: () =>
html`<span
style="
background-color: ${tag.color};
padding:0 8px;
border-radius:4px;
font-size: 14px;
line-height: 22px;
border:1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
"
>${tag.value}</span
>`,
select: () => {
if (selected) {
set.delete(tag.id);
} else {
set.add(tag.id);
}
onChange([...set]);
return false;
},
});
}) ?? [],
}),
];
},
}),
createLiteral({
type: t.tag.instance(),
getItems: (type, value, onChange) => {
return [
menu.group({
items:
type.data?.map(tag => {
return menu.action({
name: tag.value,
label: () =>
html`<span
style="
background-color: ${tag.color};
padding:0 8px;
border-radius:4px;
font-size: 14px;
line-height: 22px;
border:1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
"
>${tag.value}</span
>`,
isSelected: value.value === tag.id,
select: () => {
onChange(tag.id);
return false;
},
});
}) ?? [],
}),
];
},
}),
];

View File

@@ -0,0 +1,3 @@
export * from './define.js';
export * from './matcher.js';
export * from './types.js';

View File

@@ -0,0 +1,20 @@
import type { ReadonlySignal } from '@preact/signals-core';
import { typeSystem } from '../../logical/index.js';
import type { TypeInstance } from '../../logical/type.js';
import { allLiteralConfig } from './define.js';
export const literalItemsMatcher = {
getItems: (
type: TypeInstance,
value: ReadonlySignal<unknown>,
onChange: (value: unknown) => void
) => {
for (const config of allLiteralConfig) {
if (typeSystem.unify(type, config.type)) {
return config.getItems(type, value, onChange);
}
}
return [];
},
};

View File

@@ -0,0 +1,19 @@
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
import type { ReadonlySignal } from '@preact/signals-core';
import type { TypeInstance, ValueTypeOf } from '../../logical/type.js';
export type CreateLiteralItemsConfig = <
Type extends TypeInstance = TypeInstance,
>(
config: LiteralItemsConfig<Type>
) => LiteralItemsConfig<Type>;
export type LiteralItemsConfig<Type extends TypeInstance = any> = {
type: Type;
getItems: (
type: Type,
value: ReadonlySignal<ValueTypeOf<Type> | undefined>,
onChange: (value: ValueTypeOf<Type>) => void
) => MenuConfig[];
};

View File

@@ -0,0 +1,25 @@
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { createTraitKey } from '../traits/key.js';
import type { SingleView } from '../view-manager/index.js';
import type { FilterGroup } from './types.js';
export class FilterTrait {
filterSet = (filter: FilterGroup) => {
this.config.filterSet(filter);
};
hasFilter$ = computed(() => {
return this.filter$.value.conditions.length > 0;
});
constructor(
readonly filter$: ReadonlySignal<FilterGroup>,
readonly view: SingleView,
readonly config: {
filterSet: (filter: FilterGroup) => void;
}
) {}
}
export const filterTraitKey = createTraitKey<FilterTrait>('filter');

View File

@@ -0,0 +1,14 @@
import type { Value, VariableRef } from '../expression/types.js';
export type SingleFilter = {
type: 'filter';
left: VariableRef;
function?: string;
args: Value[];
};
export type FilterGroup = {
type: 'group';
op: 'and' | 'or';
conditions: Filter[];
};
export type Filter = SingleFilter | FilterGroup;

View File

@@ -0,0 +1,66 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { getRefType } from '../expression/ref/ref.js';
import type { Variable, VariableRef } from '../expression/types.js';
import { filterMatcher } from './filter-fn/matcher.js';
import type { FilterGroup, SingleFilter } from './types.js';
export const firstFilterName = (vars: Variable[], ref: VariableRef) => {
const type = getRefType(vars, ref);
if (!type) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
`can't resolve ref type`
);
}
return filterMatcher.firstMatchedBySelfType(type)?.name;
};
export const firstFilterByRef = (
vars: Variable[],
ref: VariableRef
): SingleFilter => {
return {
type: 'filter',
left: ref,
function: firstFilterName(vars, ref),
args: [],
};
};
export const firstFilter = (vars: Variable[]): SingleFilter => {
const variable = vars[0];
if (!variable) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
`can't find any variable`
);
}
const ref: VariableRef = {
type: 'ref',
name: variable.id,
};
const filter = firstFilterName(vars, ref);
if (!filter) {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
`can't match any filter`
);
}
return {
type: 'filter',
left: ref,
function: filter,
args: [],
};
};
export const firstFilterInGroup = (vars: Variable[]): FilterGroup => {
return {
type: 'group',
op: 'and',
conditions: [firstFilter(vars)],
};
};
export const emptyFilterGroup: FilterGroup = {
type: 'group',
op: 'and',
conditions: [],
};

View File

@@ -0,0 +1,62 @@
export const RELATIVE_ASC = [
'last30',
'last7',
'yesterday',
'today',
'tomorrow',
'next7',
'next30',
] as const;
export const RELATIVE_DESC = [...RELATIVE_ASC].reverse();
/**
* Sorts relative date keys in chronological order
*/
export function sortRelativeKeys(a: string, b: string, asc: boolean): number {
const order: readonly string[] = asc ? RELATIVE_ASC : RELATIVE_DESC;
const idxA = order.indexOf(a);
const idxB = order.indexOf(b);
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
if (idxA !== -1) return asc ? 1 : -1;
if (idxB !== -1) return asc ? -1 : 1;
return 0; // Both not found
}
/**
* Sorts numeric date keys (timestamps)
*/
export function sortNumericKeys(a: string, b: string, asc: boolean): number {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
return asc ? na - nb : nb - na;
}
return 0; // Not both numeric
}
export function compareDateKeys(mode: string | undefined, asc: boolean) {
return (a: string, b: string) => {
if (mode === 'date-relative') {
// Try relative key sorting first
const relativeResult = sortRelativeKeys(a, b, asc);
if (relativeResult !== 0) return relativeResult;
// Try numeric sorting second
const numericResult = sortNumericKeys(a, b, asc);
if (numericResult !== 0) return numericResult;
// Fallback to lexicographic order for mixed cases
return asc ? a.localeCompare(b) : b.localeCompare(a);
}
// Standard numeric/lexicographic comparison for other date modes
return (
sortNumericKeys(a, b, asc) ||
(asc ? a.localeCompare(b) : b.localeCompare(a))
);
};
}

View File

@@ -0,0 +1,24 @@
import type { GroupBy } from '../common/types.js';
import type { DataSource } from '../data-source/index.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
import { getGroupByService } from './matcher.js';
export const defaultGroupBy = (
dataSource: DataSource,
propertyMeta: PropertyMetaConfig,
propertyId: string,
data: NonNullable<unknown>
): GroupBy | undefined => {
const groupByService = getGroupByService(dataSource);
const name = groupByService?.matcher.match(
propertyMeta.config.jsonValue.type({ data, dataSource })
)?.name;
return name != null
? {
type: 'groupBy',
columnId: propertyId,
name: name,
hideEmpty: true,
}
: undefined;
};

View File

@@ -0,0 +1,267 @@
import hash from '@emotion/hash';
import {
addDays,
differenceInCalendarDays,
format as fmt,
isToday,
isTomorrow,
isYesterday,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
} from 'date-fns';
import type { TypeInstance } from '../logical/type.js';
import { t } from '../logical/type-presets.js';
import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js';
import { BooleanGroupView } from './renderer/boolean-group.js';
import { DateGroupView } from './renderer/date-group.js';
import { NumberGroupView } from './renderer/number-group.js';
import { SelectGroupView } from './renderer/select-group.js';
import { StringGroupView } from './renderer/string-group.js';
import type { GroupByConfig } from './types.js';
export const createGroupByConfig = <
Data extends Record<string, unknown>,
MatchType extends TypeInstance,
GroupValue = unknown,
>(
config: GroupByConfig<Data, MatchType, GroupValue>
): GroupByConfig => config as never;
export const ungroups = {
key: 'Ungroups',
value: null,
};
const WEEK_OPTS_MON = { weekStartsOn: 1 } as const;
const WEEK_OPTS_SUN = { weekStartsOn: 0 } as const;
const rangeLabel = (a: Date, b: Date) =>
`${fmt(a, 'MMM d yyyy')} – ${fmt(b, 'MMM d yyyy')}`;
function buildDateCfg(
name: string,
grouper: (ms: number | null) => { key: string; value: number | null }[],
groupName: (v: number | null) => string
): GroupByConfig {
return createGroupByConfig({
name,
matchType: t.date.instance(),
groupName: (_t, v) => groupName(v),
defaultKeys: _t => [ungroups],
valuesGroup: (v: number | null, _t) => grouper(v),
addToGroup: (grp: number | null, _old: number | null) => grp,
view: createUniComponentFromWebComponent(DateGroupView),
});
}
const dateRelativeCfg = buildDateCfg(
'date-relative',
v => {
if (v == null) return [ungroups];
const d = startOfDay(new Date(v));
const today = startOfDay(new Date());
const daysDiff = differenceInCalendarDays(d, today);
// Handle specific days
if (isToday(d)) return [{ key: 'today', value: +d }];
if (isTomorrow(d)) return [{ key: 'tomorrow', value: +d }];
if (isYesterday(d)) return [{ key: 'yesterday', value: +d }];
// Handle future dates
if (daysDiff > 0) {
if (daysDiff <= 7) return [{ key: 'next7', value: +d }];
if (daysDiff <= 30) return [{ key: 'next30', value: +d }];
// Group by month for future dates beyond 30 days
const m = startOfMonth(d);
return [{ key: `${+m}`, value: +m }];
}
// Handle past dates
const daysAgo = -daysDiff;
if (daysAgo <= 7) return [{ key: 'last7', value: +d }];
if (daysAgo <= 30) return [{ key: 'last30', value: +d }];
// Group by month for past dates beyond 30 days
const m = startOfMonth(d);
return [{ key: `${+m}`, value: +m }];
},
v => {
if (v == null) return '';
const d = startOfDay(new Date(v));
const today = startOfDay(new Date());
const daysDiff = differenceInCalendarDays(d, today);
// Handle specific days
if (isToday(d)) return 'Today';
if (isTomorrow(d)) return 'Tomorrow';
if (isYesterday(d)) return 'Yesterday';
// Handle future dates
if (daysDiff > 0) {
if (daysDiff <= 7) return 'Next 7 days';
if (daysDiff <= 30) return 'Next 30 days';
// Show month/year for future dates beyond 30 days
return fmt(new Date(v), 'MMM yyyy');
}
// Handle past dates
const daysAgo = -daysDiff;
if (daysAgo <= 7) return 'Last 7 days';
if (daysAgo <= 30) return 'Last 30 days';
// Show month/year for past dates beyond 30 days
return fmt(new Date(v), 'MMM yyyy');
}
);
const dateDayCfg = buildDateCfg(
'date-day',
v => {
if (v == null) return [ungroups];
const d = startOfDay(new Date(v));
return [{ key: `${+d}`, value: +d }];
},
v => (v ? fmt(new Date(v), 'MMM d yyyy') : '')
);
const dateWeekSunCfg = buildDateCfg(
'date-week-sun',
v => {
if (v == null) return [ungroups];
const w = startOfWeek(new Date(v), WEEK_OPTS_SUN);
return [{ key: `${+w}`, value: +w }];
},
v => (v ? rangeLabel(new Date(v), addDays(new Date(v), 6)) : '')
);
const dateWeekMonCfg = buildDateCfg(
'date-week-mon',
v => {
if (v == null) return [ungroups];
const w = startOfWeek(new Date(v), WEEK_OPTS_MON);
return [{ key: `${+w}`, value: +w }];
},
v => (v ? rangeLabel(new Date(v), addDays(new Date(v), 6)) : '')
);
const dateMonthCfg = buildDateCfg(
'date-month',
v => {
if (v == null) return [ungroups];
const m = startOfMonth(new Date(v));
return [{ key: `${+m}`, value: +m }];
},
v => (v ? fmt(new Date(v), 'MMM yyyy') : '')
);
const dateYearCfg = buildDateCfg(
'date-year',
v => {
if (v == null) return [ungroups];
const y = startOfYear(new Date(v));
return [{ key: `${+y}`, value: +y }];
},
v => (v ? fmt(new Date(v), 'yyyy') : '')
);
export const groupByMatchers: GroupByConfig[] = [
createGroupByConfig({
name: 'select',
matchType: t.tag.instance(),
groupName: (type, value: string | null) => {
if (t.tag.is(type) && type.data)
return type.data.find(v => v.id === value)?.value ?? '';
return '';
},
defaultKeys: type =>
t.tag.is(type) && type.data
? [ungroups, ...type.data.map(v => ({ key: v.id, value: v.id }))]
: [ungroups],
valuesGroup: (value, _t) =>
value == null ? [ungroups] : [{ key: `${value}`, value }],
addToGroup: (v: string | null, _old: string | null) => v,
view: createUniComponentFromWebComponent(SelectGroupView),
}),
createGroupByConfig({
name: 'multi-select',
matchType: t.array.instance(t.tag.instance()),
groupName: (type, value: string | null) => {
if (t.array.is(type) && t.tag.is(type.element) && type.element.data)
return type.element.data.find(v => v.id === value)?.value ?? '';
return '';
},
defaultKeys: type =>
t.array.is(type) && t.tag.is(type.element) && type.element.data
? [
ungroups,
...type.element.data.map(v => ({ key: v.id, value: v.id })),
]
: [ungroups],
valuesGroup: (value, _t) => {
if (value == null) return [ungroups];
if (Array.isArray(value) && value.length)
return value.map(id => ({ key: `${id}`, value: id }));
return [ungroups];
},
addToGroup: (
value: string | null,
old: string[] | null
): string[] | null => {
if (value == null) return old;
return Array.isArray(old) ? [...old, value] : [value];
},
removeFromGroup: (value, old) =>
Array.isArray(old) ? old.filter(v => v !== value) : old,
view: createUniComponentFromWebComponent(SelectGroupView),
}),
createGroupByConfig({
name: 'text',
matchType: t.string.instance(),
groupName: (_t, v) => `${v ?? ''}`,
defaultKeys: _t => [ungroups],
valuesGroup: (v, _t) =>
typeof v !== 'string' || !v ? [ungroups] : [{ key: hash(v), value: v }],
addToGroup: (v: string | null, _old: string | null) => v,
view: createUniComponentFromWebComponent(StringGroupView),
}),
createGroupByConfig({
name: 'number',
matchType: t.number.instance(),
groupName: (_t, v) => `${v ?? ''}`,
defaultKeys: _t => [ungroups],
valuesGroup: (v, _t) =>
typeof v !== 'number'
? [ungroups]
: [{ key: `g:${Math.floor(v / 10)}`, value: Math.floor(v / 10) }],
addToGroup: (v: number | null, _old: number | null) =>
typeof v === 'number' ? v * 10 : null,
view: createUniComponentFromWebComponent(NumberGroupView),
}),
createGroupByConfig({
name: 'boolean',
matchType: t.boolean.instance(),
groupName: (_t, v) => `${v?.toString() ?? ''}`,
defaultKeys: _t => [
{ key: 'true', value: true },
{ key: 'false', value: false },
],
valuesGroup: (v, _t) =>
typeof v !== 'boolean'
? [{ key: 'false', value: false }]
: [{ key: v.toString(), value: v }],
addToGroup: (v: boolean | null, _old: boolean | null) => v,
view: createUniComponentFromWebComponent(BooleanGroupView),
}),
dateRelativeCfg,
dateDayCfg,
dateWeekSunCfg,
dateWeekMonCfg,
dateMonthCfg,
dateYearCfg,
];

View File

@@ -0,0 +1,229 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { MoreHorizontalIcon, PlusIcon } from '@blocksuite/icons/lit';
import { nothing } from 'lit';
import { html } from 'lit/static-html.js';
import { renderUniLit } from '../utils/uni-component/uni-component.js';
import type { Group } from './trait.js';
import type { GroupRenderProps } from './types.js';
function GroupHeaderCount(group: Group) {
const cards = group.rows;
if (!cards.length) {
return;
}
return html` <div class="group-header-count">${cards.length}</div>`;
}
const GroupTitleMobile = (
groupData: Group,
ops: {
readonly: boolean;
clickAdd: (evt: MouseEvent) => void;
clickOps: (evt: MouseEvent) => void;
}
) => {
const type = groupData.tType;
if (!type) return nothing;
const icon = html` <uni-lit
class="group-header-icon"
.uni="${groupData.property.icon}"
></uni-lit>`;
const props: GroupRenderProps = {
group: groupData,
readonly: ops.readonly,
};
const showColumnName = groupData.property.type$.value === 'checkbox';
const columnName = showColumnName
? html`<span class="group-header-title"
>${groupData.property.name$.value}</span
>`
: nothing;
return html`
<style>
.group-header-count {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 4px;
background-color: var(--affine-background-secondary-color);
display: flex;
align-items: center;
justify-content: center;
color: var(--affine-text-secondary-color);
font-size: var(--data-view-cell-text-size);
}
.group-header-name {
flex: 1;
overflow: hidden;
}
.group-header-ops {
display: flex;
align-items: center;
}
.group-header-op {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px;
border-radius: 4px;
font-size: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.group-header-icon {
display: flex;
align-items: center;
margin-right: -4px;
font-size: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.group-header-title {
color: ${unsafeCSSVarV2('text/primary')};
font-size: var(--data-view-cell-text-size);
}
</style>
<div
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
>
${icon} ${renderUniLit(groupData.view, props)} ${columnName}
${GroupHeaderCount(groupData)}
</div>
${
ops.readonly
? nothing
: html` <div class="group-header-ops">
<div @click="${ops.clickAdd}" class="group-header-op add-card">
${PlusIcon()}
</div>
<div @click="${ops.clickOps}" class="group-header-op">
${MoreHorizontalIcon()}
</div>
</div>`
}
`;
};
export const GroupTitle = (
groupData: Group,
ops: {
readonly: boolean;
clickAdd: (evt: MouseEvent) => void;
clickOps: (evt: MouseEvent) => void;
}
) => {
if (IS_MOBILE) {
return GroupTitleMobile(groupData, ops);
}
const type = groupData.tType;
if (!type) return nothing;
const icon = html` <uni-lit
class="group-header-icon"
.uni="${groupData.property.icon}"
></uni-lit>`;
const props: GroupRenderProps = {
group: groupData,
readonly: ops.readonly,
};
const showColumnName = groupData.property.type$.value === 'checkbox';
const columnName = showColumnName
? html`<span class="group-header-title"
>${groupData.property.name$.value}</span
>`
: nothing;
return html`
<style>
.group-header-count {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 4px;
background-color: var(--affine-background-secondary-color);
display: flex;
align-items: center;
justify-content: center;
color: var(--affine-text-secondary-color);
font-size: var(--data-view-cell-text-size);
}
.group-header-name {
flex: 1;
overflow: hidden;
}
.group-header-ops {
display: flex;
align-items: center;
}
.group-header-op {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px;
border-radius: 4px;
visibility: hidden;
opacity: 0;
transition: all 150ms cubic-bezier(0.42, 0, 1, 1);
}
.group-header-icon {
display: flex;
align-items: center;
margin-right: -4px;
}
.group-header-icon svg {
width: 16px;
height: 16px;
color: var(--affine-icon-color);
fill: var(--affine-icon-color);
}
.group-header-op:hover {
background-color: var(--affine-hover-color);
}
.group-header-op svg {
width: 16px;
height: 16px;
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
}
.group-header-title {
color: ${unsafeCSSVarV2('text/primary')};
font-size: var(--data-view-cell-text-size);
margin-left: 4px;
}
</style>
<div
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
>
${icon} ${renderUniLit(groupData.view, props)} ${columnName}
${GroupHeaderCount(groupData)}
</div>
${
ops.readonly
? nothing
: html` <div class="group-header-ops">
<div @click="${ops.clickAdd}" class="group-header-op add-card">
${PlusIcon()}
</div>
<div @click="${ops.clickOps}" class="group-header-op">
${MoreHorizontalIcon()}
</div>
</div>`
}
`;
};

View File

@@ -0,0 +1,3 @@
export * from './define.js';
export * from './matcher.js';
export * from './trait.js';

View File

@@ -0,0 +1,53 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { DataSource } from '../data-source/base.js';
import { Matcher_ } from '../logical/matcher.js';
import { groupByMatchers } from './define.js';
import type { GroupByConfig } from './types.js';
export const createGroupByMatcher = (list: GroupByConfig[]) => {
return new Matcher_(list, v => v.matchType);
};
export const findGroupByConfigByName = (
dataSource: DataSource,
name: string
): GroupByConfig | undefined => {
const svc = getGroupByService(dataSource);
const all: GroupByConfig[] = [
...svc.allExternalGroupByConfig(),
...groupByMatchers,
];
return all.find(c => c.name === name);
};
export class GroupByService {
constructor(private readonly dataSource: DataSource) {}
allExternalGroupByConfig(): GroupByConfig[] {
return Array.from(
this.dataSource.provider.getAll(ExternalGroupByConfigProvider).values()
);
}
get matcher() {
return createGroupByMatcher([
...this.allExternalGroupByConfig(),
...groupByMatchers,
]);
}
}
export const GroupByProvider =
createIdentifier<GroupByService>('group-by-service');
export const getGroupByService = (dataSource: DataSource) => {
return dataSource.serviceGetOrCreate(
GroupByProvider,
() => new GroupByService(dataSource)
);
};
export const ExternalGroupByConfigProvider = createIdentifier<GroupByConfig>(
'external-group-by-config'
);

View File

@@ -0,0 +1,40 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { property } from 'lit/decorators.js';
import type { Group } from '../trait.js';
import type { GroupRenderProps } from '../types.js';
export class BaseGroup<JsonValue, Data extends Record<string, unknown>>
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements GroupRenderProps<JsonValue, Data>
{
@property({ attribute: false })
accessor group!: Group<unknown, JsonValue, Data>;
@property({ attribute: false })
accessor readonly!: boolean;
updateData(data: Data) {
this.group.manager.updateData(data);
}
updateValue(value: JsonValue) {
this.group.manager.updateValue(
this.group.rows.map(row => row.rowId),
value
);
}
get value(): JsonValue {
return this.group.value as JsonValue;
}
get type() {
return this.group.tType;
}
get data() {
return this.group.property.data$.value;
}
}

View File

@@ -0,0 +1,35 @@
import { CheckBoxCheckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class BooleanGroupView extends BaseGroup<boolean, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-boolean-view {
display: flex;
align-items: center;
}
.data-view-group-title-boolean-view svg {
width: 20px;
height: 20px;
}
`;
protected override render(): unknown {
// Handle null/undefined values
if (this.value == null) {
const displayName = `No ${this.group.property.name$.value ?? 'value'}`;
return html` <div class="data-view-group-title-boolean-view">
${displayName}
</div>`;
}
return html` <div class="data-view-group-title-boolean-view">
${
this.value
? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` })
: CheckBoxUnIcon()
}
</div>`;
}
}

View File

@@ -0,0 +1,54 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import type { Group } from '../trait.js';
export class DateGroupView extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.dv-date-group {
border-radius: 8px;
padding: 4px 8px;
width: max-content;
cursor: default;
display: flex;
align-items: center;
gap: 6px;
}
.dv-date-group:hover {
background-color: var(--affine-hover-color);
}
.counter {
flex-shrink: 0;
min-width: 22px;
height: 22px;
border-radius: 4px;
background: var(--affine-background-secondary-color);
color: var(--affine-text-secondary-color);
font-size: var(--data-view-cell-text-size);
display: flex;
align-items: center;
justify-content: center;
}
`;
@property({ attribute: false })
accessor group!: Group;
protected override render() {
const name = this.group.name$.value;
// Use contextual name based on the property when value is null
const displayName =
name ||
(this.group.value === null
? `No ${this.group.property.name$.value}`
: 'Ungroups');
return html`<div class="dv-date-group">
<span>${displayName}</span>
</div>`;
}
}
customElements.define('data-view-date-group-view', DateGroupView);

View File

@@ -0,0 +1,66 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class NumberGroupView extends BaseGroup<number, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-number-view {
border-radius: 8px;
padding: 4px 8px;
width: max-content;
cursor: pointer;
}
.data-view-group-title-number-view:hover {
background-color: var(--affine-hover-color);
}
`;
private readonly _click = () => {
if (this.readonly) {
return;
}
popMenu(popupTargetFromElement(this), {
options: {
items: [
menu.input({
initialValue: this.value ? `${this.value * 10}` : '',
onChange: text => {
const num = Number.parseFloat(text);
if (Number.isNaN(num)) {
return;
}
this.updateValue?.(num);
},
}),
],
},
});
};
protected override render(): unknown {
if (this.value == null) {
const displayName = `No ${this.group.property.name$.value}`;
return html` <div>${displayName}</div>`;
}
if (this.value >= 10) {
return html` <div
@click="${this._click}"
class="data-view-group-title-number-view"
>
>= 100
</div>`;
}
return html` <div
@click="${this._click}"
class="data-view-group-title-number-view"
>
${this.value * 10} - ${this.value * 10 + 9}
</div>`;
}
}

View File

@@ -0,0 +1,120 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { css, html } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { selectOptionColors } from '../../component/tags/colors.js';
import type { SelectTag } from '../../logical/index.js';
import { BaseGroup } from './base.js';
export class SelectGroupView extends BaseGroup<
string,
{
options: SelectTag[];
}
> {
static override styles = css`
data-view-group-title-select-view {
overflow: hidden;
}
.data-view-group-title-select-view {
width: 100%;
cursor: pointer;
}
.data-view-group-title-select-view.readonly {
cursor: inherit;
}
.tag {
padding: 0 8px;
border-radius: 4px;
font-size: var(--data-view-cell-text-size);
line-height: var(--data-view-cell-text-line-height);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
private readonly _click = (e: MouseEvent) => {
if (this.readonly) {
return;
}
e.stopPropagation();
popMenu(popupTargetFromElement(this), {
options: {
items: [
menu.input({
initialValue: this.tag?.value ?? '',
onChange: text => {
this.updateTag({ value: text });
},
}),
...selectOptionColors.map(({ color, name }) => {
const styles = styleMap({
backgroundColor: color,
borderRadius: '50%',
width: '20px',
height: '20px',
});
return menu.action({
name: name,
isSelected: this.tag?.color === color,
prefix: html` <div style=${styles}></div>`,
select: () => {
this.updateTag({ color });
},
});
}),
],
},
});
};
get tag() {
return this.data.options?.find(v => v.id === this.value);
}
protected override render(): unknown {
const tag = this.tag;
if (!tag) {
const displayName = `No ${this.group.property.name$.value}`;
return html` <div
style="font-size: 14px;color: var(--affine-text-primary-color);line-height: 22px;"
>
${displayName}
</div>`;
}
const style = styleMap({
backgroundColor: tag.color,
});
const classList = classMap({
'data-view-group-title-select-view': true,
readonly: this.readonly,
});
return html` <div @click="${this._click}" class="${classList}">
<div class="tag" style="${style}">${tag.value}</div>
</div>`;
}
updateTag(tag: Partial<SelectTag>) {
this.updateData?.({
...this.data,
options: this.data.options.map(v => {
if (v.id === this.value) {
return {
...v,
...tag,
};
}
return v;
}),
});
}
}

View File

@@ -0,0 +1,54 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { css, html } from 'lit';
import { BaseGroup } from './base.js';
export class StringGroupView extends BaseGroup<string, NonNullable<unknown>> {
static override styles = css`
.data-view-group-title-string-view {
border-radius: 8px;
padding: 4px 8px;
width: max-content;
cursor: pointer;
}
.data-view-group-title-string-view:hover {
background-color: var(--affine-hover-color);
}
`;
private readonly _click = () => {
if (this.readonly) {
return;
}
popMenu(popupTargetFromElement(this), {
options: {
items: [
menu.input({
initialValue: this.value ?? '',
onComplete: text => {
this.updateValue?.(text);
},
}),
],
},
});
};
protected override render(): unknown {
if (!this.value) {
const displayName = `No ${this.group.property.name$.value}`;
return html` <div>${displayName}</div>`;
}
return html` <div
@click="${this._click}"
class="data-view-group-title-string-view"
>
${this.value}
</div>`;
}
}

View File

@@ -0,0 +1,578 @@
import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { canGroupable } from '../../view-presets/kanban/group-by-utils.js';
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
import { dataViewCssVariable } from '../common/css-variable.js';
import { renderUniLit } from '../utils/uni-component/uni-component.js';
import { dragHandler } from '../utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../utils/wc-dnd/sensors/index.js';
import {
createSortContext,
sortable,
} from '../utils/wc-dnd/sort/sort-context.js';
import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js';
import { getGroupByService } from './matcher.js';
import type { GroupTrait } from './trait.js';
import type { GroupRenderProps } from './types.js';
const dateModeLabel = (key?: string) => {
switch (key) {
case 'date-relative':
return 'Relative';
case 'date-day':
return 'Day';
case 'date-week-mon':
case 'date-week-sun':
return 'Week';
case 'date-month':
return 'Month';
case 'date-year':
return 'Year';
default:
return '';
}
};
export class GroupSetting extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
data-view-group-setting {
display: flex;
flex-direction: column;
gap: 4px;
${unsafeCSS(dataViewCssVariable())};
}
.group-sort-setting {
display: flex;
flex-direction: column;
gap: 4px;
z-index: 1;
max-height: 200px;
overflow: hidden auto;
margin-right: 0;
margin-bottom: 0;
}
/* WebKit-based browser scrollbar styling */
.group-sort-setting::-webkit-scrollbar {
width: 8px;
}
.group-sort-setting::-webkit-scrollbar-thumb {
background-color: #b0b0b0; /* Grey slider */
border-radius: 4px;
}
.group-sort-setting::-webkit-scrollbar-track {
background: transparent;
}
.group-sort-setting {
scrollbar-width: thin;
scrollbar-color: #b0b0b0 transparent;
}
.group-hidden {
opacity: 0.5;
}
.group-item {
display: flex;
padding: 4px 12px;
position: relative;
cursor: grab;
}
.group-item-drag-bar {
width: 4px;
height: 12px;
border-radius: 1px;
background-color: #efeff0;
position: absolute;
left: 4px;
top: 0;
bottom: 0;
margin: auto;
}
.group-item:hover .group-item-drag-bar {
background-color: #c0bfc1;
}
.group-item-op-icon {
display: flex;
align-items: center;
border-radius: 4px;
}
.group-item-op-icon:hover {
background-color: var(--affine-hover-color);
}
.group-item-op-icon svg {
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
width: 20px;
height: 20px;
}
.group-item-name {
font-size: 14px;
line-height: 22px;
flex: 1;
}
.properties-group-op {
padding: 4px 8px;
font-size: 12px;
line-height: 20px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
color: ${unsafeCSS(cssVarV2.button.primary)};
}
.properties-group-op:hover {
background-color: var(--affine-hover-color);
}
`;
@property({ attribute: false })
accessor groupTrait!: GroupTrait;
groups$ = computed(() => this.groupTrait.groupsDataListAll$.value);
sortContext = createSortContext({
activators: defaultActivators,
container: this,
onDragEnd: evt => {
const over = evt.over;
const activeId = evt.active.id;
const groups = this.groups$.value;
if (over && over.id !== activeId && groups) {
const aIndex = groups.findIndex(g => g?.key === activeId);
const oIndex = groups.findIndex(g => g?.key === over.id);
this.groupTrait.moveGroupTo(
activeId,
aIndex > oIndex
? { before: true, id: over.id }
: { before: false, id: over.id }
);
}
},
modifiers: [({ transform }) => ({ ...transform, x: 0 })],
items: computed(
() =>
this.groupTrait.groupsDataListAll$.value?.map(v => v?.key ?? '') ?? []
),
strategy: verticalListSortingStrategy,
});
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(this, 'pointerdown', e =>
e.stopPropagation()
);
}
protected override render() {
const groups = this.groupTrait.groupsDataListAll$.value;
if (!groups) return;
const map = this.groupTrait.groupDataMap$.value;
const isAllShowed = map
? Object.keys(map).every(k => !this.groupTrait.isGroupHidden(k))
: true;
const clickChangeAll = () => {
if (!map) return;
Object.keys(map).forEach(key => {
this.groupTrait.setGroupHide(key, isAllShowed);
});
};
return html`
<div
style="padding:7px 0;display:flex;justify-content:space-between;align-items:center;"
>
<div
style="padding:0 4px;font-size:12px;color:var(--affine-text-secondary-color);line-height:20px;"
>
Groups
</div>
<div class="properties-group-op" @click="${clickChangeAll}">
${isAllShowed ? 'Hide All' : 'Show All'}
</div>
</div>
<div class="group-sort-setting">
${repeat(
groups,
g => g?.key ?? 'k',
g => {
if (!g) return;
const type = g.property.dataType$.value;
if (!type) return;
const props: GroupRenderProps = { group: g, readonly: true };
const icon = g.hide$.value ? InvisibleIcon() : ViewIcon();
return html`
<div
${sortable(g.key)}
${dragHandler(g.key)}
class="dv-hover dv-round-4 group-item ${
g.hide$.value ? 'group-hidden' : ''
}"
>
<div class="group-item-drag-bar"></div>
<div
class="group-item-name"
style="padding:0 4px;position:relative;pointer-events:none;max-width:330px;"
>
${renderUniLit(g.view, props)}
<div
style="position:absolute;left:0;top:0;right:0;bottom:0;"
></div>
</div>
<div
class="group-item-op-icon"
@click="${() => g.hideSet(!g.hide$.value)}"
>
${icon}
</div>
</div>
`;
}
)}
</div>
`;
}
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const buildGroupSelectItems = (
group: GroupTrait,
onSelect: (id?: string) => void
): MenuConfig[] => {
const view = group.view;
return [
menu.group({
items: view.propertiesRaw$.value
.filter(property => {
if (property.type$.value === 'title') {
return false;
}
if (view instanceof KanbanSingleView) {
return canGroupable(view.manager.dataSource, property.id);
}
const dataType = property.dataType$.value;
if (!dataType) {
return false;
}
const groupByService = getGroupByService(view.manager.dataSource);
return !!groupByService?.matcher.match(dataType);
})
.map<MenuConfig>(property =>
menu.action({
name: property.name$.value,
isSelected: group.property$.value?.id === property.id,
prefix: html`<uni-lit .uni="${property.icon}"></uni-lit>`,
select: () => {
group.changeGroup(property.id);
onSelect(property.id);
return false;
},
})
),
}),
menu.group({
items: [
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
group.changeGroup(undefined);
onSelect(undefined);
return false;
},
}),
],
}),
];
};
export const buildGroupSettingItems = (
group: GroupTrait,
onGroupByClick: () => void,
onGroupRemoved?: () => void
): MenuConfig[] => {
const view = group.view;
const gProp = group.property$.value;
if (!gProp) return [];
const type = gProp.type$.value;
if (!type) return [];
const icon = gProp.icon;
return [
menu.group({
items: [
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
class="dv-icon-16"
>
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
select: () => {
onGroupByClick();
return false;
},
}),
],
}),
...(type === 'date'
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Date by',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
>
${dateModeLabel(group.groupInfo$.value?.config.name)}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Relative', 'date-relative'],
['Day', 'date-day'],
[
'Week',
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'date-week-mon'
: 'date-week-sun',
],
['Month', 'date-month'],
['Year', 'date-year'],
] as [string, string][]
).map(([label, key]): MenuConfig =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name === key;
return html`<span
style="font-size:14px;color:${
isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'
}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
? [
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Start week on',
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${
group.groupInfo$.value?.config.name ===
'date-week-mon'
? 'Monday'
: 'Sunday'
}
</div>
`,
options: {
items: [
menu.dynamic(() =>
(
[
['Monday', 'date-week-mon'],
['Sunday', 'date-week-sun'],
] as [string, string][]
).map(([label, key]) =>
menu.action({
name: label,
label: () => {
const isSelected =
group.groupInfo$.value?.config.name ===
key;
return html`<span
style="font-size:14px;color:${
isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'
}"
>${label}</span
>`;
},
isSelected:
group.groupInfo$.value?.config.name === key,
select: () => {
group.changeGroupMode(key);
return false;
},
})
)
),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.subMenu({
name: 'Sort',
openOnHover: false,
middleware: dropdownSubMenuMiddleware,
autoHeight: true,
postfix: html`
<div
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
>
${group.sortAsc$.value ? 'Oldest first' : 'Newest first'}
</div>
`,
options: {
items: [
menu.dynamic(() => [
menu.action({
name: 'Oldest first',
label: () => {
const isSelected = group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${
isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'
}"
>Oldest first</span
>`;
},
isSelected: group.sortAsc$.value,
select: () => {
group.setDateSortOrder(true);
return false;
},
}),
menu.action({
name: 'Newest first',
label: () => {
const isSelected = !group.sortAsc$.value;
return html`<span
style="font-size:14px;color:${
isSelected
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)'
}"
>Newest first</span
>`;
},
isSelected: !group.sortAsc$.value,
select: () => {
group.setDateSortOrder(false);
return false;
},
}),
]),
],
},
}),
]),
],
}),
]
: []),
menu.group({
items: [
menu.dynamic(() => [
menu.action({
name: 'Hide empty groups',
isSelected: group.hideEmpty$.value,
select: () => {
group.setHideEmpty(!group.hideEmpty$.value);
return false;
},
}),
]),
],
}),
menu.group({
items: [
menuObj => html`
<data-view-group-setting
@mouseenter=${() => menuObj.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
name: 'Remove grouping',
prefix: DeleteIcon(),
class: { 'delete-item': true },
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
onGroupRemoved?.();
return false;
},
}),
],
}),
];
};

View File

@@ -0,0 +1,560 @@
import {
insertPositionToIndex,
type InsertToPosition,
} from '@blocksuite/affine-shared/utils';
import {
computed,
effect,
type ReadonlySignal,
signal,
} from '@preact/signals-core';
import type { GroupBy, GroupProperty } from '../common/types.js';
import type { TypeInstance } from '../logical/type.js';
import { createTraitKey } from '../traits/key.js';
import { computedLock } from '../utils/lock.js';
import type { Property } from '../view-manager/property.js';
import type { Row } from '../view-manager/row.js';
import type { SingleView } from '../view-manager/single-view.js';
import { compareDateKeys } from './compare-date-keys.js';
import { defaultGroupBy } from './default.js';
import { findGroupByConfigByName, getGroupByService } from './matcher.js';
import type { GroupByConfig } from './types.js';
export type GroupInfo<
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> = {
config: GroupByConfig;
property: Property<RawValue, JsonValue, Data>;
tType: TypeInstance;
};
export class Group<
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
rows: Row[] = [];
constructor(
public readonly key: string,
public readonly value: JsonValue,
private readonly groupInfo: GroupInfo<RawValue, JsonValue, Data>,
public readonly manager: GroupTrait
) {}
get property() {
return this.groupInfo.property;
}
name$ = computed(() => {
const type = this.property.dataType$.value;
return type ? this.groupInfo.config.groupName(type, this.value) : '';
});
private get config() {
return this.groupInfo.config;
}
get tType() {
return this.groupInfo.tType;
}
get view() {
return this.config.view;
}
hide$ = computed(() => {
const groupHide =
this.manager.groupPropertiesMap$.value[this.key]?.hide ?? false;
const emptyHidden = this.manager.hideEmpty$.value && this.rows.length === 0;
return groupHide || emptyHidden;
});
hideSet(hide: boolean) {
this.manager.setGroupHide(this.key, hide);
}
}
function hasGroupProperties(
data: unknown
): data is { groupProperties?: GroupProperty[] } {
if (typeof data !== 'object' || data === null) {
return false;
}
if (!('groupProperties' in data)) {
return false;
}
const value = (data as { groupProperties?: unknown }).groupProperties;
return value === undefined || Array.isArray(value);
}
const getOrderedGroupKeys = (
keys: string[],
groupInfo: GroupInfo | undefined,
sortGroup: (keys: string[], asc?: boolean) => string[],
sortAsc: boolean
) => {
if (groupInfo?.config.matchType.name === 'Date') {
return [...keys].sort(compareDateKeys(groupInfo.config.name, sortAsc));
}
return sortGroup(keys, sortAsc);
};
const applyGroupRowSort = <T extends { rows: Row[] }>(
groups: Record<string, T>,
orderedKeys: string[],
sortRow: (groupKey: string, rows: Row[]) => Row[]
) => {
orderedKeys.forEach(key => {
const group = groups[key];
if (!group) {
return;
}
group.rows = sortRow(key, group.rows);
});
};
const reorderGroupKeys = (
keys: string[],
groupKey: string,
position: InsertToPosition
) => {
const currentIndex = keys.findIndex(key => key === groupKey);
if (currentIndex < 0) {
return keys;
}
if (typeof position === 'object') {
if (position.id === groupKey) {
return keys;
}
if (!keys.includes(position.id)) {
return keys;
}
}
const reordered = [...keys];
reordered.splice(currentIndex, 1);
const index = insertPositionToIndex(position, reordered, key => key);
if (index < 0) {
return keys;
}
reordered.splice(index, 0, groupKey);
return reordered;
};
export class GroupTrait {
hideEmpty$ = signal<boolean>(true);
sortAsc$ = signal<boolean>(true);
groupProperties$ = computed(() => {
const data = this.view.data$.value;
return hasGroupProperties(data) ? (data.groupProperties ?? []) : [];
});
groupPropertiesMap$ = computed(() => {
const map: Record<string, GroupProperty> = {};
this.groupProperties$.value.forEach(g => {
map[g.key] = g;
});
return map;
});
/**
* Synchronize sortAsc$ with the GroupBy sort descriptor
*/
constructor(
private readonly groupBy$: ReadonlySignal<GroupBy | undefined>,
public view: SingleView,
private readonly ops: {
groupBySet: (g: GroupBy | undefined) => void;
sortGroup: (keys: string[], asc?: boolean) => string[];
sortRow: (groupKey: string, rows: Row[]) => Row[];
changeGroupSort: (keys: string[]) => void;
changeRowSort: (
groupKeys: string[],
groupKey: string,
keys: string[]
) => void;
changeGroupHide?: (key: string, hide: boolean) => void;
}
) {
effect(() => {
const desc = this.groupBy$.value?.sort?.desc;
if (desc != null && this.sortAsc$.value === desc) {
this.sortAsc$.value = !desc;
}
});
// Sync hideEmpty state with GroupBy data
effect(() => {
const hide = this.groupBy$.value?.hideEmpty;
if (hide != null && this.hideEmpty$.value !== hide) {
this.hideEmpty$.value = hide;
}
});
}
groupInfo$ = computed<GroupInfo | undefined>(() => {
const groupBy = this.groupBy$.value;
if (!groupBy) return;
const property = this.view.propertyGetOrCreate(groupBy.columnId);
if (!property) return;
const tType = property.dataType$.value;
if (!tType) return;
const svc = getGroupByService(this.view.manager.dataSource);
const res =
groupBy.name != null
? (findGroupByConfigByName(
this.view.manager.dataSource,
groupBy.name
) ?? svc?.matcher.match(tType))
: svc?.matcher.match(tType);
if (!res) return;
return { config: res, property, tType };
});
staticInfo$ = computed(() => {
const info = this.groupInfo$.value;
if (!info) return;
const staticMap = Object.fromEntries(
info.config
.defaultKeys(info.tType)
.map(({ key, value }) => [key, new Group(key, value, info, this)])
);
return { staticMap, groupInfo: info };
});
groupDataMap$ = computed(() => {
const si = this.staticInfo$.value;
if (!si) return;
const { staticMap, groupInfo } = si;
// Create fresh Group instances with empty rows arrays
const map: Record<string, Group> = {};
Object.entries(staticMap).forEach(([key, group]) => {
map[key] = new Group(key, group.value, groupInfo, this);
});
// Assign rows to their respective groups
this.view.rows$.value.forEach(row => {
const cell = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id);
const jv = cell.jsonValue$.value;
const keys = groupInfo.config.valuesGroup(jv, groupInfo.tType);
keys.forEach(({ key, value }) => {
if (!map[key]) map[key] = new Group(key, value, groupInfo, this);
map[key].rows.push(row);
});
});
return map;
});
groupsDataList$ = computedLock(
computed(() => {
const map = this.groupDataMap$.value;
if (!map) return;
const gi = this.groupInfo$.value;
const ordered = getOrderedGroupKeys(
Object.keys(map),
gi,
this.ops.sortGroup,
this.sortAsc$.value
);
applyGroupRowSort(map, ordered, this.ops.sortRow);
return ordered
.map(k => map[k])
.filter(
(g): g is Group =>
!!g &&
!this.isGroupHidden(g.key) &&
(!this.hideEmpty$.value || g.rows.length > 0)
);
}),
this.view.isLocked$
);
/**
* Computed list of groups including hidden ones, used by settings UI.
*/
groupsDataListAll$ = computedLock(
computed(() => {
const map = this.groupDataMap$.value;
const info = this.groupInfo$.value;
if (!map || !info) return;
const orderedKeys = getOrderedGroupKeys(
Object.keys(map),
info,
this.ops.sortGroup,
this.sortAsc$.value
);
applyGroupRowSort(map, orderedKeys, this.ops.sortRow);
const visible: Group[] = [];
const hidden: Group[] = [];
orderedKeys
.map(key => map[key])
.filter((g): g is Group => g != null)
.forEach(g => {
if (g.hide$.value) {
hidden.push(g);
} else {
visible.push(g);
}
});
return [...visible, ...hidden];
}),
this.view.isLocked$
);
/** Whether all groups are currently hidden */
allHidden$ = computed(() => {
const map = this.groupDataMap$.value;
if (!map) return false;
return Object.keys(map).every(key => this.isGroupHidden(key));
});
/**
* Toggle hiding of empty groups.
*/
setHideEmpty(value: boolean) {
this.hideEmpty$.value = value;
const gb = this.groupBy$.value;
if (gb) {
this.ops.groupBySet({ ...gb, hideEmpty: value });
}
}
isGroupHidden(key: string): boolean {
return this.groupPropertiesMap$.value[key]?.hide ?? false;
}
setGroupHide(key: string, hide: boolean) {
this.ops.changeGroupHide?.(key, hide);
}
/**
* Set sort order for date groupings and update GroupBy sort descriptor.
*/
setDateSortOrder(asc: boolean) {
this.sortAsc$.value = asc;
const gb = this.groupBy$.value;
if (gb) {
this.ops.groupBySet({
...gb,
sort: { desc: !asc },
hideEmpty: gb.hideEmpty,
});
}
}
addToGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
const groupInfo = this.groupInfo$.value;
if (!groupMap || !groupInfo) {
return;
}
const addTo = groupInfo.config.addToGroup;
if (addTo === false) {
return;
}
const v = groupMap[key]?.value;
if (v != null) {
const newValue = addTo(
v,
this.view.cellGetOrCreate(rowId, groupInfo.property.id).jsonValue$.value
);
this.view
.cellGetOrCreate(rowId, groupInfo.property.id)
.valueSet(newValue);
}
const map = this.groupDataMap$.value;
const info = this.groupInfo$.value;
if (!map || !info) return;
const addFn = info.config.addToGroup;
if (addFn === false) return;
const group = map[key];
if (!group) return;
const current = group.value;
// Handle both null and non-null values to ensure proper group assignment
const newVal = addFn(
current,
this.view.cellGetOrCreate(rowId, info.property.id).jsonValue$.value
);
this.view.cellGetOrCreate(rowId, info.property.id).valueSet(newVal);
}
changeGroupMode(modeName: string) {
const propId = this.property$.value?.id;
if (!propId) return;
this.ops.groupBySet({
type: 'groupBy',
columnId: propId,
name: modeName,
sort: { desc: !this.sortAsc$.value },
hideEmpty: this.hideEmpty$.value,
});
}
changeGroup(columnId: string | undefined) {
if (columnId == null) {
this.ops.groupBySet(undefined);
return;
}
const column = this.view.propertyGetOrCreate(columnId);
const meta = this.view.manager.dataSource.propertyMetaGet(
column.type$.value
);
if (meta) {
const gb = defaultGroupBy(
this.view.manager.dataSource,
meta,
column.id,
column.data$.value
);
if (gb) {
gb.sort = { desc: !this.sortAsc$.value };
gb.hideEmpty = this.hideEmpty$.value;
}
this.ops.groupBySet(gb);
}
}
property$ = computed(() => this.groupInfo$.value?.property);
get addGroup() {
return this.property$.value?.meta$.value?.config.addGroup;
}
updateData = (data: NonNullable<unknown>) => {
const prop = this.property$.value;
if (!prop) return;
this.view.propertyGetOrCreate(prop.id).dataUpdate(() => data);
};
changeGroupSort(keys: string[]) {
this.ops.changeGroupSort(keys);
}
moveCardTo(
rowId: string,
fromGroupKey: string | undefined,
toGroupKey: string,
position: InsertToPosition
) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
}
if (fromGroupKey !== toGroupKey) {
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}
const remove =
this.groupInfo$.value?.config.removeFromGroup ?? (() => null);
const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined;
let newValue: unknown = null;
if (group) {
newValue = remove(
group.value,
this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value
);
}
const addTo = this.groupInfo$.value?.config.addToGroup;
if (addTo === false || addTo == null) {
return;
}
newValue = addTo(groupMap[toGroupKey]?.value ?? null, newValue);
this.view.cellGetOrCreate(rowId, propertyId).jsonValueSet(newValue);
}
const rows =
groupMap[toGroupKey]?.rows
.filter(row => row.rowId !== rowId)
.map(row => row.rowId) ?? [];
const index = insertPositionToIndex(position, rows, row => row);
rows.splice(index, 0, rowId);
const groupKeys = getOrderedGroupKeys(
Object.keys(groupMap),
this.groupInfo$.value,
this.ops.sortGroup,
this.sortAsc$.value
);
this.ops.changeRowSort(groupKeys, toGroupKey, rows);
}
moveGroupTo(groupKey: string, position: InsertToPosition) {
this.view.lockRows(false);
const groups = this.groupsDataListAll$.value;
if (!groups) {
return;
}
const currentKeys = groups.map(group => group.key);
const reorderedKeys = reorderGroupKeys(currentKeys, groupKey, position);
if (
currentKeys.length === reorderedKeys.length &&
currentKeys.every((key, index) => key === reorderedKeys[index])
) {
return;
}
this.changeGroupSort(reorderedKeys);
}
removeFromGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
}
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}
const remove =
this.groupInfo$.value?.config.removeFromGroup ?? (() => undefined);
const newValue = remove(
groupMap[key]?.value ?? null,
this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value
);
this.view.cellGetOrCreate(rowId, propertyId).valueSet(newValue);
}
updateValue(rows: string[], value: unknown) {
this.view.lockRows(false);
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;
}
rows.forEach(rowId => {
this.view.cellGetOrCreate(rowId, propertyId).jsonValueSet(value);
});
}
}
export const groupTraitKey = createTraitKey<GroupTrait>('group');
export const sortByManually = <T>(
arr: T[],
getId: (v: T) => string,
ids: string[]
) => {
const map = new Map(arr.map(v => [getId(v), v]));
const result: T[] = [];
for (const id of ids) {
const value = map.get(id);
if (value) {
map.delete(id);
result.push(value);
}
}
result.push(...map.values());
return result;
};

View File

@@ -0,0 +1,41 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { TypeInstance, ValueTypeOf } from '../logical/type.js';
import type { Group } from './trait.js';
export interface GroupRenderProps<
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
> {
group: Group<unknown, JsonValue, Data>;
readonly: boolean;
}
type AddToGroup<GroupValue, MatchType> = (
value: GroupValue | null,
oldValue: ValueTypeOf<MatchType> | null
) => ValueTypeOf<MatchType> | null;
export type GroupByConfig<
Data extends NonNullable<unknown> = NonNullable<unknown>,
MatchType extends TypeInstance = TypeInstance,
GroupValue = unknown,
> = {
name: string;
matchType: MatchType;
groupName: (type: MatchType, value: GroupValue | null) => string;
defaultKeys: (type: MatchType) => {
key: string;
value: GroupValue | null;
}[];
valuesGroup: (
value: ValueTypeOf<MatchType> | null,
type: MatchType
) => {
key: string;
value: GroupValue | null;
}[];
addToGroup: AddToGroup<GroupValue, MatchType> | false;
removeFromGroup?: (
value: GroupValue | null,
oldValue: ValueTypeOf<MatchType> | null
) => ValueTypeOf<MatchType> | null;
view: UniComponent<GroupRenderProps<GroupValue | null, Data>>;
};

View File

@@ -0,0 +1,14 @@
export * from './common/index.js';
export * from './component/index.js';
export { DataSourceBase } from './data-source/base.js';
export { DataViewRootUILogic } from './data-view.js';
export * from './filter/index.js';
export * from './group-by';
export * from './logical/index.js';
export * from './property/index.js';
export type { DataViewSelection } from './types.js';
export * from './types.js';
export * from './utils/index.js';
export * from './view/index.js';
export * from './view-manager/index.js';
export * from './widget/index.js';

View File

@@ -0,0 +1,136 @@
import Zod from 'zod';
import type {
AnyTypeInstance,
TypeInstance,
Unify,
ValueTypeOf,
} from './type.js';
import type {
TypeVarContext,
TypeVarDefinitionInstance,
} from './type-variable.js';
type FnValueType<
Args extends readonly TypeInstance[],
Return extends TypeInstance,
> = (
...args: { [K in keyof Args]: ValueTypeOf<Args[K]> }
) => ValueTypeOf<Return>;
export class FnTypeInstance<
Args extends readonly TypeInstance[] = readonly TypeInstance[],
Return extends TypeInstance = TypeInstance,
> implements TypeInstance {
_validate = fnSchema;
readonly _valueType = undefined as never as FnValueType<Args, Return>;
name = 'function';
constructor(
readonly args: Args,
readonly rt: Return,
readonly vars: TypeVarDefinitionInstance[]
) {}
subst(ctx: TypeVarContext) {
const newCtx = { ...ctx };
const args: TypeInstance[] = [];
for (const arg of this.args) {
const newArg = arg.subst(newCtx);
if (!newArg) {
return;
}
args.push(newArg);
}
const rt = this.rt.subst(newCtx);
if (!rt) {
return;
}
return ct.fn.instance(args, rt);
}
unify(ctx: TypeVarContext, template: FnTypeInstance, unify: Unify): boolean {
const newCtx = { ...ctx };
for (let i = 0; i < template.args.length; i++) {
const arg = template.args[i];
const realArg = this.args[i];
if (arg == null) {
return false;
}
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (realArg != null) {
if (!unify(newCtx, realArg, arg)) {
return false;
}
}
}
return unify(newCtx, template.rt, this.rt);
}
valueValidate(value: unknown): value is FnValueType<Args, Return> {
return fnSchema.safeParse(value).success;
}
}
const fnSchema = Zod.function();
export class ArrayTypeInstance<
Element extends TypeInstance = TypeInstance,
> implements TypeInstance {
readonly _validate;
readonly _valueType = undefined as never as ValueTypeOf<Element>[];
readonly name = 'array';
constructor(readonly element: Element) {
this._validate = Zod.array(element._validate);
}
subst(ctx: TypeVarContext) {
const ele = this.element.subst(ctx);
if (!ele) {
return;
}
return ct.array.instance(ele);
}
unify(ctx: TypeVarContext, type: ArrayTypeInstance, unify: Unify): boolean {
return unify(ctx, this.element, type.element);
}
valueValidate(value: unknown): value is ValueTypeOf<Element>[] {
return this._validate.safeParse(value).success;
}
}
export const ct = {
fn: {
is: (type: AnyTypeInstance): type is FnTypeInstance => {
return type.name === 'function';
},
instance: <
Args extends readonly TypeInstance[],
Return extends TypeInstance,
>(
args: Args,
rt: Return,
vars?: TypeVarDefinitionInstance[]
) => {
return new FnTypeInstance(args, rt, vars ?? []);
},
},
array: {
is: (type: AnyTypeInstance): type is ArrayTypeInstance => {
return type.name === 'array';
},
instance: <Element extends TypeInstance>(
element: Element
): ArrayTypeInstance<Element> => {
return new ArrayTypeInstance(element);
},
},
};

View File

@@ -0,0 +1,77 @@
import type Zod from 'zod';
import type {
AnyTypeInstance,
TypeDefinition,
TypeInstance,
Unify,
} from './type.js';
import type { TypeVarContext } from './type-variable.js';
export type DataTypeOf<T extends DataType> = ReturnType<T['instance']>;
export class DTInstance<
Name extends string = string,
Data = unknown,
ValueSchema extends Zod.ZodType = Zod.ZodType,
> implements TypeInstance {
readonly _valueType = undefined as never as Zod.TypeOf<ValueSchema>;
constructor(
readonly name: Name,
readonly _validate: ValueSchema,
readonly data?: Data
) {}
subst(_ctx: TypeVarContext): void | TypeInstance {
return this;
}
unify(_ctx: TypeVarContext, type: DTInstance, _unify: Unify): boolean {
if (this.name !== type.name) {
return false;
}
if (type.data == null) {
return true;
}
return this.data != null;
}
valueValidate(value: unknown): value is this['_valueType'] {
return this._validate.safeParse(value).success;
}
}
export class DataType<
Name extends string = string,
DataSchema extends Zod.ZodType = Zod.ZodType,
ValueSchema extends Zod.ZodType = Zod.ZodType,
> implements TypeDefinition {
constructor(
private readonly name: Name,
_dataSchema: DataSchema,
private readonly valueSchema: ValueSchema
) {}
instance(literal?: Zod.TypeOf<DataSchema>) {
return new DTInstance(this.name, this.valueSchema, literal);
}
is(
type: AnyTypeInstance
): type is DTInstance<Name, Zod.TypeOf<DataSchema>, ValueSchema> {
return type.name === this.name;
}
}
export const defineDataType = <
Name extends string,
Data extends Zod.ZodType,
Value extends Zod.ZodType,
>(
name: Name,
validateData: Data,
validateValue: Value
) => {
return new DataType(name, validateData, validateValue);
};

View File

@@ -0,0 +1,3 @@
export * from './type.js';
export * from './type-presets.js';
export * from './type-system.js';

View File

@@ -0,0 +1,113 @@
import type { TypeInstance } from './type.js';
import { typeSystem } from './type-system.js';
type MatcherData<Data, Type extends TypeInstance = TypeInstance> = {
type: Type;
data: Data;
};
export class MatcherCreator<Data, Type extends TypeInstance = TypeInstance> {
createMatcher(type: Type, data: Data) {
return { type, data };
}
}
export class Matcher<Data, Type extends TypeInstance = TypeInstance> {
constructor(
private readonly list: MatcherData<Data, Type>[],
private readonly _match: (type: Type, target: TypeInstance) => boolean = (
type,
target
) => typeSystem.unify(target, type)
) {}
all(): MatcherData<Data, Type>[] {
return this.list;
}
allMatched(type: TypeInstance): MatcherData<Data>[] {
const result: MatcherData<Data>[] = [];
for (const t of this.list) {
if (this._match(t.type, type)) {
result.push(t);
}
}
return result;
}
allMatchedData(type: TypeInstance): Data[] {
const result: Data[] = [];
for (const t of this.list) {
if (this._match(t.type, type)) {
result.push(t.data);
}
}
return result;
}
find(
f: (data: MatcherData<Data, Type>) => boolean
): MatcherData<Data, Type> | undefined {
return this.list.find(f);
}
findData(f: (data: Data) => boolean): Data | undefined {
return this.list.find(data => f(data.data))?.data;
}
isMatched(type: Type, target: TypeInstance) {
return this._match(type, target);
}
match(type: TypeInstance) {
for (const t of this.list) {
if (this._match(t.type, type)) {
return t.data;
}
}
return;
}
}
export class Matcher_<Value, Type extends TypeInstance> {
constructor(
private readonly list: Value[],
private readonly getType: (value: Value) => Type,
private readonly matchFunc: (
type: Type,
target: TypeInstance
) => boolean = (type, target) => typeSystem.unify(target, type)
) {}
all(): Value[] {
return this.list;
}
allMatched(type: TypeInstance): Value[] {
const result: Value[] = [];
for (const t of this.list) {
const tType = this.getType(t);
if (this.matchFunc(tType, type)) {
result.push(t);
}
}
return result;
}
find(f: (data: Value) => boolean): Value | undefined {
return this.list.find(f);
}
isMatched(type: Type, target: TypeInstance) {
return this.matchFunc(type, target);
}
match(type: TypeInstance) {
for (const t of this.list) {
const tType = this.getType(t);
if (this.matchFunc(tType, type)) {
return t;
}
}
return;
}
}

View File

@@ -0,0 +1,67 @@
import type {
UserListService,
UserService,
} from '@blocksuite/affine-shared/services';
import * as zod from 'zod';
import Zod from 'zod';
import { ct } from './composite-type.js';
import { defineDataType } from './data-type.js';
import type { TypeConvertConfig, TypeInstance, ValueTypeOf } from './type.js';
import { tv } from './type-variable.js';
export type SelectTag = Zod.TypeOf<typeof SelectTagSchema>;
export const SelectTagSchema = Zod.object({
id: Zod.string(),
color: Zod.string(),
value: Zod.string(),
});
export const UserInfoSchema = Zod.object({
userService: Zod.custom<UserService>(() => true),
userListService: Zod.custom<UserListService>(() => true),
});
export type UserInfo = Zod.TypeOf<typeof UserInfoSchema>;
export const unknown = defineDataType('Unknown', zod.never(), zod.unknown());
export const dt = {
number: defineDataType('Number', zod.number(), zod.number()),
string: defineDataType('String', zod.string(), zod.string()),
boolean: defineDataType('Boolean', zod.boolean(), zod.boolean()),
richText: defineDataType('RichText', zod.string(), zod.string()),
date: defineDataType('Date', zod.number(), zod.number()),
url: defineDataType('URL', zod.string(), zod.string()),
image: defineDataType('Image', zod.string(), zod.string()),
tag: defineDataType('Tag', zod.array(SelectTagSchema), zod.string()),
user: defineDataType('User', UserInfoSchema, zod.string()),
};
export const t = {
unknown,
...dt,
...tv,
...ct,
};
const createTypeConvert = <From extends TypeInstance, To extends TypeInstance>(
from: From,
to: To,
convert: (value: ValueTypeOf<From>) => ValueTypeOf<To>
): TypeConvertConfig<From, To> => {
return {
from,
to,
convert,
};
};
export const converts: TypeConvertConfig[] = [
...Object.values(dt).map(from => ({
from: from.instance(),
to: t.unknown.instance(),
convert: (value: unknown) => value,
})),
createTypeConvert(
t.array.instance(unknown.instance()),
unknown.instance(),
value => value
),
createTypeConvert(t.richText.instance(), t.string.instance(), value => value),
createTypeConvert(t.url.instance(), t.string.instance(), value => value),
createTypeConvert(t.user.instance(), t.string.instance(), value => value),
];

View File

@@ -0,0 +1,195 @@
import type { FnTypeInstance } from './composite-type.js';
import type { TypeConvertConfig, TypeInstance, Unify } from './type.js';
import { converts } from './type-presets.js';
import {
tv,
type TypeVarContext,
type TypeVarReferenceInstance,
} from './type-variable.js';
type From = string;
type To = string;
const setMap2 = <T>(
map2: Map<string, Map<string, T>>,
key1: string,
key2: string,
value: T
) => {
let map = map2.get(key1);
if (!map) {
map2.set(key1, (map = new Map()));
}
map.set(key2, value);
return map;
};
const getMap2 = <T>(
map2: Map<string, Map<string, T>>,
key1: string,
key2: string
) => {
return map2.get(key1)?.get(key2);
};
export class TypeSystem {
private readonly _unify: Unify = (
ctx: TypeVarContext,
left: TypeInstance | undefined,
right: TypeInstance | undefined
): boolean => {
if (left == null) return true;
if (right == null) return false;
if (tv.typeVarReference.is(left)) {
return this.unifyReference(ctx, left, right);
}
if (tv.typeVarReference.is(right)) {
return this.unifyReference(ctx, right, left, false);
}
return this.unifyNormalType(ctx, left, right);
};
convertMapFromTo = new Map<
From,
Map<
To,
{
level: number;
from: TypeInstance;
to: TypeInstance;
convert: (value: unknown) => unknown;
}
>
>();
convertMapToFrom = new Map<
From,
Map<
To,
{
level: number;
from: TypeInstance;
to: TypeInstance;
convert: (value: unknown) => unknown;
}
>
>();
unify = <T extends TypeInstance>(
left: TypeInstance | undefined,
right: T | undefined
): left is T => {
return this._unify({}, left, right);
};
constructor(converts: TypeConvertConfig[]) {
converts.forEach(config => {
this.registerConvert(config.from, config.to, config.convert);
});
}
private registerConvert(
from: TypeInstance,
to: TypeInstance,
convert: (value: unknown) => unknown,
level = 0
) {
const currentConfig = getMap2(this.convertMapFromTo, from.name, to.name);
if (currentConfig && currentConfig.level <= level) {
return;
}
const config = {
level,
from,
to,
convert,
};
setMap2(this.convertMapFromTo, from.name, to.name, config);
setMap2(this.convertMapToFrom, to.name, from.name, config);
this.convertMapToFrom.get(from.name)?.forEach(config => {
this.registerConvert(config.from, to, value =>
convert(config.convert(value))
);
});
}
private unifyNormalType(
ctx: TypeVarContext,
left: TypeInstance | undefined,
right: TypeInstance | undefined,
covariance: boolean = true
): boolean {
if (!left || !right) {
return false;
}
if (left.name !== right.name) {
[left, right] = covariance ? [left, right] : [right, left];
const convertConfig = this.convertMapFromTo
.get(left.name)
?.get(right.name);
if (convertConfig == null) {
return false;
}
left = convertConfig.to;
}
return left.unify(ctx, right, this._unify);
}
private unifyReference(
ctx: TypeVarContext,
left: TypeVarReferenceInstance,
right: TypeInstance | undefined,
covariance: boolean = true
): boolean {
if (!right) {
return false;
}
let leftDefine = ctx[left.varName];
if (!leftDefine) {
ctx[left.varName] = leftDefine = {
define: tv.typeVarDefine.create(left.varName),
};
}
const leftType = leftDefine.type;
if (tv.typeVarReference.is(right)) {
return this.unifyReference(ctx, right, leftType, !covariance);
}
if (!leftType) {
leftDefine.type = right;
return true;
}
return this.unifyNormalType(ctx, leftType, right, covariance);
}
instanceFn(
template: FnTypeInstance,
realArgs: TypeInstance[],
realRt: TypeInstance,
ctx: TypeVarContext
): FnTypeInstance | void {
const newCtx = {
...ctx,
};
template.vars.forEach(v => {
newCtx[v.varName] = {
define: v,
};
});
for (let i = 0; i < template.args.length; i++) {
const arg = template.args[i];
const realArg = realArgs[i];
if (arg == null) {
return;
}
// oxlint-disable-next-line sonarjs/no-collapsible-if
if (realArg != null) {
if (!this._unify(newCtx, realArg, arg)) {
return;
}
}
}
this._unify(newCtx, template.rt, realRt);
return template.subst(newCtx);
}
}
export const typeSystem = new TypeSystem(converts);

View File

@@ -0,0 +1,76 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import Zod from 'zod';
import type { TypeInstance, Unify } from './type.js';
const unknownSchema = Zod.unknown();
export class TypeVarDefinitionInstance<
Name extends string = string,
Type extends TypeInstance = TypeInstance,
> {
readonly name = '__TypeVarDefine';
constructor(
readonly varName: Name,
readonly typeConstraint?: Type
) {}
}
export class TypeVarReferenceInstance<
Name extends string = string,
> implements TypeInstance {
readonly _validate = unknownSchema;
readonly _valueType = undefined as unknown;
readonly name = '__TypeVarReference';
constructor(readonly varName: Name) {}
subst(ctx: TypeVarContext): void | TypeInstance {
return ctx[this.varName]?.type;
}
unify(_ctx: TypeVarContext, _type: TypeInstance, _unify: Unify): boolean {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
'unexpected type unify, type var reference'
);
}
valueValidate(_value: unknown): _value is unknown {
return true;
}
}
export const tv = {
typeVarDefine: {
create: <
Name extends string = string,
Type extends TypeInstance = TypeInstance,
>(
name: Name,
typeConstraint?: Type
) => {
return new TypeVarDefinitionInstance(name, typeConstraint);
},
},
typeVarReference: {
create: <Name extends string>(name: Name) => {
return new TypeVarReferenceInstance(name);
},
is: (type: TypeInstance): type is TypeVarReferenceInstance => {
return type.name === '__TypeVarReference';
},
},
};
export type TypeVarDefine = {
define: TypeVarDefinitionInstance;
type?: TypeInstance;
};
export type TypeVarContext = Record<string, TypeVarDefine>;
export const tRef = tv.typeVarReference.create;
export const tVar = tv.typeVarDefine.create;

View File

@@ -0,0 +1,38 @@
import type Zod from 'zod';
import type { TypeVarContext } from './type-variable.js';
export type AnyTypeInstance = {
readonly name: string;
};
export interface TypeDefinition {
is(typeInstance: AnyTypeInstance): boolean;
}
export interface TypeInstance extends AnyTypeInstance {
readonly _valueType: any;
readonly _validate: Zod.ZodSchema;
valueValidate(value: unknown): value is this['_valueType'];
subst(ctx: TypeVarContext): TypeInstance | void;
unify(ctx: TypeVarContext, type: TypeInstance, unify: Unify): boolean;
}
export type ValueTypeOf<T> = T extends TypeInstance ? T['_valueType'] : never;
export type Unify = (
ctx: TypeVarContext,
type: TypeInstance | undefined,
expect: TypeInstance | undefined
) => boolean;
export type TypeConvertConfig<
From extends TypeInstance = TypeInstance,
To extends TypeInstance = TypeInstance,
> = {
from: From;
to: To;
convert: (value: ValueTypeOf<From>) => ValueTypeOf<To>;
};

View File

@@ -0,0 +1,134 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import type { Cell } from '../view-manager/cell.js';
import type { CellRenderProps, DataViewCellLifeCycle } from './manager.js';
export abstract class BaseCellRenderer<
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
>
extends SignalWatcher(WithDisposable(ShadowlessElement))
implements DataViewCellLifeCycle, CellRenderProps<Data, RawValue, JsonValue>
{
get expose() {
return this;
}
@property({ attribute: false })
accessor cell!: Cell<RawValue, JsonValue, Data>;
readonly$ = computed(() => {
return this.cell.property.readonly$.value;
});
value$ = computed(() => {
return this.cell.value$.value;
});
get property() {
return this.cell.property;
}
get readonly() {
return this.readonly$.value;
}
get row() {
return this.cell.row;
}
get value() {
return this.value$.value;
}
get view() {
return this.cell.view;
}
beforeEnterEditMode(): boolean {
return true;
}
blurCell() {
return true;
}
type: string | undefined;
protected override shouldUpdate(_changedProperties: PropertyValues): boolean {
return this.cell.property.type$.value === this.type;
}
override connectedCallback() {
super.connectedCallback();
this.type = this.cell.property.type$.value;
this.dataset.testid = this.type;
this.style.width = '100%';
this._disposables.addFromEvent(this, 'click', e => {
if (this.isEditing$.value) {
e.stopPropagation();
}
});
this._disposables.addFromEvent(this, 'copy', e => {
if (!this.isEditing$.value) return;
e.stopPropagation();
this.onCopy(e);
});
this._disposables.addFromEvent(this, 'cut', e => {
if (!this.isEditing$.value) return;
e.stopPropagation();
this.onCut(e);
});
this._disposables.addFromEvent(this, 'paste', e => {
if (!this.isEditing$.value) return;
e.stopPropagation();
this.onPaste(e);
});
}
focusCell() {
return true;
}
forceUpdate(): void {
this.requestUpdate();
}
valueSetImmediate(value: RawValue | undefined): void {
this.cell.valueSet(value);
}
valueSetNextTick(value: RawValue | undefined) {
requestAnimationFrame(() => {
this.cell.valueSet(value);
});
}
onCopy(_e: ClipboardEvent) {}
onCut(_e: ClipboardEvent) {}
afterEnterEditingMode(): void {
// do nothing
}
beforeExitEditingMode() {
// do nothing
}
onPaste(_e: ClipboardEvent) {}
@property({ attribute: false })
accessor isEditing$!: ReadonlySignal<boolean>;
@property({ attribute: false })
accessor selectCurrentCell!: (editing: boolean) => void;
}

View File

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

View File

@@ -0,0 +1,6 @@
export * from './base-cell.js';
export * from './convert.js';
export * from './manager.js';
export * from './property-config.js';
export * from './renderer.js';
export * from './types.js';

View File

@@ -0,0 +1,44 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import type { ReadonlySignal } from '@preact/signals-core';
import type { Cell } from '../view-manager/cell.js';
export interface CellRenderProps<
Data extends NonNullable<unknown> = NonNullable<unknown>,
RawValue = unknown,
JsonValue = unknown,
> {
cell: Cell<RawValue, JsonValue, Data>;
isEditing$: ReadonlySignal<boolean>;
selectCurrentCell: (editing: boolean) => void;
}
export interface DataViewCellLifeCycle {
beforeEnterEditMode(): boolean;
beforeExitEditingMode(): void;
afterEnterEditingMode(): void;
focusCell(): boolean;
blurCell(): boolean;
forceUpdate(): void;
}
export type DataViewCellComponent<
Data extends NonNullable<unknown> = NonNullable<unknown>,
RawValue = unknown,
JsonValue = unknown,
> = UniComponent<
CellRenderProps<Data, RawValue, JsonValue>,
DataViewCellLifeCycle
>;
export type CellRenderer<
Data extends NonNullable<unknown> = NonNullable<unknown>,
RawValue = unknown,
JsonValue = unknown,
> = {
view: DataViewCellComponent<Data, RawValue, JsonValue>;
};

View File

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

View File

@@ -0,0 +1,25 @@
import type { UniComponent } from '@blocksuite/affine-shared/types';
import { createUniComponentFromWebComponent } from '../utils/uni-component/index.js';
import type { BaseCellRenderer } from './base-cell.js';
import type { CellRenderer, DataViewCellComponent } from './manager.js';
export interface Renderer<
Data extends NonNullable<unknown> = NonNullable<unknown>,
RawValue = unknown,
JsonValue = unknown,
> {
type: string;
icon?: UniComponent;
cellRenderer: CellRenderer<Data, RawValue, JsonValue>;
}
export const createFromBaseCellRenderer = <
RawValue = unknown,
JsonValue = unknown,
Data extends Record<string, unknown> = Record<string, unknown>,
>(
renderer: new () => BaseCellRenderer<RawValue, JsonValue, Data>
): DataViewCellComponent => {
return createUniComponentFromWebComponent(renderer as never) as never;
};

View File

@@ -0,0 +1,103 @@
import type { Disposable } from '@blocksuite/global/disposable';
import type { ZodType } from 'zod';
import type { DataSource } from '../data-source/base.js';
import type { TypeInstance } from '../logical/type.js';
export type WithCommonPropertyConfig<T = {}> = T & {
dataSource: DataSource;
};
export type GetPropertyDataFromConfig<T> =
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;
hide?: boolean;
kanbanGroup?: {
enabled: boolean;
mutable?: 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?: 'start' | 'end';
defaultShow?: boolean;
};
minWidth?: number;
addGroup?: (
config: WithCommonPropertyConfig<{
text: string;
oldData: Data;
}>
) => Data;
};
export type DVJSON =
| null
| number
| string
| boolean
| DVJSON[]
| {
[k: string]: 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

@@ -0,0 +1,59 @@
import {
menu,
popMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import type { Middleware } from '@floating-ui/dom';
import { renderUniLit } from '../utils/index.js';
import type { SortUtils } from './utils.js';
export const popCreateSort = (
target: PopupTarget,
props: {
sortUtils: SortUtils;
onClose?: () => void;
onBack?: () => void;
},
ops?: {
middleware?: Middleware[];
}
) => {
const subHandler = popMenu(target, {
middleware: ops?.middleware,
options: {
onClose: props.onClose,
title: {
text: 'New sort',
onBack: props.onBack,
},
items: [
menu.group({
items: props.sortUtils.vars$.value
.filter(
v =>
!props.sortUtils.sortList$.value.some(
sort => sort.ref.name === v.id
)
)
.map(v =>
menu.action({
name: v.name,
prefix: renderUniLit(v.icon, {}),
select: () => {
props.sortUtils.add({
ref: {
type: 'ref',
name: v.id,
},
desc: false,
});
},
})
),
}),
],
},
});
subHandler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -0,0 +1,197 @@
import type { VariableRef } from '../expression/types.js';
import type { ArrayTypeInstance } from '../logical/composite-type.js';
import type { DataTypeOf } from '../logical/data-type.js';
import { t } from '../logical/index.js';
import type { TypeInstance } from '../logical/type.js';
import { typeSystem } from '../logical/type-system.js';
import type { Row, SingleView } from '../view-manager/index.js';
import type { Sort } from './types.js';
export const Compare = {
GT: 'GT',
LT: 'LT',
} as const;
export type CompareType = keyof typeof Compare | number;
const evalRef = (
view: SingleView,
ref: VariableRef
):
| ((row: Row) => {
value: unknown;
ttype?: TypeInstance;
})
| undefined => {
const ttype = view.propertyGetOrCreate(ref.name).dataType$.value;
return row => ({
value: view.cellGetOrCreate(row.rowId, ref.name).jsonValue$.value,
ttype,
});
};
const compareList = <T>(
listA: T[],
listB: T[],
compare: (a: T, b: T) => CompareType
) => {
let i = 0;
while (i < listA.length && i < listB.length) {
const a = listA[i];
const b = listB[i];
if (a == null || b == null) {
continue;
}
const result = compare(a, b);
if (result !== 0) {
return result;
}
i++;
}
return 0;
};
const compareString = (a: unknown, b: unknown): CompareType => {
const strA = String(a ?? '');
const strB = String(b ?? '');
if (strA === '' && strB !== '') {
return Compare.GT; // Empty strings come last
}
if (strA !== '' && strB === '') {
return Compare.LT; // Empty strings come last
}
if (strA === '' && strB === '') {
return 0; // Both empty, equal
}
const listA = strA.split('.');
const listB = strB.split('.');
return compareList(listA, listB, (a, b) => {
const lowA = String(a).toLowerCase(); // Ensure 'a' and 'b' from split are strings too
const lowB = String(b).toLowerCase();
const numberA = Number.parseInt(lowA);
const numberB = Number.parseInt(lowB);
const aIsNaN = Number.isNaN(numberA);
const bIsNaN = Number.isNaN(numberB);
if (aIsNaN && !bIsNaN) {
return 1; // Non-numeric part comes after numeric part
}
if (!aIsNaN && bIsNaN) {
return -1; // Numeric part comes before non-numeric part
}
if (!aIsNaN && !bIsNaN && numberA !== numberB) {
return numberA - numberB; // Numeric comparison for numeric parts
}
return lowA.localeCompare(lowB); // Lexicographical comparison for string parts
});
};
const compareNumber = (a: unknown, b: unknown) => {
if (a == null) {
return Compare.GT;
}
if (b == null) {
return Compare.LT;
}
return Number(a) - Number(b);
};
const compareBoolean = (a: unknown, b: unknown) => {
a = Boolean(a);
b = Boolean(b);
const bA = a ? 1 : 0;
const bB = b ? 1 : 0;
return bA - bB;
};
const compareArray = (type: ArrayTypeInstance, a: unknown, b: unknown) => {
if (!Array.isArray(a)) {
return Compare.GT;
}
if (!Array.isArray(b)) {
return Compare.LT;
}
return compareList(a, b, (a, b) => {
return compare(type.element, a, b);
});
};
const compareAny = (a: unknown, b: unknown) => {
if (!a) {
return Compare.GT;
}
if (!b) {
return Compare.LT;
}
return Number(a) - Number(b);
};
const compareTag = (type: DataTypeOf<typeof t.tag>, a: unknown, b: unknown) => {
if (a == null) {
return Compare.GT;
}
if (b == null) {
return Compare.LT;
}
const indexA = type.data?.findIndex(tag => tag.id === a);
const indexB = type.data?.findIndex(tag => tag.id === b);
return compareNumber(indexA, indexB);
};
const compare = (type: TypeInstance, a: unknown, b: unknown): CompareType => {
if (typeSystem.unify(type, t.richText.instance())) {
return compareString(a?.toString(), b?.toString());
}
if (typeSystem.unify(type, t.string.instance())) {
return compareString(a, b);
}
if (typeSystem.unify(type, t.number.instance())) {
return compareNumber(a, b);
}
if (typeSystem.unify(type, t.date.instance())) {
return compareNumber(a, b);
}
if (typeSystem.unify(type, t.boolean.instance())) {
return compareBoolean(a, b);
}
if (typeSystem.unify(type, t.tag.instance())) {
return compareTag(type, a, b);
}
if (t.array.is(type)) {
return compareArray(type, a, b);
}
return compareAny(a, b);
};
export const evalSort = (
sort: Sort,
view: SingleView
): ((rowA: Row, rowB: Row) => number) | undefined => {
if (sort.sortBy.length) {
const sortBy = sort.sortBy.map(sort => {
return {
ref: evalRef(view, sort.ref),
desc: sort.desc,
};
});
return (rowA, rowB) => {
for (const sort of sortBy) {
const refA = sort.ref?.(rowA);
const refB = sort.ref?.(rowB);
const result = compare(
refA?.ttype ?? t.unknown.instance(),
refA?.value,
refB?.value
);
if (typeof result === 'number' && result !== 0) {
return sort.desc ? -result : result;
}
if (result === Compare.GT) {
return 1;
}
if (result === Compare.LT) {
return -1;
}
continue;
}
return 0;
};
}
return;
};

View File

@@ -0,0 +1,43 @@
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { createTraitKey } from '../traits/key.js';
import type { Row, SingleView } from '../view-manager/index.js';
import { evalSort } from './eval.js';
import type { Sort, SortBy } from './types.js';
export class SortManager {
hasSort$ = computed(() => (this.sort$.value?.sortBy?.length ?? 0) > 0);
setSortList = (sortList: SortBy[]) => {
this.ops.setSortList({
manuallySort: [],
...this.sort$.value,
sortBy: sortList,
});
};
sort = (rows: Row[]) => {
if (!this.sort$.value) {
return rows;
}
const compare = evalSort(this.sort$.value, this.view);
if (!compare) {
return rows;
}
const newRows = rows.slice();
newRows.sort(compare);
return newRows;
};
sortList$ = computed(() => this.sort$.value?.sortBy ?? []);
constructor(
readonly sort$: ReadonlySignal<Sort | undefined>,
readonly view: SingleView,
private readonly ops: {
setSortList: (sortList: Sort) => void;
}
) {}
}
export const sortTraitKey = createTraitKey<SortManager>('sort');

View File

@@ -0,0 +1,10 @@
import type { VariableRef } from '../expression/types.js';
export type SortBy = {
ref: VariableRef;
desc: boolean;
};
export type Sort = {
sortBy: SortBy[];
manuallySort: string[];
};

View File

@@ -0,0 +1,108 @@
import type {
DatabaseAllViewEvents,
EventTraceFn,
SortParams,
} from '@blocksuite/affine-shared/services';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { Variable } from '../expression/index.js';
import { arrayMove } from '../utils/wc-dnd/utils/array-move.js';
import type { SortManager } from './manager.js';
import type { SortBy } from './types.js';
export interface SortUtils {
sortList$: ReadonlySignal<SortBy[]>;
vars$: ReadonlySignal<Variable[]>;
add: (sort: SortBy) => void;
move: (from: number, to: number) => void;
change: (index: number, sort: SortBy) => void;
remove: (index: number) => void;
removeAll: () => void;
}
export const createSortUtils = (
sortTrait: SortManager,
eventTrace: EventTraceFn<DatabaseAllViewEvents>
): SortUtils => {
const view = sortTrait.view;
const varsMap$ = computed(() => {
return new Map(view.vars$.value.map(v => [v.id, v]));
});
const sortList$ = sortTrait.sortList$;
const sortParams = (
sort?: SortBy,
index?: number
): SortParams | undefined => {
if (!sort) {
return;
}
const v = varsMap$.value.get(sort.ref.name);
return {
fieldId: sort.ref.name,
fieldType: v?.propertyType ?? '',
orderType: sort.desc ? 'desc' : 'asc',
orderIndex:
index ?? sortList$.value.findIndex(v => v.ref.name === sort.ref.name),
};
};
return {
vars$: view.vars$,
sortList$: sortList$,
add: sort => {
const list = sortTrait.sortList$.value;
sortTrait.setSortList([...list, sort]);
const params = sortParams(sort, list.length);
if (params) {
eventTrace('DatabaseSortAdd', params);
}
},
move: (fromIndex, toIndex) => {
const list = sortTrait.sortList$.value;
const from = sortParams(list[fromIndex], fromIndex);
const newList = arrayMove(list, fromIndex, toIndex);
sortTrait.setSortList(newList);
const prev = sortParams(newList[toIndex - 1], toIndex - 1);
const next = sortParams(newList[toIndex + 1], toIndex + 1);
if (from) {
eventTrace('DatabaseSortReorder', {
...from,
prevFieldType: prev?.fieldType ?? '',
nextFieldType: next?.fieldType ?? '',
newOrderIndex: toIndex,
});
}
},
change: (index, sort) => {
const list = sortTrait.sortList$.value.slice();
const old = sortParams(list[index], index);
list[index] = sort;
sortTrait.setSortList(list);
const params = sortParams(sort, index);
if (params && old) {
eventTrace('DatabaseSortModify', {
...params,
oldOrderType: old.orderType,
oldFieldType: old.fieldType,
oldFieldId: old.fieldId,
});
}
},
remove: index => {
const list = sortTrait.sortList$.value.slice();
const old = sortParams(list[index], index);
list.splice(index, 1);
sortTrait.setSortList([...list]);
if (old) {
eventTrace('DatabaseSortRemove', old);
}
},
removeAll: () => {
const count = sortTrait.sortList$.value.length;
sortTrait.setSortList([]);
eventTrace('DatabaseSortClear', {
rulesCount: count,
});
},
};
};

View File

@@ -0,0 +1,104 @@
import { t } from '../logical/index.js';
import { createStatisticConfig } from './create.js';
import type { StatisticsConfig } from './types.js';
export const anyTypeStatsFunctions: StatisticsConfig[] = [
createStatisticConfig({
group: 'Count',
menuName: 'Count All',
displayName: 'All',
type: 'count-all',
dataType: t.unknown.instance(),
impl: data => {
return data.length.toString();
},
}),
createStatisticConfig({
group: 'Count',
menuName: 'Count Values',
displayName: 'Values',
type: 'count-values',
dataType: t.unknown.instance(),
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({
group: 'Count',
menuName: 'Count Unique Values',
displayName: 'Unique Values',
type: 'count-unique-values',
dataType: t.unknown.instance(),
impl: data => {
const values = data
.flatMap(v => {
if (Array.isArray(v)) {
return v;
}
return [v];
})
.filter(v => v != null);
return new Set(values).size.toString();
},
}),
createStatisticConfig({
group: 'Count',
menuName: 'Count Empty',
displayName: 'Empty',
type: 'count-empty',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
const emptyList = data.filter(value =>
meta.config.jsonValue.isEmpty({ value, dataSource })
);
return emptyList.length.toString();
},
}),
createStatisticConfig({
group: 'Count',
menuName: 'Count Not Empty',
displayName: 'Not Empty',
type: 'count-not-empty',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
const notEmptyList = data.filter(
value => !meta.config.jsonValue.isEmpty({ value, dataSource })
);
return notEmptyList.length.toString();
},
}),
createStatisticConfig({
group: 'Percent',
menuName: 'Percent Empty',
displayName: 'Empty',
type: 'percent-empty',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
if (data.length === 0) return '';
const emptyList = data.filter(value =>
meta.config.jsonValue.isEmpty({ value, dataSource })
);
return ((emptyList.length / data.length) * 100).toFixed(2) + '%';
},
}),
createStatisticConfig({
group: 'Percent',
menuName: 'Percent Not Empty',
displayName: 'Not Empty',
type: 'percent-not-empty',
dataType: t.unknown.instance(),
impl: (data, { meta, dataSource }) => {
if (data.length === 0) return '';
const notEmptyList = data.filter(
value => !meta.config.jsonValue.isEmpty({ value, dataSource })
);
return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%';
},
}),
];

View File

@@ -0,0 +1,62 @@
import { t } from '../logical/index.js';
import { createStatisticConfig } from './create.js';
import type { StatisticsConfig } from './types.js';
export const checkboxTypeStatsFunctions: StatisticsConfig[] = [
createStatisticConfig({
group: 'Count',
type: 'count-values',
dataType: t.boolean.instance(),
}),
createStatisticConfig({
group: 'Count',
type: 'count-unique-values',
dataType: t.boolean.instance(),
}),
createStatisticConfig({
group: 'Count',
type: 'count-empty',
dataType: t.boolean.instance(),
menuName: 'Count Unchecked',
displayName: 'Unchecked',
impl: data => {
const emptyList = data.filter(value => !value);
return emptyList.length.toString();
},
}),
createStatisticConfig({
group: 'Count',
type: 'count-not-empty',
dataType: t.boolean.instance(),
menuName: 'Count Checked',
displayName: 'Checked',
impl: data => {
const notEmptyList = data.filter(value => !!value);
return notEmptyList.length.toString();
},
}),
createStatisticConfig({
group: 'Percent',
type: 'percent-empty',
dataType: t.boolean.instance(),
menuName: 'Percent Unchecked',
displayName: 'Unchecked',
impl: data => {
if (data.length === 0) return '';
const emptyList = data.filter(value => !value);
return ((emptyList.length / data.length) * 100).toFixed(2) + '%';
},
}),
createStatisticConfig({
group: 'Percent',
type: 'percent-not-empty',
dataType: t.boolean.instance(),
menuName: 'Percent Checked',
displayName: 'Checked',
impl: data => {
if (data.length === 0) return '';
const notEmptyList = data.filter(value => !!value);
return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%';
},
}),
];

View File

@@ -0,0 +1,8 @@
import type { TypeInstance } from '../logical/index.js';
import type { StatisticsConfig } from './types.js';
export const createStatisticConfig = <T extends TypeInstance>(
config: StatisticsConfig<T>
) => {
return config;
};

View File

@@ -0,0 +1,10 @@
import { anyTypeStatsFunctions } from './any.js';
import { checkboxTypeStatsFunctions } from './checkbox.js';
import { numberStatsFunctions } from './number.js';
import type { StatisticsConfig } from './types.js';
export const statsFunctions: StatisticsConfig[] = [
...anyTypeStatsFunctions,
...numberStatsFunctions,
...checkboxTypeStatsFunctions,
];

View File

@@ -0,0 +1,130 @@
import { t } from '../logical/index.js';
import { createStatisticConfig } from './create.js';
import type { StatisticsConfig } from './types.js';
export const numberStatsFunctions: StatisticsConfig[] = [
createStatisticConfig({
group: 'More options',
menuName: 'Sum',
type: 'sum',
displayName: 'Sum',
dataType: t.number.instance(),
impl: data => {
const numbers = withoutNull(data);
if (numbers.length === 0) {
return 'None';
}
return parseFloat(
numbers.reduce((a, b) => a + b, 0).toFixed(2)
).toString();
},
}),
createStatisticConfig({
group: 'More options',
menuName: 'Average',
displayName: 'Average',
type: 'average',
dataType: t.number.instance(),
impl: data => {
const numbers = withoutNull(data);
if (numbers.length === 0) {
return 'None';
}
return parseFloat(
(numbers.reduce((a, b) => a + b, 0) / numbers.length).toFixed(2)
).toString();
},
}),
createStatisticConfig({
group: 'More options',
menuName: 'Median',
displayName: 'Median',
type: 'median',
dataType: t.number.instance(),
impl: data => {
const arr = withoutNull(data).sort((a, b) => a - b);
let result: number | undefined = undefined;
if (arr.length % 2 === 1) {
result = arr[(arr.length - 1) / 2];
} else {
const index = arr.length / 2;
const a = arr[index];
const b = arr[index - 1];
if (a == null || b == null) return 'None';
result = parseFloat(((a + b) / 2).toFixed(2));
}
return result?.toString() ?? 'None';
},
}),
createStatisticConfig({
group: 'More options',
menuName: 'Min',
displayName: 'Min',
type: 'min',
dataType: t.number.instance(),
impl: data => {
let min: number | null = null;
for (const num of data) {
if (num != null) {
if (min == null) {
min = num;
} else {
min = Math.min(min, num);
}
}
}
return min?.toString() ?? 'None';
},
}),
createStatisticConfig({
group: 'More options',
menuName: 'Max',
displayName: 'Max',
type: 'max',
dataType: t.number.instance(),
impl: data => {
let max: number | null = null;
for (const num of data) {
if (num != null) {
if (max == null) {
max = num;
} else {
max = Math.max(max, num);
}
}
}
return max?.toString() ?? 'None';
},
}),
createStatisticConfig({
group: 'More options',
menuName: 'Range',
displayName: 'Range',
type: 'range',
dataType: t.number.instance(),
impl: data => {
let min: number | null = null;
let max: number | null = null;
for (const num of data) {
if (num != null) {
if (max == null) {
max = num;
} else {
max = Math.max(max, num);
}
if (min == null) {
min = num;
} else {
min = Math.min(min, num);
}
}
}
if (min == null || max == null) {
return 'None';
}
return parseFloat((max - min).toFixed(2)).toString();
},
}),
];
const withoutNull = (arr: readonly (number | null | undefined)[]): number[] =>
arr.filter(v => v != null);

View File

@@ -0,0 +1,18 @@
import type { DataSource } from '../data-source/index.js';
import type { TypeInstance, ValueTypeOf } from '../logical/type.js';
import type { PropertyMetaConfig } from '../property/property-config.js';
export type StatisticsConfig<T extends TypeInstance = TypeInstance> = {
group: string;
type: string;
dataType: T;
menuName?: string;
displayName?: string;
impl?: (
data: ReadonlyArray<ValueTypeOf<T> | undefined>,
info: {
meta: PropertyMetaConfig;
dataSource: DataSource;
}
) => string;
};

View File

@@ -0,0 +1,10 @@
export interface TraitKey<T> {
key: symbol;
__type?: T;
}
export function createTraitKey<T>(name: string): TraitKey<T> {
return {
key: Symbol(name),
};
}

View File

@@ -0,0 +1,16 @@
import type {
KanbanViewSelectionWithType,
TableViewSelectionWithType,
} from '../view-presets';
export type DataViewSelection =
| TableViewSelectionWithType
| KanbanViewSelectionWithType;
export type DataViewSelectionState = DataViewSelection | undefined;
export type PropertyDataUpdater<
Data extends Record<string, unknown> = Record<string, unknown>,
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_table_virtual_scroll: boolean;
}

Some files were not shown because too many files have changed in this diff Show More