Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions blocksuite/affine/blocks/data-view/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { viewPresets } from '@blocksuite/data-view/view-presets';
export const blockQueryViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.chartViewMeta,
];

export const blockQueryViewMap = Object.fromEntries(
Expand Down
34 changes: 33 additions & 1 deletion blocksuite/affine/blocks/database/src/configs/slash-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '@blocksuite/icons/lit';

import { insertDatabaseBlockCommand } from '../commands';
import { KanbanViewTooltip, TableViewTooltip } from './tooltips';
import { KanbanViewTooltip, TableViewTooltip, ChartViewTooltip } from './tooltips';

export const databaseSlashMenuConfig: SlashMenuConfig = {
disableWhen: ({ model }) => model.flavour === 'affine:database',
Expand Down Expand Up @@ -79,5 +79,37 @@ export const databaseSlashMenuConfig: SlashMenuConfig = {
.run();
},
},
{
name: 'Chart View',
description: 'Display items as a chart.',
searchAlias: ['chart'],
icon: DatabaseKanbanViewIcon(),
tooltip: {
figure: ChartViewTooltip,
caption: 'Chart View',
},
group: '7_Database@4',
when: ({ model }) =>
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertDatabaseBlockCommand, {
viewType: viewPresets.chartViewMeta.type,
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedDatabaseBlockId }) => {
if (insertedDatabaseBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:database',
});
}
})
.run();
},
},
],
};
23 changes: 23 additions & 0 deletions blocksuite/affine/blocks/database/src/configs/tooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,26 @@ export const ToDoListTooltip = html`<svg width="170" height="68" viewBox="0 0 17
</g>
</svg>
`;


// prettier-ignore
export const ChartViewTooltip = html`
<svg
aria-hidden="true"
role="graphics-symbol"
viewBox="0 0 20 20"
class="viewChart"
style="
width: 20px;
height: 20px;
display: block;
fill: rgba(255, 255, 255, 0.81);
flex-shrink: 0;
margin-right: 6px;
"
>
<path
d="M2.375 10a7.625 7.625 0 1 1 15.25 0 7.625 7.625 0 0 1-15.25 0m1.25 0a6.375 6.375 0 0 0 11.208 4.157L9.65 10.632a.63.63 0 0 1-.274-.517v-6.46A6.376 6.376 0 0 0 3.625 10m12.75 0c0-.935-.201-1.823-.563-2.623l-4.65 2.772 4.39 2.986A6.35 6.35 0 0 0 16.375 10m-1.187-3.706a6.37 6.37 0 0 0-4.563-2.639v5.36z"
></path>
</svg>
`;
1 change: 1 addition & 0 deletions blocksuite/affine/blocks/database/src/database-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
widgetPresets.tools.viewOptions,
widgetPresets.tools.tableAddRow,
],
chart: [widgetPresets.tools.viewOptions],
});

private readonly viewSelection$ = computed(() => {
Expand Down
1 change: 1 addition & 0 deletions blocksuite/affine/blocks/database/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets';
export const databaseBlockViews: ViewMeta[] = [
viewPresets.tableViewMeta,
viewPresets.kanbanViewMeta,
viewPresets.chartViewMeta,
];

export const databaseBlockViewMap = Object.fromEntries(
Expand Down
1 change: 1 addition & 0 deletions blocksuite/affine/data-view/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@types/lodash-es": "^4.17.12",
"chart.js": "^4.4.9",
"clsx": "^2.1.1",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import type { SingleView } from './single-view.js';

export interface ViewManager {
propertyGetOrCreate(propId: string): Property<unknown, unknown, unknown>;
viewMetas: ViewMeta[];
dataSource: DataSource;
readonly$: ReadonlySignal<boolean>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { computed, type ReadonlySignal } from '@preact/signals-core';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { SingleViewBase } from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import type { ChartViewData } from './define.js';
import type { Cell } from '../../core/view-manager/cell.js';
import { PropertyBase } from '../../core/view-manager/property.js';


/**
* ChartSingleView manages the “Chart View” data. It computes, in a reactive
* signal, a mapping from each category value to its row‐count.
*/
export class ChartSingleView extends SingleViewBase<ChartViewData> {
/**
* categoryCounts$ is a computed signal that returns an object:
* { [categoryValue: string]: number }
* For each row in the datasource, it reads the display string of
* `categoryPropertyId` and increments the corresponding count. Using the
* string value ensures select properties use their tag names.
*/
readonly categoryCounts$: ReadonlySignal<Record<string, number>> = computed(() => {
const data = this.data$.value;
const categoryProp = data?.categoryPropertyId;
if (!categoryProp) {
return {}; // no category property selected → no data
}

// Get all existing rows in this view
const rows = this.rows$.value; // array of Row objects
const counts: Record<string, number> = {};

rows.forEach(row => {
// For each row, get/create the cell in the chosen property
const cell: Cell = this.cellGetOrCreate(row.rowId, categoryProp);
// Use the string value so Select/Multi-select show their tag names
const raw = cell.stringValue$.value as unknown;
const category = typeof raw === 'string' && raw.length > 0 ? raw : 'Undefined';
counts[category] = (counts[category] || 0) + 1;
});

return counts;
});

/**
* Overrides propertyGetOrCreate only if you need custom Property handling.
* For Chart, we only group by an existing property; we do not need extra property logic.
*/
override propertyGetOrCreate(propertyId: string): ChartProperty {
return new ChartProperty(this, propertyId);
}

/** Raw property list simply mirrors all datasource properties. */
readonly propertiesRaw$ = computed(() => {
return this.dataSource.properties$.value.map(id =>
this.propertyGetOrCreate(id)
);
});

/** All properties are visible in Chart view. */
readonly properties$ = computed(() => this.propertiesRaw$.value);

/** No extra detail properties beyond the normal list. */
readonly detailProperties$ = computed(() => this.properties$.value);

/** Title/icon columns follow the datasource defaults. */
readonly mainProperties$ = computed(() => ({
titleColumn: this.propertiesRaw$.value.find(p => p.type$.value === 'title')?.id,
iconColumn: 'type',
}));

/** Chart view respects the datasource readonly state. */
readonly readonly$ = computed(() => this.manager.readonly$.value);

/**
* The view mode string identifying this view type.
* If the backing data is missing we still return 'chart'.
*/
override get type(): string {
return this.data$.value?.mode ?? 'chart';
}

/** Display all rows. */
isShow(_rowId: string): boolean {
return true;
}

constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
Comment on lines +88 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove unnecessary constructor.

The constructor only calls the superclass constructor without any additional logic.

-    constructor(viewManager: ViewManager, viewId: string) {
-        super(viewManager, viewId);
-    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
// (constructor removed — no replacement needed)
🧰 Tools
🪛 Biome (1.9.4)

[error] 88-93: This constructor is unnecessary.

Unsafe fix: Remove the unnecessary constructor.

(lint/complexity/noUselessConstructor)

🤖 Prompt for AI Agents
In blocksuite/affine/data-view/src/view-presets/chart/chart-view-manager.ts at
lines 88 to 90, the constructor is redundant as it only calls the superclass
constructor without adding any logic. Remove the entire constructor method to
simplify the class definition and rely on the default superclass constructor
behavior.

}

/** Minimal property representation for Chart view. */
export class ChartProperty extends PropertyBase {
/** Chart view does not support hiding columns; always visible. */
hide$ = computed(() => false);

/** Hiding is ignored as properties are always visible. */
override hideSet(_hide: boolean): void {
// no-op
}

/**
* Chart view doesn\'t maintain its own ordering, so move() is a noop.
* @param _position - Unused insert position.
*/
override move(_position: InsertToPosition): void {
// no-op
}

constructor(readonly chartView: ChartSingleView, propertyId: string) {
super(chartView, propertyId);
}
}
43 changes: 43 additions & 0 deletions blocksuite/affine/data-view/src/view-presets/chart/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { BasicViewDataType } from '../../core/view/data-view.js';
import { viewType } from '../../core/view/data-view.js';
import { ChartSingleView } from './chart-view-manager.js';

export const chartViewType = viewType('chart');

export type ChartType = 'pie' | 'bar' | 'stacked-bar' | 'line';

export type ChartViewData = BasicViewDataType<
typeof chartViewType.type,
{
/** Property ID to group rows by (e.g. a “status” property). */
categoryPropertyId?: string;
/** How to display the chart (pie, bar, etc.). */
chartType?: ChartType;
}
>;

export const chartViewModel = chartViewType.createModel<ChartViewData>({
defaultName: 'Chart View',
dataViewManager: ChartSingleView,
defaultData: viewManager => {
const dataSource = viewManager.dataSource;
const allProps = dataSource.properties$.value;
let prop = allProps.find(
id => dataSource.propertyNameGet(id) === 'Status'
);
if (!prop) {
prop = allProps.find(id => {
const type = dataSource.propertyTypeGet(id);
return type === 'select' || type === 'multi-select';
});
}
if (!prop && allProps.length > 0) {
prop = allProps[0];
}
return {
mode: 'chart',
categoryPropertyId: prop,
chartType: 'pie',
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ChartViewUI } from './pc/chart-view-ui.js';

export function chartEffects() {
customElements.define('dv-chart-view-ui', ChartViewUI);
}
5 changes: 5 additions & 0 deletions blocksuite/affine/data-view/src/view-presets/chart/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './define.js';
export * from './chart-view-manager.js';
export * from './renderer.js';
export * from './effect.js';
export * from './styles.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { DataViewUILogicBase } from '../../../core/view/data-view-base.js';
import type { ChartSingleView } from '../chart-view-manager.js';
import { createUniComponentFromWebComponent } from '../../../core/utils/uni-component/uni-component.js';
import { signal } from '@preact/signals-core';
import { ChartViewUI } from './chart-view-ui.js';
import type { Chart } from 'chart.js';

/**
* ChartViewUILogic bridges the view‐layer (ChartSingleView) and the UI (ChartViewUI).
* We hold onto a reactive signal `ui$` so our controllers can mount custom elements,
* and we keep `chartInstance` to manage Chart.js.
*/
export class ChartViewUILogic extends DataViewUILogicBase<ChartSingleView, unknown> {
/** Holds the reference to the rendered ChartViewUI element. */
ui$ = signal<ChartViewUI>();

/** Once the chart is drawn, we keep this to update or destroy as needed. */
chartInstance: Chart<'doughnut', number[], string> | null = null;

/** The `render` function used by DataView to instantiate the UI. */
renderer = createUniComponentFromWebComponent(ChartViewUI);

/** Clean up the Chart instance when this logic is disposed. */
onHostDisconnected(): void {
if (this.chartInstance) {
this.chartInstance.destroy();
this.chartInstance = null;
}
}

clearSelection = () => {
// no selection concept in chart view
};

addRow = () => {
// chart view does not support adding rows
return undefined;
};

focusFirstCell = () => {
// nothing to focus in chart view
};

showIndicator = () => false;

hideIndicator = () => {
/* no-op */
};

moveTo = () => {
/* no-op */
};
}
Loading