mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat: improve kanban grouping & data materialization (#14393)
fix #13512 fix #13255 fix #9743 #### PR Dependency Tree * **PR #14393** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced Kanban view grouping support for additional property types: checkboxes, select fields, multi-select fields, members, and created-by information. * Improved drag-and-drop visual feedback with more precise drop indicators in Kanban views. * **Bug Fixes** * Refined grouping logic to ensure only compatible properties appear in group-by options. * Enhanced column visibility and ordering consistency when managing Kanban views. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
459
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
459
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
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 { groupByMatchers } from '../core/group-by/define.js';
|
||||||
|
import { t } from '../core/logical/type-presets.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 { 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('kanban', () => {
|
||||||
|
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('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,12 +247,13 @@ export const groupByMatchers: GroupByConfig[] = [
|
|||||||
matchType: t.boolean.instance(),
|
matchType: t.boolean.instance(),
|
||||||
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
||||||
defaultKeys: _t => [
|
defaultKeys: _t => [
|
||||||
ungroups,
|
|
||||||
{ key: 'true', value: true },
|
{ key: 'true', value: true },
|
||||||
{ key: 'false', value: false },
|
{ key: 'false', value: false },
|
||||||
],
|
],
|
||||||
valuesGroup: (v, _t) =>
|
valuesGroup: (v, _t) =>
|
||||||
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
|
typeof v !== 'boolean'
|
||||||
|
? [{ key: 'false', value: false }]
|
||||||
|
: [{ key: v.toString(), value: v }],
|
||||||
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
||||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { css, html, unsafeCSS } from 'lit';
|
|||||||
import { property, query } from 'lit/decorators.js';
|
import { property, query } from 'lit/decorators.js';
|
||||||
import { repeat } from 'lit/directives/repeat.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 { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
||||||
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
||||||
import { dataViewCssVariable } from '../common/css-variable.js';
|
import { dataViewCssVariable } from '../common/css-variable.js';
|
||||||
@@ -278,6 +279,9 @@ export const selectGroupByProperty = (
|
|||||||
if (property.type$.value === 'title') {
|
if (property.type$.value === 'title') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (view instanceof KanbanSingleView) {
|
||||||
|
return canGroupable(view.manager.dataSource, property.id);
|
||||||
|
}
|
||||||
const dataType = property.dataType$.value;
|
const dataType = property.dataType$.value;
|
||||||
if (!dataType) {
|
if (!dataType) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type GetJsonValueFromConfig<T> =
|
|||||||
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
||||||
name: string;
|
name: string;
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: ZodType<Data>;
|
schema: ZodType<Data>;
|
||||||
default: () => Data;
|
default: () => Data;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const FALSE_VALUES = new Set([
|
|||||||
|
|
||||||
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
||||||
name: 'Checkbox',
|
name: 'Checkbox',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const multiSelectPropertyType = propertyType('multi-select');
|
|||||||
export const multiSelectPropertyModelConfig =
|
export const multiSelectPropertyModelConfig =
|
||||||
multiSelectPropertyType.modelConfig({
|
multiSelectPropertyType.modelConfig({
|
||||||
name: 'Multi-select',
|
name: 'Multi-select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const SelectPropertySchema = zod.object({
|
|||||||
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
||||||
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
||||||
name: 'Select',
|
name: 'Select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import { kanbanViewModel } from './kanban/index.js';
|
|||||||
import { tableViewModel } from './table/index.js';
|
import { tableViewModel } from './table/index.js';
|
||||||
|
|
||||||
export const viewConverts = [
|
export const viewConverts = [
|
||||||
createViewConvert(tableViewModel, kanbanViewModel, data => {
|
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||||
if (data.groupBy) {
|
filter: data.filter,
|
||||||
return {
|
})),
|
||||||
filter: data.filter,
|
|
||||||
groupBy: data.groupBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
filter: data.filter,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||||
filter: data.filter,
|
filter: data.filter,
|
||||||
groupBy: data.groupBy,
|
groupBy: data.groupBy,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|||||||
|
|
||||||
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
||||||
import type { FilterGroup } from '../../core/filter/types.js';
|
import type { FilterGroup } from '../../core/filter/types.js';
|
||||||
import { defaultGroupBy, getGroupByService, t } from '../../core/index.js';
|
|
||||||
import type { Sort } from '../../core/sort/types.js';
|
import type { Sort } from '../../core/sort/types.js';
|
||||||
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
||||||
|
import { resolveKanbanGroupBy } from './group-by-utils.js';
|
||||||
import { KanbanSingleView } from './kanban-view-manager.js';
|
import { KanbanSingleView } from './kanban-view-manager.js';
|
||||||
|
|
||||||
export const kanbanViewType = viewType('kanban');
|
export const kanbanViewType = viewType('kanban');
|
||||||
@@ -34,41 +34,16 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
defaultName: 'Kanban View',
|
defaultName: 'Kanban View',
|
||||||
dataViewManager: KanbanSingleView,
|
dataViewManager: KanbanSingleView,
|
||||||
defaultData: viewManager => {
|
defaultData: viewManager => {
|
||||||
const groupByService = getGroupByService(viewManager.dataSource);
|
const groupBy = resolveKanbanGroupBy(viewManager.dataSource);
|
||||||
const columns = viewManager.dataSource.properties$.value;
|
if (!groupBy) {
|
||||||
const allowList = columns.filter(columnId => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
return dataType && !!groupByService?.matcher.match(dataType);
|
|
||||||
});
|
|
||||||
const getWeight = (columnId: string) => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
if (!dataType || t.string.is(dataType) || t.richText.is(dataType)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (t.tag.is(dataType)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if (t.array.is(dataType)) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0];
|
|
||||||
if (!columnId) {
|
|
||||||
throw new BlockSuiteError(
|
throw new BlockSuiteError(
|
||||||
ErrorCode.DatabaseBlockError,
|
ErrorCode.DatabaseBlockError,
|
||||||
'no groupable column found'
|
'no groupable column found'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const type = viewManager.dataSource.propertyTypeGet(columnId);
|
|
||||||
const meta = type && viewManager.dataSource.propertyMetaGet(type);
|
const columns = viewManager.dataSource.properties$.value;
|
||||||
const data = viewManager.dataSource.propertyDataGet(columnId);
|
|
||||||
if (!columnId || !meta || !data) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.DatabaseBlockError,
|
|
||||||
'not implement yet'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
columns: columns.map(id => ({
|
columns: columns.map(id => ({
|
||||||
id: id,
|
id: id,
|
||||||
@@ -78,7 +53,7 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
op: 'and',
|
op: 'and',
|
||||||
conditions: [],
|
conditions: [],
|
||||||
},
|
},
|
||||||
groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data),
|
groupBy,
|
||||||
header: {
|
header: {
|
||||||
titleColumn: viewManager.dataSource.properties$.value.find(
|
titleColumn: viewManager.dataSource.properties$.value.find(
|
||||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import type { GroupBy } from '../../core/common/types.js';
|
||||||
|
import { getTagColor } from '../../core/component/tags/colors.js';
|
||||||
|
import type { DataSource } from '../../core/data-source/base.js';
|
||||||
|
import { defaultGroupBy } from '../../core/group-by/default.js';
|
||||||
|
import { getGroupByService } from '../../core/group-by/matcher.js';
|
||||||
|
|
||||||
|
type KanbanGroupCapability = 'mutable' | 'immutable' | 'none';
|
||||||
|
|
||||||
|
const KANBAN_DEFAULT_STATUS_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
||||||
|
const SHOW_EMPTY_GROUPS_BY_DEFAULT = new Set(['select', 'multi-select']);
|
||||||
|
|
||||||
|
export const getKanbanDefaultHideEmpty = (groupName?: string): boolean => {
|
||||||
|
return !groupName || !SHOW_EMPTY_GROUPS_BY_DEFAULT.has(groupName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKanbanGroupCapability = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyId: string
|
||||||
|
): KanbanGroupCapability => {
|
||||||
|
const type = dataSource.propertyTypeGet(propertyId);
|
||||||
|
if (!type) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
const kanbanGroup = meta?.config.kanbanGroup;
|
||||||
|
if (!kanbanGroup?.enabled) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
return kanbanGroup.mutable ? 'mutable' : 'immutable';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMatchingGroupBy = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
const dataType = dataSource.propertyDataTypeGet(propertyId);
|
||||||
|
if (!dataType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const groupByService = getGroupByService(dataSource);
|
||||||
|
return !!groupByService?.matcher.match(dataType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroupByFromColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
columnId: string
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const type = dataSource.propertyTypeGet(columnId);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return defaultGroupBy(
|
||||||
|
dataSource,
|
||||||
|
meta,
|
||||||
|
columnId,
|
||||||
|
dataSource.propertyDataGet(columnId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canGroupable = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
return (
|
||||||
|
getKanbanGroupCapability(dataSource, propertyId) !== 'none' &&
|
||||||
|
hasMatchingGroupBy(dataSource, propertyId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyIds: string[] = dataSource.properties$.value
|
||||||
|
): string | undefined => {
|
||||||
|
let immutableFallback: string | undefined;
|
||||||
|
|
||||||
|
for (const propertyId of propertyIds) {
|
||||||
|
const capability = getKanbanGroupCapability(dataSource, propertyId);
|
||||||
|
if (capability === 'none' || !hasMatchingGroupBy(dataSource, propertyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (capability === 'mutable') {
|
||||||
|
return propertyId;
|
||||||
|
}
|
||||||
|
immutableFallback ??= propertyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return immutableFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource
|
||||||
|
): string | undefined => {
|
||||||
|
const columnId = pickKanbanGroupColumn(dataSource);
|
||||||
|
if (columnId) {
|
||||||
|
return columnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusId = dataSource.propertyAdd('end', {
|
||||||
|
type: 'select',
|
||||||
|
name: 'Status',
|
||||||
|
});
|
||||||
|
if (!statusId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSource.propertyDataSet(statusId, {
|
||||||
|
options: KANBAN_DEFAULT_STATUS_OPTIONS.map(value => ({
|
||||||
|
id: nanoid(),
|
||||||
|
value,
|
||||||
|
color: getTagColor(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return statusId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveKanbanGroupBy = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
current?: GroupBy
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const keepColumnId =
|
||||||
|
current?.columnId && canGroupable(dataSource, current.columnId)
|
||||||
|
? current.columnId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const columnId = keepColumnId ?? ensureKanbanGroupColumn(dataSource);
|
||||||
|
if (!columnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = createGroupByFromColumn(dataSource, columnId);
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
sort: current?.sort,
|
||||||
|
hideEmpty: current?.hideEmpty ?? getKanbanDefaultHideEmpty(next.name),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -17,7 +17,52 @@ import {
|
|||||||
import { fromJson } from '../../core/property/utils';
|
import { fromJson } from '../../core/property/utils';
|
||||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||||
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||||
import type { KanbanViewData } from './define.js';
|
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||||
|
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||||
|
import {
|
||||||
|
getKanbanDefaultHideEmpty,
|
||||||
|
resolveKanbanGroupBy,
|
||||||
|
} from './group-by-utils.js';
|
||||||
|
|
||||||
|
const materializeColumnsByPropertyIds = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const needShow = new Set(propertyIds);
|
||||||
|
const orderedColumns: KanbanViewColumn[] = [];
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
if (needShow.has(column.id)) {
|
||||||
|
orderedColumns.push(column);
|
||||||
|
needShow.delete(column.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of needShow) {
|
||||||
|
orderedColumns.push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const materializeKanbanColumns = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const nextColumns = materializeColumnsByPropertyIds(columns, propertyIds);
|
||||||
|
const unchanged =
|
||||||
|
columns.length === nextColumns.length &&
|
||||||
|
columns.every((column, index) => {
|
||||||
|
const nextColumn = nextColumns[index];
|
||||||
|
return (
|
||||||
|
nextColumn != null &&
|
||||||
|
column.id === nextColumn.id &&
|
||||||
|
column.hide === nextColumn.hide
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unchanged ? columns : nextColumns;
|
||||||
|
};
|
||||||
|
|
||||||
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||||
propertiesRaw$ = computed(() => {
|
propertiesRaw$ = computed(() => {
|
||||||
@@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
groupBy$ = computed(() => {
|
groupBy$ = computed(() => {
|
||||||
return this.data$.value?.groupBy;
|
const groupBy = this.data$.value?.groupBy;
|
||||||
|
if (!groupBy || groupBy.hideEmpty != null) {
|
||||||
|
return groupBy;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...groupBy,
|
||||||
|
hideEmpty: getKanbanDefaultHideEmpty(groupBy.name),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
groupTrait = this.traitSet(
|
groupTrait = this.traitSet(
|
||||||
groupTraitKey,
|
groupTraitKey,
|
||||||
new GroupTrait(this.groupBy$, this, {
|
new GroupTrait(this.groupBy$, this, {
|
||||||
groupBySet: groupBy => {
|
groupBySet: groupBy => {
|
||||||
|
const nextGroupBy = resolveKanbanGroupBy(
|
||||||
|
this.manager.dataSource,
|
||||||
|
groupBy
|
||||||
|
);
|
||||||
this.dataUpdate(() => {
|
this.dataUpdate(() => {
|
||||||
return {
|
return {
|
||||||
groupBy: groupBy,
|
groupBy: nextGroupBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -200,6 +256,23 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return this.view?.mode ?? 'kanban';
|
return this.view?.mode ?? 'kanban';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private materializeColumns() {
|
||||||
|
const view = this.view;
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextColumns = materializeKanbanColumns(
|
||||||
|
view.columns,
|
||||||
|
this.dataSource.properties$.value
|
||||||
|
);
|
||||||
|
if (nextColumns === view.columns) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataUpdate(() => ({ columns: nextColumns }));
|
||||||
|
}
|
||||||
|
|
||||||
get view() {
|
get view() {
|
||||||
return this.data$.value;
|
return this.data$.value;
|
||||||
}
|
}
|
||||||
@@ -289,6 +362,13 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||||
return new KanbanColumn(this, columnId);
|
return new KanbanColumn(this, columnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(viewManager: ViewManager, viewId: string) {
|
||||||
|
super(viewManager, viewId);
|
||||||
|
// Materialize view columns on view activation so newly added properties
|
||||||
|
// can participate in hide/order operations in kanban.
|
||||||
|
this.materializeColumns();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanColumnData = KanbanViewData['columns'][number];
|
type KanbanColumnData = KanbanViewData['columns'][number];
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
div.className = 'with-data-view-css-variable';
|
div.className = 'with-data-view-css-variable';
|
||||||
div.style.width = `${card.getBoundingClientRect().width}px`;
|
div.style.width = `${card.getBoundingClientRect().width}px`;
|
||||||
div.style.position = 'fixed';
|
div.style.position = 'fixed';
|
||||||
// div.style.pointerEvents = 'none';
|
div.style.pointerEvents = 'none';
|
||||||
div.style.transform = 'rotate(-3deg)';
|
div.style.transform = 'rotate(-3deg)';
|
||||||
div.style.left = `${x}px`;
|
div.style.left = `${x}px`;
|
||||||
div.style.top = `${y}px`;
|
div.style.top = `${y}px`;
|
||||||
@@ -209,8 +209,12 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
};
|
};
|
||||||
const createDropPreview = () => {
|
const createDropPreview = () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.style.height = '2px';
|
div.dataset.isDropPreview = 'true';
|
||||||
div.style.borderRadius = '1px';
|
div.style.pointerEvents = 'none';
|
||||||
|
div.style.position = 'fixed';
|
||||||
|
div.style.zIndex = '9999';
|
||||||
|
div.style.height = '3px';
|
||||||
|
div.style.borderRadius = '2px';
|
||||||
div.style.backgroundColor = 'var(--affine-primary-color)';
|
div.style.backgroundColor = 'var(--affine-primary-color)';
|
||||||
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
||||||
return {
|
return {
|
||||||
@@ -219,19 +223,50 @@ const createDropPreview = () => {
|
|||||||
self: KanbanCard | undefined,
|
self: KanbanCard | undefined,
|
||||||
card?: KanbanCard
|
card?: KanbanCard
|
||||||
) {
|
) {
|
||||||
const target = card ?? group.querySelector('.add-card');
|
if (card === self) {
|
||||||
if (!target) {
|
|
||||||
console.error('`target` is not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (target.previousElementSibling === self || target === self) {
|
|
||||||
div.remove();
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.previousElementSibling === div) {
|
|
||||||
|
if (!card) {
|
||||||
|
const cards = Array.from(
|
||||||
|
group.querySelectorAll('affine-data-view-kanban-card')
|
||||||
|
);
|
||||||
|
const lastCard = cards[cards.length - 1];
|
||||||
|
if (lastCard === self) {
|
||||||
|
div.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect: DOMRect | undefined;
|
||||||
|
let y = 0;
|
||||||
|
if (card) {
|
||||||
|
rect = card.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
} else {
|
||||||
|
const addCard = group.querySelector('.add-card');
|
||||||
|
if (addCard instanceof HTMLElement) {
|
||||||
|
rect = addCard.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
const body = group.querySelector('.group-body');
|
||||||
|
if (body instanceof HTMLElement) {
|
||||||
|
rect = body.getBoundingClientRect();
|
||||||
|
y = rect.bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.insertAdjacentElement('beforebegin', div);
|
|
||||||
|
document.body.append(div);
|
||||||
|
div.style.left = `${Math.round(rect.left)}px`;
|
||||||
|
div.style.top = `${Math.round(y - 2)}px`;
|
||||||
|
div.style.width = `${Math.round(rect.width)}px`;
|
||||||
},
|
},
|
||||||
remove() {
|
remove() {
|
||||||
div.remove();
|
div.remove();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
||||||
import type { SingleView } from '../../../core/index.js';
|
import type { SingleView } from '../../../core/index.js';
|
||||||
|
import { canGroupable } from '../group-by-utils.js';
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
affine-data-view-kanban-header {
|
affine-data-view-kanban-header {
|
||||||
@@ -43,7 +44,12 @@ export class KanbanHeader extends SignalWatcher(
|
|||||||
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
||||||
options: {
|
options: {
|
||||||
items: this.view.properties$.value
|
items: this.view.properties$.value
|
||||||
.filter(column => column.id !== groupTrait.property$.value?.id)
|
.filter(column => {
|
||||||
|
if (column.id === groupTrait.property$.value?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canGroupable(this.view.manager.dataSource, column.id);
|
||||||
|
})
|
||||||
.map(column => {
|
.map(column => {
|
||||||
return menu.action({
|
return menu.action({
|
||||||
name: column.name$.value,
|
name: column.name$.value,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import zod from 'zod';
|
|||||||
export const createdByColumnType = propertyType('created-by');
|
export const createdByColumnType = propertyType('created-by');
|
||||||
export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
|
export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
|
||||||
name: 'Created By',
|
name: 'Created By',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: false,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type MemberCellJsonValueType = zod.TypeOf<
|
|||||||
>;
|
>;
|
||||||
export const memberPropertyModelConfig = memberColumnType.modelConfig({
|
export const memberPropertyModelConfig = memberColumnType.modelConfig({
|
||||||
name: 'Member',
|
name: 'Member',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -338,8 +338,8 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1'],
|
rows: ['row1'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [1],
|
value: [true],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'rich-text',
|
type: 'rich-text',
|
||||||
@@ -350,8 +350,6 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
// group by `number` column, `Ungroups` is hidden because it's empty (hideEmpty: true by default)
|
|
||||||
// so the first visible group is the one with value "1" at groupIndex: 0
|
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
cellIndex: 0,
|
cellIndex: 0,
|
||||||
@@ -380,9 +378,9 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1', 'row2'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
// Both rows have value 1 to put them in the same group
|
// Both rows are checked so they stay in the same group.
|
||||||
value: [1, 1],
|
value: [true, true],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'rich-text',
|
type: 'rich-text',
|
||||||
@@ -394,8 +392,6 @@ test.describe('kanban view selection', () => {
|
|||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
await pressArrowUp(page);
|
await pressArrowUp(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
// `Ungroups` is hidden because it's empty (hideEmpty: true by default)
|
|
||||||
// so the first visible group is "1" at groupIndex: 0
|
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 1,
|
cardIndex: 1,
|
||||||
cellIndex: 2,
|
cellIndex: 2,
|
||||||
@@ -414,18 +410,18 @@ test.describe('kanban view selection', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await enterPlaygroundRoom(page);
|
await enterPlaygroundRoom(page);
|
||||||
await initKanbanViewState(page, {
|
await initKanbanViewState(page, {
|
||||||
rows: ['row1', 'row2', 'row3'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1, 10],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
|
|
||||||
await pressArrowRight(page, 3);
|
await pressArrowRight(page, 2);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
@@ -434,7 +430,7 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await pressArrowLeft(page);
|
await pressArrowLeft(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
groupIndex: 2,
|
groupIndex: 1,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
cellIndex: 0,
|
cellIndex: 0,
|
||||||
});
|
});
|
||||||
@@ -480,11 +476,11 @@ test.describe('kanban view selection', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await enterPlaygroundRoom(page);
|
await enterPlaygroundRoom(page);
|
||||||
await initKanbanViewState(page, {
|
await initKanbanViewState(page, {
|
||||||
rows: ['row1', 'row2', 'row3'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1, 10],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -493,7 +489,7 @@ test.describe('kanban view selection', () => {
|
|||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
|
|
||||||
await pressArrowRight(page, 3);
|
await pressArrowRight(page, 2);
|
||||||
await assertKanbanCardSelected(page, {
|
await assertKanbanCardSelected(page, {
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
@@ -501,7 +497,7 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await pressArrowLeft(page);
|
await pressArrowLeft(page);
|
||||||
await assertKanbanCardSelected(page, {
|
await assertKanbanCardSelected(page, {
|
||||||
groupIndex: 2,
|
groupIndex: 1,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -512,8 +508,8 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1', 'row2'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user