mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 02:21:51 +08:00
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:  <!-- 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:
@@ -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
|
||||
|
||||
+5
-3
@@ -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() {
|
||||
|
||||
+5
-3
@@ -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(() => {
|
||||
|
||||
+8
-1
@@ -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 };
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user