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
@@ -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 })
@@ -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';
};
@@ -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';
};
@@ -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;
};