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
@@ -6,7 +6,7 @@ import {
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import { import {
type CalloutBlockModel, type CalloutBlockModel,
ParagraphBlockModel, type ParagraphBlockModel,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { focusTextModel } from '@blocksuite/affine-rich-text'; import { focusTextModel } from '@blocksuite/affine-rich-text';
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
@@ -23,6 +23,7 @@ export type MenuButtonData = {
select: (ele: HTMLElement) => void | false; select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void; onHover?: (hover: boolean) => void;
testId?: string; testId?: string;
closeOnSelect?: boolean;
}; };
export class MenuButton extends MenuFocusable { export class MenuButton extends MenuFocusable {
@@ -85,7 +86,9 @@ export class MenuButton extends MenuFocusable {
onClick() { onClick() {
if (this.data.select(this) !== false) { if (this.data.select(this) !== false) {
this.menu.options.onComplete?.(); this.menu.options.onComplete?.();
this.menu.close(); if (this.data.closeOnSelect !== false) {
this.menu.close();
}
} }
} }
@@ -150,7 +153,9 @@ export class MobileMenuButton extends MenuFocusable {
onClick() { onClick() {
if (this.data.select(this) !== false) { if (this.data.select(this) !== false) {
this.menu.options.onComplete?.(); this.menu.options.onComplete?.();
this.menu.close(); if (this.data.closeOnSelect !== false) {
this.menu.close();
}
} }
} }
@@ -200,6 +205,7 @@ export const menuButtonItems = {
select: (ele: HTMLElement) => void | false; select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void; onHover?: (hover: boolean) => void;
class?: MenuClass; class?: MenuClass;
closeOnSelect?: boolean;
hide?: () => boolean; hide?: () => boolean;
testId?: string; testId?: string;
}) => }) =>
@@ -219,6 +225,7 @@ export const menuButtonItems = {
}, },
onHover: config.onHover, onHover: config.onHover,
select: config.select, select: config.select,
closeOnSelect: config.closeOnSelect,
class: { class: {
'selected-item': config.isSelected ?? false, 'selected-item': config.isSelected ?? false,
...config.class, ...config.class,
@@ -15,6 +15,7 @@ import {
computePosition, computePosition,
type Middleware, type Middleware,
offset, offset,
type Placement,
type ReferenceElement, type ReferenceElement,
shift, shift,
} from '@floating-ui/dom'; } from '@floating-ui/dom';
@@ -37,7 +38,9 @@ export class MenuComponent
display: flex; display: flex;
flex-direction: column; flex-direction: column;
user-select: none; user-select: none;
min-width: 180px; min-width: 320px;
max-width: 320px;
max-height: 700px;
box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
border-radius: 4px; border-radius: 4px;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')}; background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
@@ -439,6 +442,7 @@ export const createPopup = (
onClose?: () => void; onClose?: () => void;
middleware?: Array<Middleware | null | undefined | false>; middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement; container?: HTMLElement;
placement?: Placement;
} }
) => { ) => {
const close = () => { const close = () => {
@@ -448,6 +452,7 @@ export const createPopup = (
const modal = createModal(target.root); const modal = createModal(target.root);
autoUpdate(target.targetRect, content, () => { autoUpdate(target.targetRect, content, () => {
computePosition(target.targetRect, content, { computePosition(target.targetRect, content, {
placement: options?.placement,
middleware: options?.middleware ?? [shift({ crossAxis: true })], middleware: options?.middleware ?? [shift({ crossAxis: true })],
}) })
.then(({ x, y }) => { .then(({ x, y }) => {
@@ -520,6 +525,7 @@ export const popMenu = (
options: MenuOptions; options: MenuOptions;
middleware?: Array<Middleware | null | undefined | false>; middleware?: Array<Middleware | null | undefined | false>;
container?: HTMLElement; container?: HTMLElement;
placement?: Placement;
} }
): MenuHandler => { ): MenuHandler => {
if (IS_MOBILE) { if (IS_MOBILE) {
@@ -551,6 +557,7 @@ export const popMenu = (
offset(4), offset(4),
], ],
container: props.container, container: props.container,
placement: props.placement,
}); });
return { return {
close: closePopup, close: closePopup,
@@ -563,12 +570,14 @@ export const popMenu = (
export const popFilterableSimpleMenu = ( export const popFilterableSimpleMenu = (
target: PopupTarget, target: PopupTarget,
options: MenuConfig[], options: MenuConfig[],
onClose?: () => void onClose?: () => void,
placement: Placement = 'bottom-start'
) => { ) => {
popMenu(target, { popMenu(target, {
options: { options: {
items: options, items: options,
onClose, onClose,
}, },
placement,
}); });
}; };
@@ -4,12 +4,15 @@ import {
autoPlacement, autoPlacement,
autoUpdate, autoUpdate,
computePosition, computePosition,
type Middleware,
offset, offset,
shift,
} from '@floating-ui/dom'; } from '@floating-ui/dom';
import { html, nothing, type TemplateResult } from 'lit'; import { css, html, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { MenuButton } from './button.js';
import { MenuFocusable } from './focusable.js'; import { MenuFocusable } from './focusable.js';
import { Menu, type MenuOptions } from './menu.js'; import { Menu, type MenuOptions } from './menu.js';
import { popMenu, popupTargetFromElement } from './menu-renderer.js'; import { popMenu, popupTargetFromElement } from './menu-renderer.js';
@@ -20,29 +23,55 @@ export type MenuSubMenuData = {
options: MenuOptions; options: MenuOptions;
select?: () => void; select?: () => void;
class?: string; class?: string;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: boolean;
}; };
export const subMenuOffset = offset({ export const subMenuOffset = offset({
mainAxis: 16, mainAxis: 16,
crossAxis: -8.5, crossAxis: 0,
}); });
export const subMenuPlacements = autoPlacement({ export const subMenuPlacements = autoPlacement({
allowedPlacements: ['right-start', 'left-start', 'right-end', 'left-end'], allowedPlacements: ['bottom-end'],
}); });
export const subMenuMiddleware = [subMenuOffset, subMenuPlacements]; export const subMenuMiddleware = [subMenuOffset, subMenuPlacements];
export const dropdownSubMenuMiddleware = [
autoPlacement({ allowedPlacements: ['bottom-end'] }),
offset({ mainAxis: 8, crossAxis: 0 }),
shift({ crossAxis: true }),
];
export class MenuSubMenu extends MenuFocusable { export class MenuSubMenu extends MenuFocusable {
static override styles = [
MenuButton.styles,
css`
.affine-menu-button svg:last-child {
transition: transform 150ms cubic-bezier(0.42, 0, 1, 1);
}
affine-menu-sub-menu.active .affine-menu-button svg:last-child {
transform: rotate(90deg);
}
`,
];
createTime = 0; createTime = 0;
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.createTime = Date.now(); this.createTime = Date.now();
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter); if (this.data.openOnHover !== false) {
this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter);
}
this.disposables.addFromEvent(this, 'click', e => { this.disposables.addFromEvent(this, 'click', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (this.data.select) { if (this.data.select) {
this.data.select(); this.data.select();
this.menu.close(); if (this.data.closeOnSelect !== false) {
this.menu.close();
}
} else { } else {
this.openSubMenu(); this.openSubMenu();
} }
@@ -60,11 +89,38 @@ export class MenuSubMenu extends MenuFocusable {
} }
openSubMenu() { openSubMenu() {
if (this.data.openOnHover === false) {
const { menu } = popMenu(popupTargetFromElement(this), {
options: {
...this.data.options,
onComplete: () => {
if (this.data.closeOnSelect !== false) {
this.menu.close();
}
},
onClose: () => {
menu.menuElement.remove();
this.data.options.onClose?.();
},
},
middleware: this.data.middleware,
});
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
this.menu.openSubMenu(menu);
return;
}
const focus = this.menu.currentFocused$.value; const focus = this.menu.currentFocused$.value;
const menu = new Menu({ const menu = new Menu({
...this.data.options, ...this.data.options,
onComplete: () => { onComplete: () => {
this.menu.close(); if (this.data.closeOnSelect !== false) {
this.menu.close();
}
}, },
onClose: () => { onClose: () => {
menu.menuElement.remove(); menu.menuElement.remove();
@@ -74,9 +130,14 @@ export class MenuSubMenu extends MenuFocusable {
}, },
}); });
this.menu.menuElement.parentElement?.append(menu.menuElement); this.menu.menuElement.parentElement?.append(menu.menuElement);
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
const unsub = autoUpdate(this, menu.menuElement, () => { const unsub = autoUpdate(this, menu.menuElement, () => {
computePosition(this, menu.menuElement, { computePosition(this, menu.menuElement, {
middleware: subMenuMiddleware, middleware: this.data.middleware ?? subMenuMiddleware,
}) })
.then(({ x, y }) => { .then(({ x, y }) => {
menu.menuElement.style.left = `${x}px`; menu.menuElement.style.left = `${x}px`;
@@ -125,14 +186,22 @@ export class MobileSubMenu extends MenuFocusable {
options: { options: {
...this.data.options, ...this.data.options,
onComplete: () => { onComplete: () => {
this.menu.close(); if (this.data.closeOnSelect !== false) {
this.menu.close();
}
}, },
onClose: () => { onClose: () => {
menu.menuElement.remove(); menu.menuElement.remove();
this.data.options.onClose?.(); this.data.options.onClose?.();
}, },
}, },
middleware: this.data.middleware,
}); });
if (this.data.autoHeight) {
menu.menuElement.style.minHeight = 'fit-content';
menu.menuElement.style.maxHeight = 'fit-content';
}
menu.menuElement.style.minWidth = '200px';
this.menu.openSubMenu(menu); this.menu.openSubMenu(menu);
} }
@@ -175,6 +244,10 @@ export const subMenuItems = {
options: MenuOptions; options: MenuOptions;
disableArrow?: boolean; disableArrow?: boolean;
hide?: () => boolean; hide?: () => boolean;
openOnHover?: boolean;
middleware?: Middleware[];
autoHeight?: boolean;
closeOnSelect?: boolean;
}) => }) =>
menu => { menu => {
if (config.hide?.() || !menu.search(config.name)) { if (config.hide?.() || !menu.search(config.name)) {
@@ -190,6 +263,10 @@ export const subMenuItems = {
${config.disableArrow ? nothing : ArrowRightSmallIcon()} `, ${config.disableArrow ? nothing : ArrowRightSmallIcon()} `,
class: config.class, class: config.class,
options: config.options, options: config.options,
openOnHover: config.openOnHover,
middleware: config.middleware,
autoHeight: config.autoHeight,
closeOnSelect: config.closeOnSelect,
}; };
return renderSubMenu(data, menu); return renderSubMenu(data, menu);
}, },
@@ -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');
});
});
@@ -6,6 +6,7 @@ import {
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit'; import { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std'; import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { computed } from '@preact/signals-core'; import { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit'; import { css, html, unsafeCSS } from 'lit';
@@ -235,13 +236,16 @@ export const popPropertiesSetting = (
view: SingleView; view: SingleView;
onClose?: () => void; onClose?: () => void;
onBack?: () => void; onBack?: () => void;
} },
middleware?: Array<Middleware | null | undefined | false>
) => { ) => {
popMenu(target, { const handler = popMenu(target, {
middleware,
options: { options: {
title: { title: {
text: 'Properties', text: 'Properties',
onBack: props.onBack, onBack: props.onBack,
onClose: props.onClose,
postfix: () => { postfix: () => {
const items = props.view.propertiesRaw$.value; const items = props.view.propertiesRaw$.value;
const isAllShowed = items.every(property => !property.hide$.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(); // const view = new DataViewPropertiesSettingView();
// view.view = props.view; // view.view = props.view;
@@ -2,6 +2,7 @@ export type GroupBy = {
type: 'groupBy'; type: 'groupBy';
columnId: string; columnId: string;
name: string; name: string;
hideEmpty?: boolean;
sort?: { sort?: {
desc: boolean; desc: boolean;
}; };
@@ -24,7 +24,7 @@ export const popCreateFilter = (
middleware?: Middleware[]; middleware?: Middleware[];
} }
) => { ) => {
popMenu(target, { const subHandler = popMenu(target, {
middleware: ops?.middleware, middleware: ops?.middleware,
options: { options: {
onClose: props.onClose, onClose: props.onClose,
@@ -64,4 +64,5 @@ export const popCreateFilter = (
], ],
}, },
}); });
subHandler.menu.menuElement.style.minHeight = '550px';
}; };
@@ -15,6 +15,7 @@ export const allLiteralConfig: LiteralItemsConfig[] = [
() => { () => {
return html` <date-picker return html` <date-picker
.padding="${8}" .padding="${8}"
.size="${20}"
.value="${value.value}" .value="${value.value}"
.onChange="${(date: Date) => { .onChange="${(date: Date) => {
onChange(date.getTime()); onChange(date.getTime());
@@ -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))
);
};
}
@@ -18,6 +18,7 @@ export const defaultGroupBy = (
type: 'groupBy', type: 'groupBy',
columnId: propertyId, columnId: propertyId,
name: name, name: name,
hideEmpty: true,
} }
: undefined; : undefined;
}; };
@@ -1,9 +1,22 @@
import hash from '@emotion/hash'; 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 type { TypeInstance } from '../logical/type.js';
import { t } from '../logical/type-presets.js'; import { t } from '../logical/type-presets.js';
import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js'; import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js';
import { BooleanGroupView } from './renderer/boolean-group.js'; import { BooleanGroupView } from './renderer/boolean-group.js';
import { DateGroupView } from './renderer/date-group.js';
import { NumberGroupView } from './renderer/number-group.js'; import { NumberGroupView } from './renderer/number-group.js';
import { SelectGroupView } from './renderer/select-group.js'; import { SelectGroupView } from './renderer/select-group.js';
import { StringGroupView } from './renderer/string-group.js'; import { StringGroupView } from './renderer/string-group.js';
@@ -15,171 +28,239 @@ export const createGroupByConfig = <
GroupValue = unknown, GroupValue = unknown,
>( >(
config: GroupByConfig<Data, MatchType, GroupValue> config: GroupByConfig<Data, MatchType, GroupValue>
): GroupByConfig => { ): GroupByConfig => config as never;
return config as never as GroupByConfig;
};
export const ungroups = { export const ungroups = {
key: 'Ungroups', key: 'Ungroups',
value: null, 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({ createGroupByConfig({
name: 'select', name: 'select',
matchType: t.tag.instance(), matchType: t.tag.instance(),
groupName: (type, value: string | null) => { 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 type.data.find(v => v.id === value)?.value ?? '';
}
return ''; return '';
}, },
defaultKeys: type => { defaultKeys: type =>
if (t.tag.is(type) && type.data) { t.tag.is(type) && type.data
return [ ? [ungroups, ...type.data.map(v => ({ key: v.id, value: v.id }))]
ungroups, : [ungroups],
...type.data.map(v => ({ valuesGroup: (value, _t) =>
key: v.id, value == null ? [ungroups] : [{ key: `${value}`, value }],
value: v.id, addToGroup: (v: string | null, _old: string | null) => v,
})),
];
}
return [ungroups];
},
valuesGroup: (value, _type) => {
if (value == null) {
return [ungroups];
}
return [
{
key: `${value}`,
value: value.toString(),
},
];
},
addToGroup: v => v,
view: createUniComponentFromWebComponent(SelectGroupView), view: createUniComponentFromWebComponent(SelectGroupView),
}), }),
createGroupByConfig({ createGroupByConfig({
name: 'multi-select', name: 'multi-select',
matchType: t.array.instance(t.tag.instance()), matchType: t.array.instance(t.tag.instance()),
groupName: (type, value: string | null) => { 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 type.element.data.find(v => v.id === value)?.value ?? '';
}
return ''; return '';
}, },
defaultKeys: type => { defaultKeys: type =>
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) { t.array.is(type) && t.tag.is(type.element) && type.element.data
return [ ? [
ungroups, ungroups,
...type.element.data.map(v => ({ ...type.element.data.map(v => ({ key: v.id, value: v.id })),
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]; return [ungroups];
}, },
valuesGroup: (value, _type) => { addToGroup: (
if (value == null) { value: string | null,
return [ungroups]; old: string[] | null
} ): string[] | null => {
if (Array.isArray(value) && value.length) { if (value == null) return old;
return value.map(id => ({
key: `${id}`,
value: id,
}));
}
return [ungroups];
},
addToGroup: (value, old) => {
if (value == null) {
return old;
}
return Array.isArray(old) ? [...old, value] : [value]; return Array.isArray(old) ? [...old, value] : [value];
}, },
removeFromGroup: (value, old) => { removeFromGroup: (value, old) =>
if (Array.isArray(old)) { Array.isArray(old) ? old.filter(v => v !== value) : old,
return old.filter(v => v !== value);
}
return old;
},
view: createUniComponentFromWebComponent(SelectGroupView), view: createUniComponentFromWebComponent(SelectGroupView),
}), }),
createGroupByConfig({ createGroupByConfig({
name: 'text', name: 'text',
matchType: t.string.instance(), matchType: t.string.instance(),
groupName: (_type, value: string | null) => { groupName: (_t, v) => `${v ?? ''}`,
return `${value ?? ''}`; defaultKeys: _t => [ungroups],
}, valuesGroup: (v, _t) =>
defaultKeys: _type => { typeof v !== 'string' || !v ? [ungroups] : [{ key: hash(v), value: v }],
return [ungroups]; addToGroup: (v: string | null, _old: string | null) => v,
},
valuesGroup: (value, _type) => {
if (typeof value !== 'string' || !value) {
return [ungroups];
}
return [
{
key: hash(value),
value,
},
];
},
addToGroup: v => v,
view: createUniComponentFromWebComponent(StringGroupView), view: createUniComponentFromWebComponent(StringGroupView),
}), }),
createGroupByConfig({ createGroupByConfig({
name: 'number', name: 'number',
matchType: t.number.instance(), matchType: t.number.instance(),
groupName: (_type, value: number | null) => { groupName: (_t, v) => `${v ?? ''}`,
return `${value ?? ''}`; defaultKeys: _t => [ungroups],
}, valuesGroup: (v, _t) =>
defaultKeys: _type => { typeof v !== 'number'
return [ungroups]; ? [ungroups]
}, : [{ key: `g:${Math.floor(v / 10)}`, value: Math.floor(v / 10) }],
valuesGroup: (value: number | null, _type) => { addToGroup: (v: number | null, _old: number | null) =>
if (typeof value !== 'number') { typeof v === 'number' ? v * 10 : null,
return [ungroups];
}
return [
{
key: `g:${Math.floor(value / 10)}`,
value: Math.floor(value / 10),
},
];
},
addToGroup: value => (typeof value === 'number' ? value * 10 : null),
view: createUniComponentFromWebComponent(NumberGroupView), view: createUniComponentFromWebComponent(NumberGroupView),
}), }),
createGroupByConfig({ createGroupByConfig({
name: 'boolean', name: 'boolean',
matchType: t.boolean.instance(), matchType: t.boolean.instance(),
groupName: (_type, value: boolean | null) => { groupName: (_t, v) => `${v?.toString() ?? ''}`,
return `${value?.toString() ?? ''}`; defaultKeys: _t => [
}, ungroups,
defaultKeys: _type => { { key: 'true', value: true },
return [ { key: 'false', value: false },
{ 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,
valuesGroup: (value, _type) => {
if (typeof value !== 'boolean') {
return [
{
key: 'false',
value: false,
},
];
}
return [
{
key: value.toString(),
value: value,
},
];
},
addToGroup: v => v,
view: createUniComponentFromWebComponent(BooleanGroupView), view: createUniComponentFromWebComponent(BooleanGroupView),
}), }),
dateRelativeCfg,
dateDayCfg,
dateWeekSunCfg,
dateWeekMonCfg,
dateMonthCfg,
dateYearCfg,
]; ];
@@ -9,6 +9,18 @@ export const createGroupByMatcher = (list: GroupByConfig[]) => {
return new Matcher_(list, v => v.matchType); 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 { export class GroupByService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
@@ -16,6 +16,14 @@ export class BooleanGroupView extends BaseGroup<boolean, NonNullable<unknown>> {
`; `;
protected override render(): 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"> return html` <div class="data-view-group-title-boolean-view">
${this.value ${this.value
? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` }) ? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` })
@@ -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);
@@ -45,7 +45,8 @@ export class NumberGroupView extends BaseGroup<number, NonNullable<unknown>> {
protected override render(): unknown { protected override render(): unknown {
if (this.value == null) { 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) { if (this.value >= 10) {
return html` <div return html` <div
@@ -84,10 +84,11 @@ export class SelectGroupView extends BaseGroup<
protected override render(): unknown { protected override render(): unknown {
const tag = this.tag; const tag = this.tag;
if (!tag) { if (!tag) {
const displayName = `No ${this.group.property.name$.value}`;
return html` <div return html` <div
style="font-size: 14px;color: var(--affine-text-primary-color);line-height: 22px;" style="font-size: 14px;color: var(--affine-text-primary-color);line-height: 22px;"
> >
Ungroups ${displayName}
</div>`; </div>`;
} }
const style = styleMap({ const style = styleMap({
@@ -41,7 +41,8 @@ export class StringGroupView extends BaseGroup<string, NonNullable<unknown>> {
protected override render(): unknown { protected override render(): unknown {
if (!this.value) { if (!this.value) {
return html` <div>Ungroups</div>`; const displayName = `No ${this.group.property.name$.value}`;
return html` <div>${displayName}</div>`;
} }
return html` <div return html` <div
@click="${this._click}" @click="${this._click}"
@@ -1,4 +1,5 @@
import { import {
dropdownSubMenuMiddleware,
menu, menu,
type MenuConfig, type MenuConfig,
type MenuOptions, type MenuOptions,
@@ -6,9 +7,12 @@ import {
type PopupTarget, type PopupTarget,
} from '@blocksuite/affine-components/context-menu'; } from '@blocksuite/affine-components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; 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 { 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 { computed } from '@preact/signals-core';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, unsafeCSS } from 'lit'; import { css, html, unsafeCSS } from 'lit';
import { property, query } from 'lit/decorators.js'; import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.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 { GroupTrait } from './trait.js';
import type { GroupRenderProps } from './types.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( export class GroupSetting extends SignalWatcher(
WithDisposable(ShadowlessElement) WithDisposable(ShadowlessElement)
) { ) {
@@ -39,13 +61,44 @@ export class GroupSetting extends SignalWatcher(
${unsafeCSS(dataViewCssVariable())}; ${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 { .group-item {
display: flex; display: flex;
padding: 4px 12px; padding: 4px 12px;
position: relative; position: relative;
cursor: grab; cursor: grab;
} }
.group-item-drag-bar { .group-item-drag-bar {
width: 4px; width: 4px;
height: 12px; height: 12px;
@@ -57,18 +110,49 @@ export class GroupSetting extends SignalWatcher(
bottom: 0; bottom: 0;
margin: auto; margin: auto;
} }
.group-item:hover .group-item-drag-bar { .group-item:hover .group-item-drag-bar {
background-color: #c0bfc1; 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 }) @property({ attribute: false })
accessor groupTrait!: GroupTrait; accessor groupTrait!: GroupTrait;
groups$ = computed(() => { groups$ = computed(() => this.groupTrait.groupsDataListAll$.value);
return this.groupTrait.groupsDataList$.value;
});
sortContext = createSortContext({ sortContext = createSortContext({
activators: defaultActivators, activators: defaultActivators,
@@ -78,99 +162,101 @@ export class GroupSetting extends SignalWatcher(
const activeId = evt.active.id; const activeId = evt.active.id;
const groups = this.groups$.value; const groups = this.groups$.value;
if (over && over.id !== activeId && groups) { if (over && over.id !== activeId && groups) {
const activeIndex = groups.findIndex(data => data?.key === activeId); const aIndex = groups.findIndex(g => g?.key === activeId);
const overIndex = groups.findIndex(data => data?.key === over.id); const oIndex = groups.findIndex(g => g?.key === over.id);
this.groupTrait.moveGroupTo( this.groupTrait.moveGroupTo(
activeId, activeId,
activeIndex > overIndex aIndex > oIndex
? { ? { before: true, id: over.id }
before: true, : { before: false, id: over.id }
id: over.id,
}
: {
before: false,
id: over.id,
}
); );
} }
}, },
modifiers: [ modifiers: [({ transform }) => ({ ...transform, x: 0 })],
({ transform }) => { items: computed(
return { () =>
...transform, this.groupTrait.groupsDataListAll$.value?.map(v => v?.key ?? '') ?? []
x: 0, ),
};
},
],
items: computed(() => {
return (
this.groupTrait.groupsDataList$.value?.map(
v => v?.key ?? 'default key'
) ?? []
);
}),
strategy: verticalListSortingStrategy, strategy: verticalListSortingStrategy,
}); });
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._disposables.addFromEvent(this, 'pointerdown', e => { this._disposables.addFromEvent(this, 'pointerdown', e =>
e.stopPropagation(); e.stopPropagation()
}); );
} }
protected override render(): unknown { protected override render() {
const groups = this.groupTrait.groupsDataList$.value; const groups = this.groupTrait.groupsDataListAll$.value;
if (!groups) { if (!groups) return;
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` return html`
<div style="padding: 7px 0;"> <div
style="padding:7px 0;display:flex;justify-content:space-between;align-items:center;"
>
<div <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 Groups
</div> </div>
<div></div> <div class="properties-group-op" @click="${clickChangeAll}">
${isAllShowed ? 'Hide All' : 'Show All'}
</div>
</div> </div>
<div
style="display:flex;flex-direction: column;gap: 4px;" <div class="group-sort-setting">
class="group-sort-setting"
>
${repeat( ${repeat(
groups, groups,
group => group?.key ?? 'default key', g => g?.key ?? 'k',
group => { g => {
const type = group.property.dataType$.value; if (!g) return;
const type = g.property.dataType$.value;
if (!type) return; if (!type) return;
const props: GroupRenderProps = { const props: GroupRenderProps = { group: g, readonly: true };
group, const icon = g.hide$.value ? InvisibleIcon() : ViewIcon();
readonly: true, return html`
};
return html` <div
${sortable(group.key)}
${dragHandler(group.key)}
class="dv-hover dv-round-4 group-item"
>
<div class="group-item-drag-bar"></div>
<div <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 <div
style="position:absolute;left: 0;top: 0;right: 0;bottom: 0;" class="group-item-name"
></div> 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>`; `;
} }
)} )}
</div> </div>
`; `;
} }
@query('.group-sort-setting') @query('.group-sort-setting') accessor groupContainer!: HTMLElement;
accessor groupContainer!: HTMLElement;
} }
export const selectGroupByProperty = ( export const selectGroupByProperty = (
@@ -184,10 +270,7 @@ export const selectGroupByProperty = (
const view = group.view; const view = group.view;
return { return {
onClose: ops?.onClose, onClose: ops?.onClose,
title: { title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
text: 'Group by',
onBack: ops?.onBack,
},
items: [ items: [
menu.group({ menu.group({
items: view.propertiesRaw$.value items: view.propertiesRaw$.value
@@ -219,7 +302,7 @@ export const selectGroupByProperty = (
menu.action({ menu.action({
prefix: DeleteIcon(), prefix: DeleteIcon(),
hide: () => hide: () =>
view instanceof KanbanSingleView || group.property$.value == null, view instanceof KanbanSingleView || !group.property$.value,
class: { 'delete-item': true }, class: { 'delete-item': true },
name: 'Remove Grouping', name: 'Remove Grouping',
select: () => { select: () => {
@@ -232,77 +315,305 @@ export const selectGroupByProperty = (
], ],
}; };
}; };
export const popSelectGroupByProperty = ( export const popSelectGroupByProperty = (
target: PopupTarget, target: PopupTarget,
group: GroupTrait, group: GroupTrait,
ops?: { ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
onSelect?: () => void; middleware?: Array<Middleware | null | undefined | false>
onClose?: () => void;
onBack?: () => void;
}
) => { ) => {
popMenu(target, { const handler = popMenu(target, {
options: selectGroupByProperty(group, ops), options: selectGroupByProperty(group, ops),
middleware,
}); });
handler.menu.menuElement.style.minHeight = '550px';
}; };
export const popGroupSetting = ( export const popGroupSetting = (
target: PopupTarget, target: PopupTarget,
group: GroupTrait, group: GroupTrait,
onBack: () => void onBack: () => void,
onClose?: () => void,
middleware?: Array<Middleware | null | undefined | false>
) => { ) => {
const view = group.view; const view = group.view;
const groupProperty = group.property$.value; const gProp = group.property$.value;
if (groupProperty == null) { if (!gProp) return;
return; const type = gProp.type$.value;
} if (!type) return;
const type = groupProperty.type$.value;
if (!type) { const icon = gProp.icon;
return;
}
const icon = groupProperty.icon;
const menuHandler = popMenu(target, { const menuHandler = popMenu(target, {
options: { options: {
title: { title: {
text: 'Group', text: 'Group',
onBack: onBack, onBack,
onClose,
}, },
items: [ items: [
menu.group({ menu.group({
items: [ items: [
menu.subMenu({ menu.action({
name: 'Group By', name: 'Group By',
postfix: html` postfix: html`
<div <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" class="dv-icon-16"
> >
${renderUniLit(icon, {})} ${groupProperty.name$.value} ${renderUniLit(icon, {})} ${gProp.name$.value}
</div> </div>
`, `,
label: () => html` select: () => {
<div style="color: var(--affine-text-secondary-color);"> const subHandler = popMenu(target, {
Group By options: selectGroupByProperty(group, {
</div> onSelect: () => {
`, menuHandler.close();
options: selectGroupByProperty(group, { popGroupSetting(
onSelect: () => { target,
menuHandler.close(); group,
popGroupSetting(target, group, onBack); 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({ menu.group({
items: [ items: [
menu => menu => html`
html` <data-view-group-setting <data-view-group-setting
@mouseenter="${() => menu.closeSubMenu()}" @mouseenter=${() => menu.closeSubMenu()}
.groupTrait="${group}" .groupTrait=${group}
.columnId="${groupProperty.id}" .columnId=${gProp.id}
></data-view-group-setting>`, ></data-view-group-setting>
`,
], ],
}), }),
menu.group({ menu.group({
items: [ items: [
menu.action({ menu.action({
@@ -312,11 +623,14 @@ export const popGroupSetting = (
hide: () => !(view instanceof TableSingleView), hide: () => !(view instanceof TableSingleView),
select: () => { select: () => {
group.changeGroup(undefined); group.changeGroup(undefined);
return false;
}, },
}), }),
], ],
}), }),
], ],
}, },
middleware,
}); });
menuHandler.menu.menuElement.style.minHeight = '550px';
}; };
@@ -2,7 +2,12 @@ import {
insertPositionToIndex, insertPositionToIndex,
type InsertToPosition, type InsertToPosition,
} from '@blocksuite/affine-shared/utils'; } 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 { GroupBy, GroupProperty } from '../common/types.js';
import type { TypeInstance } from '../logical/type.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 { Property } from '../view-manager/property.js';
import type { Row } from '../view-manager/row.js'; import type { Row } from '../view-manager/row.js';
import type { SingleView } from '../view-manager/single-view.js'; import type { SingleView } from '../view-manager/single-view.js';
import { compareDateKeys } from './compare-date-keys.js';
import { defaultGroupBy } from './default.js'; import { defaultGroupBy } from './default.js';
import { getGroupByService } from './matcher.js'; import { findGroupByConfigByName, getGroupByService } from './matcher.js';
// Test
import type { GroupByConfig } from './types.js'; import type { GroupByConfig } from './types.js';
export type GroupInfo< export type GroupInfo<
@@ -42,138 +49,71 @@ export class Group<
get property() { get property() {
return this.groupInfo.property; return this.groupInfo.property;
} }
name$ = computed(() => { name$ = computed(() => {
const type = this.property.dataType$.value; const type = this.property.dataType$.value;
if (!type) { return type ? this.groupInfo.config.groupName(type, this.value) : '';
return '';
}
return this.groupInfo.config.groupName(type, this.value);
}); });
private get config() { private get config() {
return this.groupInfo.config; return this.groupInfo.config;
} }
get tType() { get tType() {
return this.groupInfo.tType; return this.groupInfo.tType;
} }
get view() { get view() {
return this.config.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 { export class GroupTrait {
groupInfo$ = computed<GroupInfo | undefined>(() => { hideEmpty$ = signal<boolean>(true);
const groupBy = this.groupBy$.value; sortAsc$ = signal<boolean>(true);
if (!groupBy) {
return; groupProperties$ = computed(() => {
} const data = this.view.data$.value;
const property = this.view.propertyGetOrCreate(groupBy.columnId); return hasGroupProperties(data) ? (data.groupProperties ?? []) : [];
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,
};
}); });
staticInfo$ = computed(() => { groupPropertiesMap$ = computed(() => {
const groupInfo = this.groupInfo$.value; const map: Record<string, GroupProperty> = {};
if (!groupInfo) { this.groupProperties$.value.forEach(g => {
return; map[g.key] = g;
}
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);
}); });
this.view.rows$.value.forEach(row => { return map;
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;
}); });
/**
* Synchronize sortAsc$ with the GroupBy sort descriptor
*/
constructor( constructor(
private readonly groupBy$: ReadonlySignal<GroupBy | undefined>, private readonly groupBy$: ReadonlySignal<GroupBy | undefined>,
public view: SingleView, public view: SingleView,
private readonly ops: { private readonly ops: {
groupBySet: (groupBy: GroupBy | undefined) => void; groupBySet: (g: GroupBy | undefined) => void;
sortGroup: (keys: string[]) => string[]; sortGroup: (keys: string[], asc?: boolean) => string[];
sortRow: (groupKey: string, rows: Row[]) => Row[]; sortRow: (groupKey: string, rows: Row[]) => Row[];
changeGroupSort: (keys: string[]) => void; changeGroupSort: (keys: string[]) => void;
changeRowSort: ( changeRowSort: (
@@ -181,11 +121,188 @@ export class GroupTrait {
groupKey: string, groupKey: string,
keys: string[] keys: string[]
) => void; ) => 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) { addToGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value; const groupMap = this.groupDataMap$.value;
const groupInfo = this.groupInfo$.value; const groupInfo = this.groupInfo$.value;
if (!groupMap || !groupInfo) { if (!groupMap || !groupInfo) {
@@ -205,18 +322,34 @@ export class GroupTrait {
.cellGetOrCreate(rowId, groupInfo.property.id) .cellGetOrCreate(rowId, groupInfo.property.id)
.valueSet(newValue); .valueSet(newValue);
} }
} const map = this.groupDataMap$.value;
const info = this.groupInfo$.value;
if (!map || !info) return;
changeCardSort(groupKey: string, cardIds: string[]) { const addFn = info.config.addToGroup;
const groups = this.groupsDataList$.value; if (addFn === false) return;
if (!groups) {
return; const group = map[key];
} if (!group) return;
this.ops.changeRowSort(
groups.map(v => v.key), const current = group.value;
groupKey, // Handle both null and non-null values to ensure proper group assignment
cardIds 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) { changeGroup(columnId: string | undefined) {
@@ -225,31 +358,38 @@ export class GroupTrait {
return; return;
} }
const column = this.view.propertyGetOrCreate(columnId); const column = this.view.propertyGetOrCreate(columnId);
const propertyMeta = this.view.manager.dataSource.propertyMetaGet( const meta = this.view.manager.dataSource.propertyMetaGet(
column.type$.value column.type$.value
); );
if (propertyMeta) { if (meta) {
this.ops.groupBySet( const gb = defaultGroupBy(
defaultGroupBy( this.view.manager.dataSource,
this.view.manager.dataSource, meta,
propertyMeta, column.id,
column.id, column.data$.value
column.data$.value
)
); );
if (gb) {
gb.sort = { desc: !this.sortAsc$.value };
gb.hideEmpty = this.hideEmpty$.value;
}
this.ops.groupBySet(gb);
} }
} }
changeGroupSort(keys: string[]) { property$ = computed(() => this.groupInfo$.value?.property);
this.ops.changeGroupSort(keys);
get addGroup() {
return this.property$.value?.meta$.value?.config.addGroup;
} }
defaultGroupProperty(key: string): GroupProperty { updateData = (data: NonNullable<unknown>) => {
return { const prop = this.property$.value;
key, if (!prop) return;
hide: false, this.view.propertyGetOrCreate(prop.id).dataUpdate(() => data);
manuallyCardSort: [], };
};
changeGroupSort(keys: string[]) {
this.ops.changeGroupSort(keys);
} }
moveCardTo( moveCardTo(
@@ -258,7 +398,6 @@ export class GroupTrait {
toGroupKey: string, toGroupKey: string,
position: InsertToPosition position: InsertToPosition
) { ) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value; const groupMap = this.groupDataMap$.value;
if (!groupMap) { if (!groupMap) {
return; return;
@@ -291,16 +430,16 @@ export class GroupTrait {
.map(row => row.rowId) ?? []; .map(row => row.rowId) ?? [];
const index = insertPositionToIndex(position, rows, row => row); const index = insertPositionToIndex(position, rows, row => row);
rows.splice(index, 0, rowId); 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) { moveGroupTo(groupKey: string, position: InsertToPosition) {
this.view.lockRows(false);
const groups = this.groupsDataList$.value; const groups = this.groupsDataList$.value;
if (!groups) { if (!groups) {
return; return;
} }
const keys = groups.map(v => v.key); const keys = groups.map(v => v!.key);
keys.splice( keys.splice(
keys.findIndex(key => key === groupKey), keys.findIndex(key => key === groupKey),
1 1
@@ -311,7 +450,6 @@ export class GroupTrait {
} }
removeFromGroup(rowId: string, key: string) { removeFromGroup(rowId: string, key: string) {
this.view.lockRows(false);
const groupMap = this.groupDataMap$.value; const groupMap = this.groupDataMap$.value;
if (!groupMap) { if (!groupMap) {
return; return;
@@ -330,7 +468,6 @@ export class GroupTrait {
} }
updateValue(rows: string[], value: unknown) { updateValue(rows: string[], value: unknown) {
this.view.lockRows(false);
const propertyId = this.property$.value?.id; const propertyId = this.property$.value?.id;
if (!propertyId) { if (!propertyId) {
return; return;
@@ -3,6 +3,7 @@ import {
popMenu, popMenu,
type PopupTarget, type PopupTarget,
} from '@blocksuite/affine-components/context-menu'; } from '@blocksuite/affine-components/context-menu';
import type { Middleware } from '@floating-ui/dom';
import { renderUniLit } from '../utils/index.js'; import { renderUniLit } from '../utils/index.js';
import type { SortUtils } from './utils.js'; import type { SortUtils } from './utils.js';
@@ -13,9 +14,13 @@ export const popCreateSort = (
sortUtils: SortUtils; sortUtils: SortUtils;
onClose?: () => void; onClose?: () => void;
onBack?: () => void; onBack?: () => void;
},
ops?: {
middleware?: Middleware[];
} }
) => { ) => {
popMenu(target, { const subHandler = popMenu(target, {
middleware: ops?.middleware,
options: { options: {
onClose: props.onClose, onClose: props.onClose,
title: { title: {
@@ -50,4 +55,5 @@ export const popCreateSort = (
], ],
}, },
}); });
subHandler.menu.menuElement.style.minHeight = '550px';
}; };
@@ -20,6 +20,7 @@ export type MainProperties = {
}; };
export interface SingleView { export interface SingleView {
data$: any;
readonly id: string; readonly id: string;
readonly type: string; readonly type: string;
readonly manager: ViewManager; readonly manager: ViewManager;
@@ -23,7 +23,7 @@ export const dateValueContainerStyle = css({
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
fontSize: '17px', fontSize: '17px',
lineHeight: '22px', lineHeight: '22px',
height: '46px', height: '30px',
}); });
export const datePickerContainerStyle = css({ export const datePickerContainerStyle = css({
@@ -74,12 +74,15 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
}; };
}); });
}, },
sortGroup: ids => sortGroup: (ids, asc) => {
sortByManually( const sorted = sortByManually(
ids, ids,
v => v, v => v,
this.view?.groupProperties.map(v => v.key) ?? [] 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) => { sortRow: (key, rows) => {
const property = this.view?.groupProperties.find(v => v.key === key); const property = this.view?.groupProperties.find(v => v.key === key);
return sortByManually( 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) { if (!groups) {
return html``; return html``;
} }
const groupEntries = groups.filter(
(group): group is NonNullable<(typeof groups)[number]> => group != null
);
const vPadding = this.logic.root.config.virtualPadding$.value; const vPadding = this.logic.root.config.virtualPadding$.value;
const wrapperStyle = styleMap({ const wrapperStyle = styleMap({
marginLeft: `-${vPadding}px`, marginLeft: `-${vPadding}px`,
@@ -149,7 +152,7 @@ export class MobileKanbanViewUI extends DataViewUIBase<MobileKanbanViewUILogic>
})} })}
<div class="${mobileKanbanGroups}" style="${wrapperStyle}"> <div class="${mobileKanbanGroups}" style="${wrapperStyle}">
${repeat( ${repeat(
groups, groupEntries,
group => group.key, group => group.key,
group => { group => {
return html` <mobile-kanban-group return html` <mobile-kanban-group
@@ -25,6 +25,9 @@ export const popCardMenu = (
if (!groupTrait) { if (!groupTrait) {
return; return;
} }
const groups = (groupTrait.groupsDataList$.value ?? []).filter(
(v): v is NonNullable<typeof v> => v != null
);
popFilterableSimpleMenu(ele, [ popFilterableSimpleMenu(ele, [
menu.group({ menu.group({
items: [ items: [
@@ -47,12 +50,10 @@ export const popCardMenu = (
prefix: ArrowRightBigIcon(), prefix: ArrowRightBigIcon(),
options: { options: {
items: items:
groupTrait.groupsDataList$.value groups
?.filter(v => { .filter(v => v.key !== groupKey)
return v.key !== groupKey; .map(group =>
}) menu.action({
.map(group => {
return menu.action({
name: group.value != null ? group.name$.value : 'Ungroup', name: group.value != null ? group.name$.value : 'Ungroup',
select: () => { select: () => {
groupTrait.moveCardTo( groupTrait.moveCardTo(
@@ -62,8 +63,8 @@ export const popCardMenu = (
'start' 'start'
); );
}, },
}); })
}) ?? [], ) ?? [],
}, },
}), }),
], ],
@@ -202,7 +202,11 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
return html``; 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 return html` <affine-data-view-kanban-group
${sortable(group.key)} ${sortable(group.key)}
data-key="${group.key}" data-key="${group.key}"
@@ -226,8 +230,13 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
} }
override render(): TemplateResult { override render(): TemplateResult {
const groups = this.logic.groups$.value; const groups = this.logic.groups$.value?.filter(
if (!groups) { (
group
): group is NonNullable<(typeof this.logic.groups$.value)[number]> =>
group != null
);
if (!groups || groups.length === 0) {
return html``; return html``;
} }
@@ -37,6 +37,9 @@ export const popCardMenu = (
rowId: string, rowId: string,
selection: KanbanSelectionController selection: KanbanSelectionController
) => { ) => {
const groups = (selection.view.groupTrait.groupsDataList$.value ?? []).filter(
(v): v is NonNullable<typeof v> => v != null
);
popFilterableSimpleMenu(ele, [ popFilterableSimpleMenu(ele, [
menu.action({ menu.action({
name: 'Expand Card', name: 'Expand Card',
@@ -50,22 +53,23 @@ export const popCardMenu = (
prefix: ArrowRightBigIcon(), prefix: ArrowRightBigIcon(),
options: { options: {
items: items:
selection.view.groupTrait.groupsDataList$.value groups
?.filter(v => { .filter(v => {
const cardSelection = selection.selection; const cardSelection = selection.selection;
if (cardSelection?.selectionType === 'card') { 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; return false;
}) })
.map(group => { .map(group =>
return menu.action({ menu.action({
name: group.value != null ? group.name$.value : 'Ungroup', name: group.value != null ? group.name$.value : 'Ungroup',
select: () => { select: () => {
selection.moveCard(rowId, group.key); selection.moveCard(rowId, group.key);
}, },
}); })
}) ?? [], ) ?? [],
}, },
}), }),
menu.group({ menu.group({
@@ -108,10 +108,13 @@ export class MobileTableViewUI extends DataViewUIBase<MobileTableViewUILogic> {
private renderTable() { private renderTable() {
const groups = this.logic.view.groupTrait.groupsDataList$.value; const groups = this.logic.view.groupTrait.groupsDataList$.value;
if (groups) { if (groups) {
const groupEntries = groups.filter(
(group): group is NonNullable<(typeof groups)[number]> => group != null
);
return html` return html`
<div style="display:flex;flex-direction: column;gap: 16px;"> <div style="display:flex;flex-direction: column;gap: 16px;">
${repeat( ${repeat(
groups, groupEntries,
v => v.key, v => v.key,
group => { group => {
return html` <mobile-table-group return html` <mobile-table-group
@@ -18,9 +18,11 @@ export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
accessor gridGroup!: TableGridGroup; accessor gridGroup!: TableGridGroup;
group$ = computed(() => { group$ = computed(() => {
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find( const groups =
g => g.key === this.gridGroup.groupId 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() { get selectionController() {
@@ -35,9 +35,11 @@ export class TableGroupHeader extends SignalWatcher(
} }
group$ = computed(() => { group$ = computed(() => {
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find( const groups =
g => g.key === this.gridGroup.groupId 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(() => { 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, id: group.key,
rows: group.rows.map(v => v.rowId), rows: group.rows.map(v => v.rowId),
})); }));
@@ -92,6 +92,17 @@ export const addGroupIconStyle = css({
fill: 'var(--affine-icon-color)', 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({ const cellDividerStyle = css({
width: '1px', width: '1px',
height: '100%', height: '100%',
@@ -12,7 +12,7 @@ import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.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 { import {
createUniComponentFromWebComponent, createUniComponentFromWebComponent,
renderUniLit, renderUniLit,
@@ -30,6 +30,7 @@ import { TableSelectionController } from './controller/selection.js';
import { import {
addGroupIconStyle, addGroupIconStyle,
addGroupStyle, addGroupStyle,
groupsHiddenMessageStyle,
tableGroupsContainerStyle, tableGroupsContainerStyle,
tableScrollContainerStyle, tableScrollContainerStyle,
tableViewStyle, tableViewStyle,
@@ -154,26 +155,27 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
} }
private renderTable() { private renderTable() {
const groups = this.logic.view.groupTrait.groupsDataList$.value; const groups = this.logic.view.groupTrait.groupsDataList$.value?.filter(
if (groups) { (g): g is Group => g !== undefined
);
if (groups && groups.length) {
return html` return html`
<div class="${tableGroupsContainerStyle}"> <div class="${tableGroupsContainerStyle}">
${repeat( ${repeat(
groups, groups,
v => v.key, group => group.key,
group => { group =>
return html` <affine-data-view-table-group html`<affine-data-view-table-group
data-group-key="${group.key}" data-group-key="${group.key}"
.tableViewLogic="${this.logic}" .tableViewLogic="${this.logic}"
.group="${group}" .group="${group}"
></affine-data-view-table-group>`; ></affine-data-view-table-group>`
}
)} )}
${this.logic.renderAddGroup(this.logic.view.groupTrait)} ${this.logic.renderAddGroup(this.logic.view.groupTrait)}
</div> </div>
`; `;
} }
return html` <affine-data-view-table-group return html`<affine-data-view-table-group
.tableViewLogic="${this.logic}" .tableViewLogic="${this.logic}"
></affine-data-view-table-group>`; ></affine-data-view-table-group>`;
} }
@@ -205,7 +207,11 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
class="affine-database-table-container" class="affine-database-table-container"
style="${containerStyle}" style="${containerStyle}"
> >
${this.renderTable()} ${this.logic.view.groupTrait.allHidden$.value
? html`<div class="${groupsHiddenMessageStyle}">
All groups are hidden
</div>`
: this.renderTable()}
</div> </div>
</div> </div>
</div> </div>
@@ -101,12 +101,15 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
}; };
}); });
}, },
sortGroup: ids => sortGroup: (ids, asc) => {
sortByManually( const sorted = sortByManually(
ids, ids,
v => v, v => v,
this.groupProperties.map(v => v.key) this.groupProperties.map(v => v.key)
), );
// If descending order is requested, reverse the sorted array
return asc === false ? sorted.reverse() : sorted;
},
sortRow: (key, rows) => { sortRow: (key, rows) => {
const property = this.groupProperties.find(v => v.key === key); const property = this.groupProperties.find(v => v.key === key);
return sortByManually( 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 };
});
},
}) })
); );
@@ -4,7 +4,6 @@ import {
popMenu, popMenu,
type PopupTarget, type PopupTarget,
popupTargetFromElement, popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu'; } from '@blocksuite/affine-components/context-menu';
import { SignalWatcher } from '@blocksuite/global/lit'; import { SignalWatcher } from '@blocksuite/global/lit';
import { import {
@@ -13,6 +12,7 @@ import {
DeleteIcon, DeleteIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std'; import { ShadowlessElement } from '@blocksuite/std';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { computed, type ReadonlySignal } from '@preact/signals-core'; import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
@@ -99,6 +99,11 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
return; return;
} }
const handler = popMenu(target, { const handler = popMenu(target, {
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
offset({ mainAxis: 4, crossAxis: 0 }),
shift({ crossAxis: true }),
],
options: { options: {
items: [ items: [
menu.group({ menu.group({
@@ -107,7 +112,7 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
name: fn.label, name: fn.label,
postfix: ArrowRightSmallIcon(), postfix: ArrowRightSmallIcon(),
select: ele => { select: ele => {
popMenu(popupTargetFromElement(ele), { const subHandler = popMenu(popupTargetFromElement(ele), {
options: { options: {
items: [ items: [
menu.group({ 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; 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 }) @property({ attribute: false })
@@ -1,10 +1,8 @@
import { import {
menu, menu,
popFilterableSimpleMenu,
popMenu, popMenu,
type PopupTarget, type PopupTarget,
popupTargetFromElement, popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu'; } from '@blocksuite/affine-components/context-menu';
import { SignalWatcher } from '@blocksuite/global/lit'; import { SignalWatcher } from '@blocksuite/global/lit';
import { import {
@@ -17,6 +15,7 @@ import {
PlusIcon, PlusIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std'; import { ShadowlessElement } from '@blocksuite/std';
import { type Middleware, offset } from '@floating-ui/dom';
import { computed, type ReadonlySignal } from '@preact/signals-core'; import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
@@ -208,66 +207,64 @@ export class FilterRootView extends SignalWatcher(ShadowlessElement) {
if (!filter) { if (!filter) {
return; return;
} }
popFilterableSimpleMenu(popupTargetFromElement(target), [ const handler = popMenu(popupTargetFromElement(target), {
menu.action({ placement: 'bottom-end',
name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', middleware: [offset({ mainAxis: 12, crossAxis: 0 })],
prefix: ConvertIcon(), options: {
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: '',
items: [ items: [
menu.action({ menu.action({
name: 'Delete', name:
prefix: DeleteIcon(), filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
class: { 'delete-item': true }, prefix: ConvertIcon(),
onHover: hover => { hide: () => getDepth(filter) > 3,
this.containerClass = hover
? { index: i, class: 'delete-style' }
: undefined;
},
select: () => { select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(i, 1);
this.onChange({ this.onChange({
...this.filterGroup.value, type: 'group',
conditions, 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) { private deleteFilter(i: number) {
@@ -378,16 +375,20 @@ export const popFilterRoot = (
props: { props: {
filterTrait: FilterTrait; filterTrait: FilterTrait;
onBack: () => void; onBack: () => void;
onClose?: () => void;
dataViewLogic: DataViewUILogicBase; dataViewLogic: DataViewUILogicBase;
} },
middleware?: Array<Middleware | null | undefined | false>
) => { ) => {
const filterTrait = props.filterTrait; const filterTrait = props.filterTrait;
const view = filterTrait.view; const view = filterTrait.view;
popMenu(target, { const handler = popMenu(target, {
middleware,
options: { options: {
title: { title: {
text: 'Filters', text: 'Filters',
onBack: props.onBack, onBack: props.onBack,
onClose: props.onClose,
}, },
items: [ items: [
menu.group({ menu.group({
@@ -409,23 +410,16 @@ export const popFilterRoot = (
prefix: PlusIcon(), prefix: PlusIcon(),
select: ele => { select: ele => {
const value = filterTrait.filter$.value; const value = filterTrait.filter$.value;
popCreateFilter( popCreateFilter(popupTargetFromElement(ele), {
popupTargetFromElement(ele), vars: view.vars$,
{ onSelect: filter => {
vars: view.vars$, filterTrait.filterSet({
onSelect: filter => { ...value,
filterTrait.filterSet({ conditions: [...value.conditions, filter],
...value, });
conditions: [...value.conditions, filter], props.dataViewLogic.eventTrace('CreateDatabaseFilter', {});
});
props.dataViewLogic.eventTrace(
'CreateDatabaseFilter',
{}
);
},
}, },
{ middleware: subMenuMiddleware } });
);
return false; return false;
}, },
}), }),
@@ -434,4 +428,5 @@ export const popFilterRoot = (
], ],
}, },
}); });
handler.menu.menuElement.style.minHeight = '550px';
}; };
@@ -13,6 +13,7 @@ import {
PlusIcon, PlusIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std'; import { ShadowlessElement } from '@blocksuite/std';
import type { Middleware } from '@floating-ui/dom';
import { computed } from '@preact/signals-core'; import { computed } from '@preact/signals-core';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
@@ -203,11 +204,14 @@ export const popSortRoot = (
title?: { title?: {
text: string; text: string;
onBack?: () => void; onBack?: () => void;
onClose?: () => void;
}; };
} },
middleware?: Array<Middleware | null | undefined | false>
) => { ) => {
const sortUtils = props.sortUtils; const sortUtils = props.sortUtils;
popMenu(target, { const handler = popMenu(target, {
middleware,
options: { options: {
title: props.title, title: props.title,
items: [ items: [
@@ -237,4 +241,5 @@ export const popSortRoot = (
], ],
}, },
}); });
handler.menu.menuElement.style.minHeight = '550px';
}; };
@@ -18,6 +18,7 @@ import {
MoreHorizontalIcon, MoreHorizontalIcon,
SortIcon, SortIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { autoPlacement, offset, shift } from '@floating-ui/dom';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
@@ -97,7 +98,8 @@ declare global {
const createSettingMenus = ( const createSettingMenus = (
target: PopupTarget, target: PopupTarget,
dataViewLogic: DataViewUILogicBase, dataViewLogic: DataViewUILogicBase,
reopen: () => void reopen: () => void,
closeMenu: () => void
) => { ) => {
const view = dataViewLogic.view; const view = dataViewLogic.view;
const settingItems: MenuConfig[] = []; const settingItems: MenuConfig[] = [];
@@ -105,15 +107,25 @@ const createSettingMenus = (
menu.action({ menu.action({
name: 'Properties', name: 'Properties',
prefix: InfoIcon(), prefix: InfoIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;"> postfix: html` <div style="font-size: 14px;">
${view.properties$.value.length} shown ${view.properties$.value.length} shown
</div> </div>
${ArrowRightSmallIcon()}`, ${ArrowRightSmallIcon()}`,
select: () => { select: () => {
popPropertiesSetting(target, { popPropertiesSetting(
view: view, target,
onBack: reopen, {
}); 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({ menu.action({
name: 'Filter', name: 'Filter',
prefix: FilterIcon(), prefix: FilterIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;"> postfix: html` <div style="font-size: 14px;">
${filterCount === 0 ${filterCount === 0
? '' ? ''
@@ -134,28 +147,66 @@ const createSettingMenus = (
${ArrowRightSmallIcon()}`, ${ArrowRightSmallIcon()}`,
select: () => { select: () => {
if (!filterTrait.filter$.value.conditions.length) { if (!filterTrait.filter$.value.conditions.length) {
popCreateFilter(target, { popCreateFilter(
vars: view.vars$, target,
onBack: reopen, {
onSelect: filter => { vars: view.vars$,
filterTrait.filterSet({ onBack: reopen,
...(filterTrait.filter$.value ?? emptyFilterGroup), onClose: closeMenu,
conditions: [...filterTrait.filter$.value.conditions, filter], onSelect: filter => {
}); filterTrait.filterSet({
popFilterRoot(target, { ...(filterTrait.filter$.value ?? emptyFilterGroup),
filterTrait: filterTrait, conditions: [
onBack: reopen, ...filterTrait.filter$.value.conditions,
dataViewLogic: dataViewLogic, filter,
}); ],
dataViewLogic.eventTrace('CreateDatabaseFilter', {}); });
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 { } else {
popFilterRoot(target, { popFilterRoot(
filterTrait: filterTrait, target,
onBack: reopen, {
dataViewLogic: dataViewLogic, 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({ menu.action({
name: 'Sort', name: 'Sort',
prefix: SortIcon(), prefix: SortIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;"> postfix: html` <div style="font-size: 14px;">
${sortCount === 0 ${sortCount === 0
? '' ? ''
@@ -183,18 +235,42 @@ const createSettingMenus = (
dataViewLogic.eventTrace dataViewLogic.eventTrace
); );
if (!sortList.length) { if (!sortList.length) {
popCreateSort(target, { popCreateSort(
sortUtils: sortUtils, target,
onBack: reopen, {
}); sortUtils: sortUtils,
} else {
popSortRoot(target, {
sortUtils: sortUtils,
title: {
text: 'Sort',
onBack: reopen, 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({ menu.action({
name: 'Group', name: 'Group',
prefix: GroupingIcon(), prefix: GroupingIcon(),
closeOnSelect: false,
postfix: html` <div style="font-size: 14px;"> postfix: html` <div style="font-size: 14px;">
${groupTrait.property$.value?.name$.value ?? ''} ${groupTrait.property$.value?.name$.value ?? ''}
</div> </div>
@@ -213,12 +290,37 @@ const createSettingMenus = (
select: () => { select: () => {
const groupBy = groupTrait.property$.value; const groupBy = groupTrait.property$.value;
if (!groupBy) { if (!groupBy) {
popSelectGroupByProperty(target, groupTrait, { popSelectGroupByProperty(
onSelect: () => popGroupSetting(target, groupTrait, reopen), target,
onBack: reopen, 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 { } 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>`; ></affine-menu-button>`;
}; };
}); });
popMenu(target, { const subHandler = popMenu(target, {
options: { options: {
title: { title: {
onBack: reopen, 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(), prefix: LayoutIcon(),
}), }),
@@ -348,7 +458,9 @@ export const popViewOptions = (
items.push( items.push(
menu.group({ menu.group({
items: createSettingMenus(target, dataViewLogic, reopen), items: createSettingMenus(target, dataViewLogic, reopen, () =>
handler.close()
),
}) })
); );
items.push( items.push(
@@ -357,6 +469,7 @@ export const popViewOptions = (
menu.action({ menu.action({
name: 'Duplicate', name: 'Duplicate',
prefix: DuplicateIcon(), prefix: DuplicateIcon(),
closeOnSelect: false,
select: () => { select: () => {
view.duplicate(); view.duplicate();
}, },
@@ -364,6 +477,7 @@ export const popViewOptions = (
menu.action({ menu.action({
name: 'Delete', name: 'Delete',
prefix: DeleteIcon(), prefix: DeleteIcon(),
closeOnSelect: false,
select: () => { select: () => {
view.delete(); view.delete();
}, },
@@ -372,13 +486,22 @@ export const popViewOptions = (
], ],
}) })
); );
popMenu(target, { let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: { options: {
title: { title: {
text: 'View settings', text: 'View settings',
onClose: () => handler.close(),
}, },
items, items,
onClose: onClose, onClose: onClose,
}, },
middleware: [
autoPlacement({ allowedPlacements: ['bottom-start'] }),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
}); });
handler.menu.menuElement.style.minHeight = '550px';
return handler;
}; };
@@ -183,8 +183,9 @@ export class RevenueCatService {
return ent.products.items; return ent.products.items;
} }
const entId = ent.id; const entId = ent.id;
if (this.productsCache.has(entId)) { const cachedProduct = this.productsCache.get(entId);
return this.productsCache.get(entId)!; if (cachedProduct) {
return cachedProduct;
} }
const res = await fetch( const res = await fetch(
@@ -1,5 +1,4 @@
import { Button, IconButton, Modal } from '@affine/component'; import { Button, IconButton, IconType, Modal } from '@affine/component';
import { IconType } from '@affine/component';
import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
@@ -350,22 +350,23 @@ test.describe('kanban view selection', () => {
await focusKanbanCardHeader(page); await focusKanbanCardHeader(page);
await assertKanbanCellSelected(page, { await assertKanbanCellSelected(page, {
// group by `number` column, the first(groupIndex: 0) group is `Ungroups` // group by `number` column, `Ungroups` is hidden because it's empty (hideEmpty: true by default)
groupIndex: 1, // so the first visible group is the one with value "1" at groupIndex: 0
groupIndex: 0,
cardIndex: 0, cardIndex: 0,
cellIndex: 0, cellIndex: 0,
}); });
await pressArrowDown(page, 3); await pressArrowDown(page, 3);
await assertKanbanCellSelected(page, { await assertKanbanCellSelected(page, {
groupIndex: 1, groupIndex: 0,
cardIndex: 0, cardIndex: 0,
cellIndex: 0, cellIndex: 0,
}); });
await pressArrowUp(page); await pressArrowUp(page);
await assertKanbanCellSelected(page, { await assertKanbanCellSelected(page, {
groupIndex: 1, groupIndex: 0,
cardIndex: 0, cardIndex: 0,
cellIndex: 2, cellIndex: 2,
}); });
@@ -380,7 +381,8 @@ test.describe('kanban view selection', () => {
columns: [ columns: [
{ {
type: 'number', type: 'number',
value: [1, 2], // Both rows have value 1 to put them in the same group
value: [1, 1],
}, },
{ {
type: 'rich-text', type: 'rich-text',
@@ -392,14 +394,16 @@ test.describe('kanban view selection', () => {
await focusKanbanCardHeader(page); await focusKanbanCardHeader(page);
await pressArrowUp(page); await pressArrowUp(page);
await assertKanbanCellSelected(page, { await assertKanbanCellSelected(page, {
groupIndex: 1, // `Ungroups` is hidden because it's empty (hideEmpty: true by default)
// so the first visible group is "1" at groupIndex: 0
groupIndex: 0,
cardIndex: 1, cardIndex: 1,
cellIndex: 2, cellIndex: 2,
}); });
await pressArrowDown(page); await pressArrowDown(page);
await assertKanbanCellSelected(page, { await assertKanbanCellSelected(page, {
groupIndex: 1, groupIndex: 0,
cardIndex: 0, cardIndex: 0,
cellIndex: 0, cellIndex: 0,
}); });