feat(editor): add date grouping configurations (#12679)

https://github.com/user-attachments/assets/d5578060-2c8c-47a5-ba65-ef2e9430518b

This PR adds the ability to group-by date with configuration which an
example is shown in the image below:


![image](https://github.com/user-attachments/assets/8762342a-999e-444e-afa2-5cfbf7e24907)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Date-based grouping modes (relative, day, week Sun/Mon, month, year),
a date group renderer, and quick lookup for group-by configs by name.

* **Improvements**
* Enhanced group settings: date sub‑modes, week‑start, per‑group
visibility, Hide All/Show All, date sort order, improved drag/drop and
reorder.
* Consistent popup placement/middleware, nested popup positioning,
per‑item close-on-select, and enforced minimum menu heights.
* UI: empty groups now display "No <property>"; views defensively handle
null/hidden groups.

* **Tests**
  * Added unit tests for date-key sorting and comparison.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Norkz <richardlora557@gmail.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Richard Lora
2025-12-11 18:32:21 -04:00
committed by GitHub
parent b258fc3775
commit f832b28dac
42 changed files with 1642 additions and 575 deletions

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

@@ -6,6 +6,7 @@ import {
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';
@@ -235,13 +236,16 @@ export const popPropertiesSetting = (
view: SingleView;
onClose?: () => void;
onBack?: () => void;
}
},
middleware?: Array<Middleware | null | undefined | false>
) => {
popMenu(target, {
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);
@@ -270,8 +274,10 @@ export const popPropertiesSetting = (
],
}),
],
onClose: props.onClose,
},
});
handler.menu.menuElement.style.minHeight = '550px';
// const view = new DataViewPropertiesSettingView();
// view.view = props.view;

View File

@@ -2,6 +2,7 @@ export type GroupBy = {
type: 'groupBy';
columnId: string;
name: string;
hideEmpty?: boolean;
sort?: {
desc: boolean;
};

View File

@@ -24,7 +24,7 @@ export const popCreateFilter = (
middleware?: Middleware[];
}
) => {
popMenu(target, {
const subHandler = popMenu(target, {
middleware: ops?.middleware,
options: {
onClose: props.onClose,
@@ -64,4 +64,5 @@ export const popCreateFilter = (
],
},
});
subHandler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -15,6 +15,7 @@ export const allLiteralConfig: LiteralItemsConfig[] = [
() => {
return html` <date-picker
.padding="${8}"
.size="${20}"
.value="${value.value}"
.onChange="${(date: Date) => {
onChange(date.getTime());

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

@@ -18,6 +18,7 @@ export const defaultGroupBy = (
type: 'groupBy',
columnId: propertyId,
name: name,
hideEmpty: true,
}
: undefined;
};

View File

@@ -1,9 +1,22 @@
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';
@@ -15,171 +28,239 @@ export const createGroupByConfig = <
GroupValue = unknown,
>(
config: GroupByConfig<Data, MatchType, GroupValue>
): GroupByConfig => {
return config as never as GroupByConfig;
};
): GroupByConfig => config as never;
export const ungroups = {
key: 'Ungroups',
value: null,
};
export const groupByMatchers = [
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) {
if (t.tag.is(type) && type.data)
return type.data.find(v => v.id === value)?.value ?? '';
}
return '';
},
defaultKeys: type => {
if (t.tag.is(type) && type.data) {
return [
ungroups,
...type.data.map(v => ({
key: v.id,
value: v.id,
})),
];
}
return [ungroups];
},
valuesGroup: (value, _type) => {
if (value == null) {
return [ungroups];
}
return [
{
key: `${value}`,
value: value.toString(),
},
];
},
addToGroup: v => v,
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) {
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 => {
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) {
return [
ungroups,
...type.element.data.map(v => ({
key: v.id,
value: v.id,
})),
];
}
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];
},
valuesGroup: (value, _type) => {
if (value == null) {
return [ungroups];
}
if (Array.isArray(value) && value.length) {
return value.map(id => ({
key: `${id}`,
value: id,
}));
}
return [ungroups];
},
addToGroup: (value, old) => {
if (value == null) {
return old;
}
addToGroup: (
value: string | null,
old: string[] | null
): string[] | null => {
if (value == null) return old;
return Array.isArray(old) ? [...old, value] : [value];
},
removeFromGroup: (value, old) => {
if (Array.isArray(old)) {
return old.filter(v => v !== value);
}
return old;
},
removeFromGroup: (value, old) =>
Array.isArray(old) ? old.filter(v => v !== value) : old,
view: createUniComponentFromWebComponent(SelectGroupView),
}),
createGroupByConfig({
name: 'text',
matchType: t.string.instance(),
groupName: (_type, value: string | null) => {
return `${value ?? ''}`;
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value, _type) => {
if (typeof value !== 'string' || !value) {
return [ungroups];
}
return [
{
key: hash(value),
value,
},
];
},
addToGroup: v => v,
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: (_type, value: number | null) => {
return `${value ?? ''}`;
},
defaultKeys: _type => {
return [ungroups];
},
valuesGroup: (value: number | null, _type) => {
if (typeof value !== 'number') {
return [ungroups];
}
return [
{
key: `g:${Math.floor(value / 10)}`,
value: Math.floor(value / 10),
},
];
},
addToGroup: value => (typeof value === 'number' ? value * 10 : null),
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: (_type, value: boolean | null) => {
return `${value?.toString() ?? ''}`;
},
defaultKeys: _type => {
return [
{ key: 'true', value: true },
{ key: 'false', value: false },
];
},
valuesGroup: (value, _type) => {
if (typeof value !== 'boolean') {
return [
{
key: 'false',
value: false,
},
];
}
return [
{
key: value.toString(),
value: value,
},
];
},
addToGroup: v => v,
groupName: (_t, v) => `${v?.toString() ?? ''}`,
defaultKeys: _t => [
ungroups,
{ key: 'true', value: true },
{ key: 'false', value: false },
],
valuesGroup: (v, _t) =>
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
addToGroup: (v: boolean | null, _old: boolean | null) => v,
view: createUniComponentFromWebComponent(BooleanGroupView),
}),
dateRelativeCfg,
dateDayCfg,
dateWeekSunCfg,
dateWeekMonCfg,
dateMonthCfg,
dateYearCfg,
];

View File

@@ -9,6 +9,18 @@ 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) {}

View File

@@ -16,6 +16,14 @@ export class BooleanGroupView extends BaseGroup<boolean, NonNullable<unknown>> {
`;
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` })

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

@@ -45,7 +45,8 @@ export class NumberGroupView extends BaseGroup<number, NonNullable<unknown>> {
protected override render(): unknown {
if (this.value == null) {
return html` <div>Ungroups</div>`;
const displayName = `No ${this.group.property.name$.value}`;
return html` <div>${displayName}</div>`;
}
if (this.value >= 10) {
return html` <div

View File

@@ -84,10 +84,11 @@ export class SelectGroupView extends BaseGroup<
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;"
>
Ungroups
${displayName}
</div>`;
}
const style = styleMap({

View File

@@ -41,7 +41,8 @@ export class StringGroupView extends BaseGroup<string, NonNullable<unknown>> {
protected override render(): unknown {
if (!this.value) {
return html` <div>Ungroups</div>`;
const displayName = `No ${this.group.property.name$.value}`;
return html` <div>${displayName}</div>`;
}
return html` <div
@click="${this._click}"

View File

@@ -1,4 +1,5 @@
import {
dropdownSubMenuMiddleware,
menu,
type MenuConfig,
type MenuOptions,
@@ -6,9 +7,12 @@ import {
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DeleteIcon } from '@blocksuite/icons/lit';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { autoPlacement, offset, shift } 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 { repeat } from 'lit/directives/repeat.js';
@@ -28,6 +32,24 @@ 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)
) {
@@ -39,13 +61,44 @@ export class GroupSetting extends SignalWatcher(
${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;
@@ -57,18 +110,49 @@ export class GroupSetting extends SignalWatcher(
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(() => {
return this.groupTrait.groupsDataList$.value;
});
groups$ = computed(() => this.groupTrait.groupsDataListAll$.value);
sortContext = createSortContext({
activators: defaultActivators,
@@ -78,99 +162,101 @@ export class GroupSetting extends SignalWatcher(
const activeId = evt.active.id;
const groups = this.groups$.value;
if (over && over.id !== activeId && groups) {
const activeIndex = groups.findIndex(data => data?.key === activeId);
const overIndex = groups.findIndex(data => data?.key === over.id);
const aIndex = groups.findIndex(g => g?.key === activeId);
const oIndex = groups.findIndex(g => g?.key === over.id);
this.groupTrait.moveGroupTo(
activeId,
activeIndex > overIndex
? {
before: true,
id: over.id,
}
: {
before: false,
id: over.id,
}
aIndex > oIndex
? { before: true, id: over.id }
: { before: false, id: over.id }
);
}
},
modifiers: [
({ transform }) => {
return {
...transform,
x: 0,
};
},
],
items: computed(() => {
return (
this.groupTrait.groupsDataList$.value?.map(
v => v?.key ?? 'default key'
) ?? []
);
}),
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();
});
this._disposables.addFromEvent(this, 'pointerdown', e =>
e.stopPropagation()
);
}
protected override render(): unknown {
const groups = this.groupTrait.groupsDataList$.value;
if (!groups) {
return;
}
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;">
<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;"
style="padding:0 4px;font-size:12px;color:var(--affine-text-secondary-color);line-height:20px;"
>
Groups
</div>
<div></div>
<div class="properties-group-op" @click="${clickChangeAll}">
${isAllShowed ? 'Hide All' : 'Show All'}
</div>
</div>
<div
style="display:flex;flex-direction: column;gap: 4px;"
class="group-sort-setting"
>
<div class="group-sort-setting">
${repeat(
groups,
group => group?.key ?? 'default key',
group => {
const type = group.property.dataType$.value;
g => g?.key ?? 'k',
g => {
if (!g) return;
const type = g.property.dataType$.value;
if (!type) return;
const props: GroupRenderProps = {
group,
readonly: true,
};
return html` <div
${sortable(group.key)}
${dragHandler(group.key)}
class="dv-hover dv-round-4 group-item"
>
<div class="group-item-drag-bar"></div>
const props: GroupRenderProps = { group: g, readonly: true };
const icon = g.hide$.value ? InvisibleIcon() : ViewIcon();
return html`
<div
style="padding: 0 4px;position:relative;pointer-events: none;max-width: 330px"
${sortable(g.key)}
${dragHandler(g.key)}
class="dv-hover dv-round-4 group-item ${g.hide$.value
? 'group-hidden'
: ''}"
>
${renderUniLit(group.view, props)}
<div class="group-item-drag-bar"></div>
<div
style="position:absolute;left: 0;top: 0;right: 0;bottom: 0;"
></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>`;
`;
}
)}
</div>
`;
}
@query('.group-sort-setting')
accessor groupContainer!: HTMLElement;
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
}
export const selectGroupByProperty = (
@@ -184,10 +270,7 @@ export const selectGroupByProperty = (
const view = group.view;
return {
onClose: ops?.onClose,
title: {
text: 'Group by',
onBack: ops?.onBack,
},
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
items: [
menu.group({
items: view.propertiesRaw$.value
@@ -219,7 +302,7 @@ export const selectGroupByProperty = (
menu.action({
prefix: DeleteIcon(),
hide: () =>
view instanceof KanbanSingleView || group.property$.value == null,
view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true },
name: 'Remove Grouping',
select: () => {
@@ -232,77 +315,305 @@ export const selectGroupByProperty = (
],
};
};
export const popSelectGroupByProperty = (
target: PopupTarget,
group: GroupTrait,
ops?: {
onSelect?: () => void;
onClose?: () => void;
onBack?: () => void;
}
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
middleware?: Array<Middleware | null | undefined | false>
) => {
popMenu(target, {
const handler = popMenu(target, {
options: selectGroupByProperty(group, ops),
middleware,
});
handler.menu.menuElement.style.minHeight = '550px';
};
export const popGroupSetting = (
target: PopupTarget,
group: GroupTrait,
onBack: () => void
onBack: () => void,
onClose?: () => void,
middleware?: Array<Middleware | null | undefined | false>
) => {
const view = group.view;
const groupProperty = group.property$.value;
if (groupProperty == null) {
return;
}
const type = groupProperty.type$.value;
if (!type) {
return;
}
const icon = groupProperty.icon;
const gProp = group.property$.value;
if (!gProp) return;
const type = gProp.type$.value;
if (!type) return;
const icon = gProp.icon;
const menuHandler = popMenu(target, {
options: {
title: {
text: 'Group',
onBack: onBack,
onBack,
onClose,
},
items: [
menu.group({
items: [
menu.subMenu({
menu.action({
name: 'Group By',
postfix: html`
<div
style="display:flex;align-items:center;gap: 4px;font-size: 12px;line-height: 20px;color: var(--affine-text-secondary-color);margin-right: 4px;margin-left: 8px;"
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, {})} ${groupProperty.name$.value}
${renderUniLit(icon, {})} ${gProp.name$.value}
</div>
`,
label: () => html`
<div style="color: var(--affine-text-secondary-color);">
Group By
</div>
`,
options: selectGroupByProperty(group, {
onSelect: () => {
menuHandler.close();
popGroupSetting(target, group, onBack);
select: () => {
const subHandler = popMenu(target, {
options: selectGroupByProperty(group, {
onSelect: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onBack: () => {
menuHandler.close();
popGroupSetting(
target,
group,
onBack,
onClose,
middleware
);
},
onClose,
}),
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
}),
],
}),
...(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: [
menu =>
html` <data-view-group-setting
@mouseenter="${() => menu.closeSubMenu()}"
.groupTrait="${group}"
.columnId="${groupProperty.id}"
></data-view-group-setting>`,
menu => html`
<data-view-group-setting
@mouseenter=${() => menu.closeSubMenu()}
.groupTrait=${group}
.columnId=${gProp.id}
></data-view-group-setting>
`,
],
}),
menu.group({
items: [
menu.action({
@@ -312,11 +623,14 @@ export const popGroupSetting = (
hide: () => !(view instanceof TableSingleView),
select: () => {
group.changeGroup(undefined);
return false;
},
}),
],
}),
],
},
middleware,
});
menuHandler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -2,7 +2,12 @@ import {
insertPositionToIndex,
type InsertToPosition,
} from '@blocksuite/affine-shared/utils';
import { computed, type ReadonlySignal } from '@preact/signals-core';
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';
@@ -11,8 +16,10 @@ 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 { getGroupByService } from './matcher.js';
import { findGroupByConfigByName, getGroupByService } from './matcher.js';
// Test
import type { GroupByConfig } from './types.js';
export type GroupInfo<
@@ -42,138 +49,71 @@ export class Group<
get property() {
return this.groupInfo.property;
}
name$ = computed(() => {
const type = this.property.dataType$.value;
if (!type) {
return '';
}
return this.groupInfo.config.groupName(type, this.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);
}
export class GroupTrait {
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 groupByService = getGroupByService(this.view.manager.dataSource);
const result = groupByService?.matcher.match(tType);
if (!result) {
return;
}
return {
config: result,
property,
tType: tType,
};
hideEmpty$ = signal<boolean>(true);
sortAsc$ = signal<boolean>(true);
groupProperties$ = computed(() => {
const data = this.view.data$.value;
return hasGroupProperties(data) ? (data.groupProperties ?? []) : [];
});
staticInfo$ = computed(() => {
const groupInfo = this.groupInfo$.value;
if (!groupInfo) {
return;
}
const staticMap = Object.fromEntries(
groupInfo.config
.defaultKeys(groupInfo.tType)
.map(({ key, value }) => [key, new Group(key, value, groupInfo, this)])
);
return {
staticMap,
groupInfo,
};
});
groupDataMap$ = computed(() => {
const staticInfo = this.staticInfo$.value;
if (!staticInfo) {
return;
}
const { staticMap, groupInfo } = staticInfo;
const groupMap: Record<string, Group> = {};
Object.entries(staticMap).forEach(([key, group]) => {
groupMap[key] = new Group(key, group.value, groupInfo, this);
groupPropertiesMap$ = computed(() => {
const map: Record<string, GroupProperty> = {};
this.groupProperties$.value.forEach(g => {
map[g.key] = g;
});
this.view.rows$.value.forEach(row => {
const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id)
.jsonValue$.value;
const keys = groupInfo.config.valuesGroup(value, groupInfo.tType);
keys.forEach(({ key, value }) => {
if (!groupMap[key]) {
groupMap[key] = new Group(key, value, groupInfo, this);
}
groupMap[key].rows.push(row);
});
});
return groupMap;
});
groupsDataList$ = computedLock(
computed(() => {
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
}
const sortedGroup = this.ops.sortGroup(Object.keys(groupMap));
sortedGroup.forEach(key => {
if (!groupMap[key]) return;
groupMap[key].rows = this.ops.sortRow(key, groupMap[key].rows);
});
return sortedGroup
.map(key => groupMap[key])
.filter((v): v is Group => v != null);
}),
this.view.isLocked$
);
updateData = (data: NonNullable<unknown>) => {
const property = this.property$.value;
if (!property) {
return;
}
this.view.propertyGetOrCreate(property.id).dataUpdate(() => data);
};
get addGroup() {
return this.property$.value?.meta$.value?.config.addGroup;
}
property$ = computed(() => {
const groupInfo = this.groupInfo$.value;
if (!groupInfo) {
return;
}
return groupInfo.property;
return map;
});
/**
* Synchronize sortAsc$ with the GroupBy sort descriptor
*/
constructor(
private readonly groupBy$: ReadonlySignal<GroupBy | undefined>,
public view: SingleView,
private readonly ops: {
groupBySet: (groupBy: GroupBy | undefined) => void;
sortGroup: (keys: string[]) => string[];
groupBySet: (g: GroupBy | undefined) => void;
sortGroup: (keys: string[], asc?: boolean) => string[];
sortRow: (groupKey: string, rows: Row[]) => Row[];
changeGroupSort: (keys: string[]) => void;
changeRowSort: (
@@ -181,11 +121,188 @@ export class GroupTrait {
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;
let ordered: string[];
if (gi?.config.matchType.name === 'Date') {
ordered = Object.keys(map).sort(
compareDateKeys(gi.config.name, this.sortAsc$.value)
);
} else {
ordered = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
}
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;
let orderedKeys: string[];
if (info.config.matchType.name === 'Date') {
orderedKeys = Object.keys(map).sort(
compareDateKeys(info.config.name, this.sortAsc$.value)
);
} else {
orderedKeys = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
}
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) {
@@ -205,18 +322,34 @@ export class GroupTrait {
.cellGetOrCreate(rowId, groupInfo.property.id)
.valueSet(newValue);
}
}
const map = this.groupDataMap$.value;
const info = this.groupInfo$.value;
if (!map || !info) return;
changeCardSort(groupKey: string, cardIds: string[]) {
const groups = this.groupsDataList$.value;
if (!groups) {
return;
}
this.ops.changeRowSort(
groups.map(v => v.key),
groupKey,
cardIds
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) {
@@ -225,31 +358,38 @@ export class GroupTrait {
return;
}
const column = this.view.propertyGetOrCreate(columnId);
const propertyMeta = this.view.manager.dataSource.propertyMetaGet(
const meta = this.view.manager.dataSource.propertyMetaGet(
column.type$.value
);
if (propertyMeta) {
this.ops.groupBySet(
defaultGroupBy(
this.view.manager.dataSource,
propertyMeta,
column.id,
column.data$.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);
}
}
changeGroupSort(keys: string[]) {
this.ops.changeGroupSort(keys);
property$ = computed(() => this.groupInfo$.value?.property);
get addGroup() {
return this.property$.value?.meta$.value?.config.addGroup;
}
defaultGroupProperty(key: string): GroupProperty {
return {
key,
hide: false,
manuallyCardSort: [],
};
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(
@@ -258,7 +398,6 @@ export class GroupTrait {
toGroupKey: string,
position: InsertToPosition
) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -291,16 +430,16 @@ export class GroupTrait {
.map(row => row.rowId) ?? [];
const index = insertPositionToIndex(position, rows, row => row);
rows.splice(index, 0, rowId);
this.changeCardSort(toGroupKey, rows);
const groupKeys = Object.keys(groupMap);
this.ops.changeRowSort(groupKeys, toGroupKey, rows);
}
moveGroupTo(groupKey: string, position: InsertToPosition) {
this.view.lockRows(false);
const groups = this.groupsDataList$.value;
if (!groups) {
return;
}
const keys = groups.map(v => v.key);
const keys = groups.map(v => v!.key);
keys.splice(
keys.findIndex(key => key === groupKey),
1
@@ -311,7 +450,6 @@ export class GroupTrait {
}
removeFromGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value;
if (!groupMap) {
return;
@@ -330,7 +468,6 @@ export class GroupTrait {
}
updateValue(rows: string[], value: unknown) {
this.view.lockRows(false);
const propertyId = this.property$.value?.id;
if (!propertyId) {
return;

View File

@@ -3,6 +3,7 @@ import {
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';
@@ -13,9 +14,13 @@ export const popCreateSort = (
sortUtils: SortUtils;
onClose?: () => void;
onBack?: () => void;
},
ops?: {
middleware?: Middleware[];
}
) => {
popMenu(target, {
const subHandler = popMenu(target, {
middleware: ops?.middleware,
options: {
onClose: props.onClose,
title: {
@@ -50,4 +55,5 @@ export const popCreateSort = (
],
},
});
subHandler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -20,6 +20,7 @@ export type MainProperties = {
};
export interface SingleView {
data$: any;
readonly id: string;
readonly type: string;
readonly manager: ViewManager;

View File

@@ -23,7 +23,7 @@ export const dateValueContainerStyle = css({
color: 'var(--text-secondary)',
fontSize: '17px',
lineHeight: '22px',
height: '46px',
height: '30px',
});
export const datePickerContainerStyle = css({

View File

@@ -74,12 +74,15 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
};
});
},
sortGroup: ids =>
sortByManually(
sortGroup: (ids, asc) => {
const sorted = sortByManually(
ids,
v => v,
this.view?.groupProperties.map(v => v.key) ?? []
),
);
// If descending order is requested, reverse the sorted array
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
const property = this.view?.groupProperties.find(v => v.key === key);
return sortByManually(
@@ -136,6 +139,33 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
};
});
},
changeGroupHide: (key, hide) => {
this.dataUpdate(() => {
const list = [...(this.view?.groupProperties ?? [])];
const idx = list.findIndex(g => g.key === key);
if (idx >= 0) {
const target = list[idx];
if (!target) {
return { groupProperties: list };
}
list[idx] = { ...target, hide };
} else {
// maintain existing order when inserting a new entry
const order = (this.groupTrait.groupsDataListAll$.value ?? [])
.map(g => g?.key)
.filter((k): k is string => typeof k === 'string');
let insertPos = 0;
for (const k of order) {
if (k === key) break;
if (list.findIndex(g => g.key === k) !== -1) {
insertPos++;
}
}
list.splice(insertPos, 0, { key, hide, manuallyCardSort: [] });
}
return { groupProperties: list };
});
},
})
);

View File

@@ -136,6 +136,9 @@ export class MobileKanbanViewUI extends DataViewUIBase<MobileKanbanViewUILogic>
if (!groups) {
return html``;
}
const groupEntries = groups.filter(
(group): group is NonNullable<(typeof groups)[number]> => group != null
);
const vPadding = this.logic.root.config.virtualPadding$.value;
const wrapperStyle = styleMap({
marginLeft: `-${vPadding}px`,
@@ -149,7 +152,7 @@ export class MobileKanbanViewUI extends DataViewUIBase<MobileKanbanViewUILogic>
})}
<div class="${mobileKanbanGroups}" style="${wrapperStyle}">
${repeat(
groups,
groupEntries,
group => group.key,
group => {
return html` <mobile-kanban-group

View File

@@ -25,6 +25,9 @@ export const popCardMenu = (
if (!groupTrait) {
return;
}
const groups = (groupTrait.groupsDataList$.value ?? []).filter(
(v): v is NonNullable<typeof v> => v != null
);
popFilterableSimpleMenu(ele, [
menu.group({
items: [
@@ -47,12 +50,10 @@ export const popCardMenu = (
prefix: ArrowRightBigIcon(),
options: {
items:
groupTrait.groupsDataList$.value
?.filter(v => {
return v.key !== groupKey;
})
.map(group => {
return menu.action({
groups
.filter(v => v.key !== groupKey)
.map(group =>
menu.action({
name: group.value != null ? group.name$.value : 'Ungroup',
select: () => {
groupTrait.moveCardTo(
@@ -62,8 +63,8 @@ export const popCardMenu = (
'start'
);
},
});
}) ?? [],
})
) ?? [],
},
}),
],

View File

@@ -202,7 +202,11 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
return html``;
}
return html`${groups.map(group => {
const safeGroups = groups.filter(
(group): group is NonNullable<(typeof groups)[number]> => group != null
);
return html`${safeGroups.map(group => {
return html` <affine-data-view-kanban-group
${sortable(group.key)}
data-key="${group.key}"
@@ -226,8 +230,13 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
}
override render(): TemplateResult {
const groups = this.logic.groups$.value;
if (!groups) {
const groups = this.logic.groups$.value?.filter(
(
group
): group is NonNullable<(typeof this.logic.groups$.value)[number]> =>
group != null
);
if (!groups || groups.length === 0) {
return html``;
}

View File

@@ -37,6 +37,9 @@ export const popCardMenu = (
rowId: string,
selection: KanbanSelectionController
) => {
const groups = (selection.view.groupTrait.groupsDataList$.value ?? []).filter(
(v): v is NonNullable<typeof v> => v != null
);
popFilterableSimpleMenu(ele, [
menu.action({
name: 'Expand Card',
@@ -50,22 +53,23 @@ export const popCardMenu = (
prefix: ArrowRightBigIcon(),
options: {
items:
selection.view.groupTrait.groupsDataList$.value
?.filter(v => {
groups
.filter(v => {
const cardSelection = selection.selection;
if (cardSelection?.selectionType === 'card') {
return v.key !== cardSelection?.cards[0].groupKey;
const currentGroup = cardSelection.cards[0]?.groupKey;
return currentGroup ? v.key !== currentGroup : true;
}
return false;
})
.map(group => {
return menu.action({
.map(group =>
menu.action({
name: group.value != null ? group.name$.value : 'Ungroup',
select: () => {
selection.moveCard(rowId, group.key);
},
});
}) ?? [],
})
) ?? [],
},
}),
menu.group({

View File

@@ -108,10 +108,13 @@ export class MobileTableViewUI extends DataViewUIBase<MobileTableViewUILogic> {
private renderTable() {
const groups = this.logic.view.groupTrait.groupsDataList$.value;
if (groups) {
const groupEntries = groups.filter(
(group): group is NonNullable<(typeof groups)[number]> => group != null
);
return html`
<div style="display:flex;flex-direction: column;gap: 16px;">
${repeat(
groups,
groupEntries,
v => v.key,
group => {
return html` <mobile-table-group

View File

@@ -18,9 +18,11 @@ export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
accessor gridGroup!: TableGridGroup;
group$ = computed(() => {
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find(
g => g.key === this.gridGroup.groupId
);
const groups =
this.tableViewLogic.groupTrait$.value?.groupsDataList$.value ?? [];
return groups
.filter((group): group is NonNullable<typeof group> => group != null)
.find(g => g.key === this.gridGroup.groupId);
});
get selectionController() {

View File

@@ -35,9 +35,11 @@ export class TableGroupHeader extends SignalWatcher(
}
group$ = computed(() => {
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find(
g => g.key === this.gridGroup.groupId
);
const groups =
this.tableViewLogic.groupTrait$.value?.groupsDataList$.value ?? [];
return groups
.filter((group): group is NonNullable<typeof group> => group != null)
.find(g => g.key === this.gridGroup.groupId);
});
groupKey$ = computed(() => {

View File

@@ -95,7 +95,14 @@ export class VirtualTableViewUILogic extends DataViewUILogicBase<
},
];
}
return groupTrait.groupsDataList$.value.map(group => ({
const groups = groupTrait.groupsDataList$.value.filter(
(
group
): group is NonNullable<
(typeof groupTrait.groupsDataList$.value)[number]
> => group != null
);
return groups.map(group => ({
id: group.key,
rows: group.rows.map(v => v.rowId),
}));

View File

@@ -92,6 +92,17 @@ export const addGroupIconStyle = css({
fill: 'var(--affine-icon-color)',
},
});
export const groupsHiddenMessageStyle = css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '80px',
zIndex: 0,
color: 'var(--affine-text-secondary-color)',
fontSize: '14px',
textAlign: 'center',
});
const cellDividerStyle = css({
width: '1px',
height: '100%',

View File

@@ -12,7 +12,7 @@ import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { GroupTrait } from '../../../core/group-by/trait.js';
import type { Group, GroupTrait } from '../../../core/group-by/trait.js';
import {
createUniComponentFromWebComponent,
renderUniLit,
@@ -30,6 +30,7 @@ import { TableSelectionController } from './controller/selection.js';
import {
addGroupIconStyle,
addGroupStyle,
groupsHiddenMessageStyle,
tableGroupsContainerStyle,
tableScrollContainerStyle,
tableViewStyle,
@@ -154,26 +155,27 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
}
private renderTable() {
const groups = this.logic.view.groupTrait.groupsDataList$.value;
if (groups) {
const groups = this.logic.view.groupTrait.groupsDataList$.value?.filter(
(g): g is Group => g !== undefined
);
if (groups && groups.length) {
return html`
<div class="${tableGroupsContainerStyle}">
${repeat(
groups,
v => v.key,
group => {
return html` <affine-data-view-table-group
group => group.key,
group =>
html`<affine-data-view-table-group
data-group-key="${group.key}"
.tableViewLogic="${this.logic}"
.group="${group}"
></affine-data-view-table-group>`;
}
></affine-data-view-table-group>`
)}
${this.logic.renderAddGroup(this.logic.view.groupTrait)}
</div>
`;
}
return html` <affine-data-view-table-group
return html`<affine-data-view-table-group
.tableViewLogic="${this.logic}"
></affine-data-view-table-group>`;
}
@@ -205,7 +207,11 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
class="affine-database-table-container"
style="${containerStyle}"
>
${this.renderTable()}
${this.logic.view.groupTrait.allHidden$.value
? html`<div class="${groupsHiddenMessageStyle}">
All groups are hidden
</div>`
: this.renderTable()}
</div>
</div>
</div>

View File

@@ -101,12 +101,15 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
};
});
},
sortGroup: ids =>
sortByManually(
sortGroup: (ids, asc) => {
const sorted = sortByManually(
ids,
v => v,
this.groupProperties.map(v => v.key)
),
);
// If descending order is requested, reverse the sorted array
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => {
const property = this.groupProperties.find(v => v.key === key);
return sortByManually(
@@ -163,6 +166,30 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
};
});
},
changeGroupHide: (key, hide) => {
this.dataUpdate(() => {
const list = [...this.groupProperties];
const idx = list.findIndex(g => g.key === key);
if (idx >= 0) {
const target = list[idx];
if (!target) {
return { groupProperties: list };
}
list[idx] = { ...target, hide };
} else {
const order = (this.groupTrait.groupsDataListAll$.value ?? [])
.map(g => g?.key)
.filter((k): k is string => !!k);
let insertPos = 0;
for (const k of order) {
if (k === key) break;
if (list.some(g => g.key === k)) insertPos++;
}
list.splice(insertPos, 0, { key, hide, manuallyCardSort: [] });
}
return { groupProperties: list };
});
},
})
);

View File

@@ -4,7 +4,6 @@ import {
popMenu,
type PopupTarget,
popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
@@ -13,6 +12,7 @@ import {
DeleteIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
@@ -99,6 +99,11 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
return;
}
const handler = popMenu(target, {
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
offset({ mainAxis: 4, crossAxis: 0 }),
shift({ crossAxis: true }),
],
options: {
items: [
menu.group({
@@ -107,7 +112,7 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
name: fn.label,
postfix: ArrowRightSmallIcon(),
select: ele => {
popMenu(popupTargetFromElement(ele), {
const subHandler = popMenu(popupTargetFromElement(ele), {
options: {
items: [
menu.group({
@@ -117,8 +122,18 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
}),
],
},
middleware: subMenuMiddleware,
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start'],
}),
offset({ mainAxis: 4, crossAxis: 0 }),
shift({ crossAxis: true }),
],
});
// allow submenu height and width to adjust to content
subHandler.menu.menuElement.style.minHeight = 'fit-content';
subHandler.menu.menuElement.style.maxHeight = 'fit-content';
subHandler.menu.menuElement.style.minWidth = '200px';
return false;
},
}),
@@ -142,6 +157,10 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
],
},
});
// allow main menu height and width to adjust to calendar size
handler.menu.menuElement.style.minHeight = 'fit-content';
handler.menu.menuElement.style.maxHeight = 'fit-content';
handler.menu.menuElement.style.minWidth = '200px';
};
@property({ attribute: false })

View File

@@ -1,10 +1,8 @@
import {
menu,
popFilterableSimpleMenu,
popMenu,
type PopupTarget,
popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu';
import { SignalWatcher } from '@blocksuite/global/lit';
import {
@@ -17,6 +15,7 @@ import {
PlusIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { type Middleware, offset } from '@floating-ui/dom';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, state } from 'lit/decorators.js';
@@ -208,66 +207,64 @@ export class FilterRootView extends SignalWatcher(ShadowlessElement) {
if (!filter) {
return;
}
popFilterableSimpleMenu(popupTargetFromElement(target), [
menu.action({
name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
prefix: ConvertIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
hide: () => getDepth(filter) > 3,
select: () => {
this.onChange({
type: 'group',
op: 'and',
conditions: [this.filterGroup.value],
});
},
}),
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(
i + 1,
0,
JSON.parse(JSON.stringify(conditions[i]))
);
this.onChange({ ...this.filterGroup.value, conditions: conditions });
},
}),
menu.group({
name: '',
const handler = popMenu(popupTargetFromElement(target), {
placement: 'bottom-end',
middleware: [offset({ mainAxis: 12, crossAxis: 0 })],
options: {
items: [
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
class: { 'delete-item': true },
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'delete-style' }
: undefined;
},
name:
filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
prefix: ConvertIcon(),
hide: () => getDepth(filter) > 3,
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(i, 1);
this.onChange({
...this.filterGroup.value,
conditions,
type: 'group',
op: 'and',
conditions: [this.filterGroup.value],
});
},
}),
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(
i + 1,
0,
JSON.parse(JSON.stringify(conditions[i]))
);
this.onChange({
...this.filterGroup.value,
conditions: conditions,
});
},
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
class: { 'delete-item': true },
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(i, 1);
this.onChange({
...this.filterGroup.value,
conditions,
});
},
}),
],
}),
],
}),
]);
},
});
handler.menu.menuElement.style.minWidth = '200px';
handler.menu.menuElement.style.maxWidth = 'fit-content';
handler.menu.menuElement.style.minHeight = 'fit-content';
}
private deleteFilter(i: number) {
@@ -378,16 +375,20 @@ export const popFilterRoot = (
props: {
filterTrait: FilterTrait;
onBack: () => void;
onClose?: () => void;
dataViewLogic: DataViewUILogicBase;
}
},
middleware?: Array<Middleware | null | undefined | false>
) => {
const filterTrait = props.filterTrait;
const view = filterTrait.view;
popMenu(target, {
const handler = popMenu(target, {
middleware,
options: {
title: {
text: 'Filters',
onBack: props.onBack,
onClose: props.onClose,
},
items: [
menu.group({
@@ -409,23 +410,16 @@ export const popFilterRoot = (
prefix: PlusIcon(),
select: ele => {
const value = filterTrait.filter$.value;
popCreateFilter(
popupTargetFromElement(ele),
{
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
props.dataViewLogic.eventTrace(
'CreateDatabaseFilter',
{}
);
},
popCreateFilter(popupTargetFromElement(ele), {
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
props.dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
{ middleware: subMenuMiddleware }
);
});
return false;
},
}),
@@ -434,4 +428,5 @@ export const popFilterRoot = (
],
},
});
handler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -13,6 +13,7 @@ import {
PlusIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { computed } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
@@ -203,11 +204,14 @@ export const popSortRoot = (
title?: {
text: string;
onBack?: () => void;
onClose?: () => void;
};
}
},
middleware?: Array<Middleware | null | undefined | false>
) => {
const sortUtils = props.sortUtils;
popMenu(target, {
const handler = popMenu(target, {
middleware,
options: {
title: props.title,
items: [
@@ -237,4 +241,5 @@ export const popSortRoot = (
],
},
});
handler.menu.menuElement.style.minHeight = '550px';
};

View File

@@ -18,6 +18,7 @@ import {
MoreHorizontalIcon,
SortIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
@@ -97,7 +98,8 @@ declare global {
const createSettingMenus = (
target: PopupTarget,
dataViewLogic: DataViewUILogicBase,
reopen: () => void
reopen: () => void,
closeMenu: () => void
) => {
const view = dataViewLogic.view;
const settingItems: MenuConfig[] = [];
@@ -105,15 +107,25 @@ const createSettingMenus = (
menu.action({
name: 'Properties',
prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown
</div>
${ArrowRightSmallIcon()}`,
select: () => {
popPropertiesSetting(target, {
view: view,
onBack: reopen,
});
popPropertiesSetting(
target,
{
view: view,
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
},
})
);
@@ -124,6 +136,7 @@ const createSettingMenus = (
menu.action({
name: 'Filter',
prefix: FilterIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${filterCount === 0
? ''
@@ -134,28 +147,66 @@ const createSettingMenus = (
${ArrowRightSmallIcon()}`,
select: () => {
if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(target, {
vars: view.vars$,
onBack: reopen,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [...filterTrait.filter$.value.conditions, filter],
});
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
dataViewLogic: dataViewLogic,
});
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
popCreateFilter(
target,
{
vars: view.vars$,
onBack: reopen,
onClose: closeMenu,
onSelect: filter => {
filterTrait.filterSet({
...(filterTrait.filter$.value ?? emptyFilterGroup),
conditions: [
...filterTrait.filter$.value.conditions,
filter,
],
});
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
},
},
});
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popFilterRoot(target, {
filterTrait: filterTrait,
onBack: reopen,
dataViewLogic: dataViewLogic,
});
popFilterRoot(
target,
{
filterTrait: filterTrait,
onBack: reopen,
onClose: closeMenu,
dataViewLogic: dataViewLogic,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
@@ -168,6 +219,7 @@ const createSettingMenus = (
menu.action({
name: 'Sort',
prefix: SortIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${sortCount === 0
? ''
@@ -183,18 +235,42 @@ const createSettingMenus = (
dataViewLogic.eventTrace
);
if (!sortList.length) {
popCreateSort(target, {
sortUtils: sortUtils,
onBack: reopen,
});
} else {
popSortRoot(target, {
sortUtils: sortUtils,
title: {
text: 'Sort',
popCreateSort(
target,
{
sortUtils: sortUtils,
onBack: reopen,
onClose: closeMenu,
},
});
{
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}
);
} else {
popSortRoot(
target,
{
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen,
onClose: closeMenu,
},
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
}
},
})
@@ -206,6 +282,7 @@ const createSettingMenus = (
menu.action({
name: 'Group',
prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''}
</div>
@@ -213,12 +290,37 @@ const createSettingMenus = (
select: () => {
const groupBy = groupTrait.property$.value;
if (!groupBy) {
popSelectGroupByProperty(target, groupTrait, {
onSelect: () => popGroupSetting(target, groupTrait, reopen),
onBack: reopen,
});
popSelectGroupByProperty(
target,
groupTrait,
{
onSelect: () =>
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]),
onBack: reopen,
onClose: closeMenu,
},
[
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]
);
} else {
popGroupSetting(target, groupTrait, reopen);
popGroupSetting(target, groupTrait, reopen, closeMenu, [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
]);
}
},
})
@@ -308,7 +410,7 @@ export const popViewOptions = (
></affine-menu-button>`;
};
});
popMenu(target, {
const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
@@ -338,7 +440,15 @@ export const popViewOptions = (
// }),
],
},
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
@@ -348,7 +458,9 @@ export const popViewOptions = (
items.push(
menu.group({
items: createSettingMenus(target, dataViewLogic, reopen),
items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
})
);
items.push(
@@ -357,6 +469,7 @@ export const popViewOptions = (
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => {
view.duplicate();
},
@@ -364,6 +477,7 @@ export const popViewOptions = (
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
closeOnSelect: false,
select: () => {
view.delete();
},
@@ -372,13 +486,22 @@ export const popViewOptions = (
],
})
);
popMenu(target, {
let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: {
title: {
text: 'View settings',
onClose: () => handler.close(),
},
items,
onClose: onClose,
},
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
handler.menu.menuElement.style.minHeight = '550px';
return handler;
};