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
@@ -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 };
});
},
})
);
@@ -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
@@ -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'
);
},
});
}) ?? [],
})
) ?? [],
},
}),
],
@@ -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``;
}
@@ -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({
@@ -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
@@ -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() {
@@ -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(() => {
@@ -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),
}));
@@ -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%',
@@ -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>
@@ -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 };
});
},
})
);