mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(editor): add date grouping configurations (#12679)
https://github.com/user-attachments/assets/d5578060-2c8c-47a5-ba65-ef2e9430518b This PR adds the ability to group-by date with configuration which an example is shown in the image below:  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Date-based grouping modes (relative, day, week Sun/Mon, month, year), a date group renderer, and quick lookup for group-by configs by name. * **Improvements** * Enhanced group settings: date sub‑modes, week‑start, per‑group visibility, Hide All/Show All, date sort order, improved drag/drop and reorder. * Consistent popup placement/middleware, nested popup positioning, per‑item close-on-select, and enforced minimum menu heights. * UI: empty groups now display "No <property>"; views defensively handle null/hidden groups. * **Tests** * Added unit tests for date-key sorting and comparison. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Norkz <richardlora557@gmail.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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 { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
@@ -235,13 +236,16 @@ export const popPropertiesSetting = (
|
||||
view: SingleView;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
},
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
popMenu(target, {
|
||||
const handler = popMenu(target, {
|
||||
middleware,
|
||||
options: {
|
||||
title: {
|
||||
text: 'Properties',
|
||||
onBack: props.onBack,
|
||||
onClose: props.onClose,
|
||||
postfix: () => {
|
||||
const items = props.view.propertiesRaw$.value;
|
||||
const isAllShowed = items.every(property => !property.hide$.value);
|
||||
@@ -270,8 +274,10 @@ export const popPropertiesSetting = (
|
||||
],
|
||||
}),
|
||||
],
|
||||
onClose: props.onClose,
|
||||
},
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
|
||||
// const view = new DataViewPropertiesSettingView();
|
||||
// view.view = props.view;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type GroupBy = {
|
||||
type: 'groupBy';
|
||||
columnId: string;
|
||||
name: string;
|
||||
hideEmpty?: boolean;
|
||||
sort?: {
|
||||
desc: boolean;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const popCreateFilter = (
|
||||
middleware?: Middleware[];
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
const subHandler = popMenu(target, {
|
||||
middleware: ops?.middleware,
|
||||
options: {
|
||||
onClose: props.onClose,
|
||||
@@ -64,4 +64,5 @@ export const popCreateFilter = (
|
||||
],
|
||||
},
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export const allLiteralConfig: LiteralItemsConfig[] = [
|
||||
() => {
|
||||
return html` <date-picker
|
||||
.padding="${8}"
|
||||
.size="${20}"
|
||||
.value="${value.value}"
|
||||
.onChange="${(date: Date) => {
|
||||
onChange(date.getTime());
|
||||
|
||||
@@ -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',
|
||||
columnId: propertyId,
|
||||
name: name,
|
||||
hideEmpty: true,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import hash from '@emotion/hash';
|
||||
import {
|
||||
addDays,
|
||||
differenceInCalendarDays,
|
||||
format as fmt,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isYesterday,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
} from 'date-fns';
|
||||
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import { t } from '../logical/type-presets.js';
|
||||
import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js';
|
||||
import { BooleanGroupView } from './renderer/boolean-group.js';
|
||||
import { DateGroupView } from './renderer/date-group.js';
|
||||
import { NumberGroupView } from './renderer/number-group.js';
|
||||
import { SelectGroupView } from './renderer/select-group.js';
|
||||
import { StringGroupView } from './renderer/string-group.js';
|
||||
@@ -15,171 +28,239 @@ export const createGroupByConfig = <
|
||||
GroupValue = unknown,
|
||||
>(
|
||||
config: GroupByConfig<Data, MatchType, GroupValue>
|
||||
): GroupByConfig => {
|
||||
return config as never as GroupByConfig;
|
||||
};
|
||||
): GroupByConfig => config as never;
|
||||
|
||||
export const ungroups = {
|
||||
key: 'Ungroups',
|
||||
value: null,
|
||||
};
|
||||
export const groupByMatchers = [
|
||||
|
||||
const WEEK_OPTS_MON = { weekStartsOn: 1 } as const;
|
||||
const WEEK_OPTS_SUN = { weekStartsOn: 0 } as const;
|
||||
|
||||
const rangeLabel = (a: Date, b: Date) =>
|
||||
`${fmt(a, 'MMM d yyyy')} – ${fmt(b, 'MMM d yyyy')}`;
|
||||
|
||||
function buildDateCfg(
|
||||
name: string,
|
||||
grouper: (ms: number | null) => { key: string; value: number | null }[],
|
||||
groupName: (v: number | null) => string
|
||||
): GroupByConfig {
|
||||
return createGroupByConfig({
|
||||
name,
|
||||
matchType: t.date.instance(),
|
||||
groupName: (_t, v) => groupName(v),
|
||||
defaultKeys: _t => [ungroups],
|
||||
valuesGroup: (v: number | null, _t) => grouper(v),
|
||||
addToGroup: (grp: number | null, _old: number | null) => grp,
|
||||
view: createUniComponentFromWebComponent(DateGroupView),
|
||||
});
|
||||
}
|
||||
|
||||
const dateRelativeCfg = buildDateCfg(
|
||||
'date-relative',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const d = startOfDay(new Date(v));
|
||||
const today = startOfDay(new Date());
|
||||
const daysDiff = differenceInCalendarDays(d, today);
|
||||
|
||||
// Handle specific days
|
||||
if (isToday(d)) return [{ key: 'today', value: +d }];
|
||||
if (isTomorrow(d)) return [{ key: 'tomorrow', value: +d }];
|
||||
if (isYesterday(d)) return [{ key: 'yesterday', value: +d }];
|
||||
|
||||
// Handle future dates
|
||||
if (daysDiff > 0) {
|
||||
if (daysDiff <= 7) return [{ key: 'next7', value: +d }];
|
||||
if (daysDiff <= 30) return [{ key: 'next30', value: +d }];
|
||||
// Group by month for future dates beyond 30 days
|
||||
const m = startOfMonth(d);
|
||||
return [{ key: `${+m}`, value: +m }];
|
||||
}
|
||||
|
||||
// Handle past dates
|
||||
const daysAgo = -daysDiff;
|
||||
if (daysAgo <= 7) return [{ key: 'last7', value: +d }];
|
||||
if (daysAgo <= 30) return [{ key: 'last30', value: +d }];
|
||||
// Group by month for past dates beyond 30 days
|
||||
const m = startOfMonth(d);
|
||||
return [{ key: `${+m}`, value: +m }];
|
||||
},
|
||||
v => {
|
||||
if (v == null) return '';
|
||||
const d = startOfDay(new Date(v));
|
||||
const today = startOfDay(new Date());
|
||||
const daysDiff = differenceInCalendarDays(d, today);
|
||||
|
||||
// Handle specific days
|
||||
if (isToday(d)) return 'Today';
|
||||
if (isTomorrow(d)) return 'Tomorrow';
|
||||
if (isYesterday(d)) return 'Yesterday';
|
||||
|
||||
// Handle future dates
|
||||
if (daysDiff > 0) {
|
||||
if (daysDiff <= 7) return 'Next 7 days';
|
||||
if (daysDiff <= 30) return 'Next 30 days';
|
||||
// Show month/year for future dates beyond 30 days
|
||||
return fmt(new Date(v), 'MMM yyyy');
|
||||
}
|
||||
|
||||
// Handle past dates
|
||||
const daysAgo = -daysDiff;
|
||||
if (daysAgo <= 7) return 'Last 7 days';
|
||||
if (daysAgo <= 30) return 'Last 30 days';
|
||||
// Show month/year for past dates beyond 30 days
|
||||
return fmt(new Date(v), 'MMM yyyy');
|
||||
}
|
||||
);
|
||||
|
||||
const dateDayCfg = buildDateCfg(
|
||||
'date-day',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const d = startOfDay(new Date(v));
|
||||
return [{ key: `${+d}`, value: +d }];
|
||||
},
|
||||
v => (v ? fmt(new Date(v), 'MMM d yyyy') : '')
|
||||
);
|
||||
|
||||
const dateWeekSunCfg = buildDateCfg(
|
||||
'date-week-sun',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const w = startOfWeek(new Date(v), WEEK_OPTS_SUN);
|
||||
return [{ key: `${+w}`, value: +w }];
|
||||
},
|
||||
v => (v ? rangeLabel(new Date(v), addDays(new Date(v), 6)) : '')
|
||||
);
|
||||
|
||||
const dateWeekMonCfg = buildDateCfg(
|
||||
'date-week-mon',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const w = startOfWeek(new Date(v), WEEK_OPTS_MON);
|
||||
return [{ key: `${+w}`, value: +w }];
|
||||
},
|
||||
v => (v ? rangeLabel(new Date(v), addDays(new Date(v), 6)) : '')
|
||||
);
|
||||
|
||||
const dateMonthCfg = buildDateCfg(
|
||||
'date-month',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const m = startOfMonth(new Date(v));
|
||||
return [{ key: `${+m}`, value: +m }];
|
||||
},
|
||||
v => (v ? fmt(new Date(v), 'MMM yyyy') : '')
|
||||
);
|
||||
|
||||
const dateYearCfg = buildDateCfg(
|
||||
'date-year',
|
||||
v => {
|
||||
if (v == null) return [ungroups];
|
||||
const y = startOfYear(new Date(v));
|
||||
return [{ key: `${+y}`, value: +y }];
|
||||
},
|
||||
v => (v ? fmt(new Date(v), 'yyyy') : '')
|
||||
);
|
||||
|
||||
export const groupByMatchers: GroupByConfig[] = [
|
||||
createGroupByConfig({
|
||||
name: 'select',
|
||||
matchType: t.tag.instance(),
|
||||
groupName: (type, value: string | null) => {
|
||||
if (t.tag.is(type) && type.data) {
|
||||
if (t.tag.is(type) && type.data)
|
||||
return type.data.find(v => v.id === value)?.value ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: type => {
|
||||
if (t.tag.is(type) && type.data) {
|
||||
return [
|
||||
ungroups,
|
||||
...type.data.map(v => ({
|
||||
key: v.id,
|
||||
value: v.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (value == null) {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: `${value}`,
|
||||
value: value.toString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
addToGroup: v => v,
|
||||
defaultKeys: type =>
|
||||
t.tag.is(type) && type.data
|
||||
? [ungroups, ...type.data.map(v => ({ key: v.id, value: v.id }))]
|
||||
: [ungroups],
|
||||
valuesGroup: (value, _t) =>
|
||||
value == null ? [ungroups] : [{ key: `${value}`, value }],
|
||||
addToGroup: (v: string | null, _old: string | null) => v,
|
||||
view: createUniComponentFromWebComponent(SelectGroupView),
|
||||
}),
|
||||
|
||||
createGroupByConfig({
|
||||
name: 'multi-select',
|
||||
matchType: t.array.instance(t.tag.instance()),
|
||||
groupName: (type, value: string | null) => {
|
||||
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) {
|
||||
if (t.array.is(type) && t.tag.is(type.element) && type.element.data)
|
||||
return type.element.data.find(v => v.id === value)?.value ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: type => {
|
||||
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) {
|
||||
return [
|
||||
ungroups,
|
||||
...type.element.data.map(v => ({
|
||||
key: v.id,
|
||||
value: v.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
defaultKeys: type =>
|
||||
t.array.is(type) && t.tag.is(type.element) && type.element.data
|
||||
? [
|
||||
ungroups,
|
||||
...type.element.data.map(v => ({ key: v.id, value: v.id })),
|
||||
]
|
||||
: [ungroups],
|
||||
valuesGroup: (value, _t) => {
|
||||
if (value == null) return [ungroups];
|
||||
if (Array.isArray(value) && value.length)
|
||||
return value.map(id => ({ key: `${id}`, value: id }));
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (value == null) {
|
||||
return [ungroups];
|
||||
}
|
||||
if (Array.isArray(value) && value.length) {
|
||||
return value.map(id => ({
|
||||
key: `${id}`,
|
||||
value: id,
|
||||
}));
|
||||
}
|
||||
return [ungroups];
|
||||
},
|
||||
addToGroup: (value, old) => {
|
||||
if (value == null) {
|
||||
return old;
|
||||
}
|
||||
addToGroup: (
|
||||
value: string | null,
|
||||
old: string[] | null
|
||||
): string[] | null => {
|
||||
if (value == null) return old;
|
||||
return Array.isArray(old) ? [...old, value] : [value];
|
||||
},
|
||||
removeFromGroup: (value, old) => {
|
||||
if (Array.isArray(old)) {
|
||||
return old.filter(v => v !== value);
|
||||
}
|
||||
return old;
|
||||
},
|
||||
removeFromGroup: (value, old) =>
|
||||
Array.isArray(old) ? old.filter(v => v !== value) : old,
|
||||
view: createUniComponentFromWebComponent(SelectGroupView),
|
||||
}),
|
||||
|
||||
createGroupByConfig({
|
||||
name: 'text',
|
||||
matchType: t.string.instance(),
|
||||
groupName: (_type, value: string | null) => {
|
||||
return `${value ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: hash(value),
|
||||
value,
|
||||
},
|
||||
];
|
||||
},
|
||||
addToGroup: v => v,
|
||||
groupName: (_t, v) => `${v ?? ''}`,
|
||||
defaultKeys: _t => [ungroups],
|
||||
valuesGroup: (v, _t) =>
|
||||
typeof v !== 'string' || !v ? [ungroups] : [{ key: hash(v), value: v }],
|
||||
addToGroup: (v: string | null, _old: string | null) => v,
|
||||
view: createUniComponentFromWebComponent(StringGroupView),
|
||||
}),
|
||||
|
||||
createGroupByConfig({
|
||||
name: 'number',
|
||||
matchType: t.number.instance(),
|
||||
groupName: (_type, value: number | null) => {
|
||||
return `${value ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value: number | null, _type) => {
|
||||
if (typeof value !== 'number') {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: `g:${Math.floor(value / 10)}`,
|
||||
value: Math.floor(value / 10),
|
||||
},
|
||||
];
|
||||
},
|
||||
addToGroup: value => (typeof value === 'number' ? value * 10 : null),
|
||||
groupName: (_t, v) => `${v ?? ''}`,
|
||||
defaultKeys: _t => [ungroups],
|
||||
valuesGroup: (v, _t) =>
|
||||
typeof v !== 'number'
|
||||
? [ungroups]
|
||||
: [{ key: `g:${Math.floor(v / 10)}`, value: Math.floor(v / 10) }],
|
||||
addToGroup: (v: number | null, _old: number | null) =>
|
||||
typeof v === 'number' ? v * 10 : null,
|
||||
view: createUniComponentFromWebComponent(NumberGroupView),
|
||||
}),
|
||||
|
||||
createGroupByConfig({
|
||||
name: 'boolean',
|
||||
matchType: t.boolean.instance(),
|
||||
groupName: (_type, value: boolean | null) => {
|
||||
return `${value?.toString() ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [
|
||||
{ key: 'true', value: true },
|
||||
{ key: 'false', value: false },
|
||||
];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (typeof value !== 'boolean') {
|
||||
return [
|
||||
{
|
||||
key: 'false',
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: value.toString(),
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
},
|
||||
addToGroup: v => v,
|
||||
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
||||
defaultKeys: _t => [
|
||||
ungroups,
|
||||
{ key: 'true', value: true },
|
||||
{ key: 'false', value: false },
|
||||
],
|
||||
valuesGroup: (v, _t) =>
|
||||
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
|
||||
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||
}),
|
||||
|
||||
dateRelativeCfg,
|
||||
dateDayCfg,
|
||||
dateWeekSunCfg,
|
||||
dateWeekMonCfg,
|
||||
dateMonthCfg,
|
||||
dateYearCfg,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,18 @@ export const createGroupByMatcher = (list: GroupByConfig[]) => {
|
||||
return new Matcher_(list, v => v.matchType);
|
||||
};
|
||||
|
||||
export const findGroupByConfigByName = (
|
||||
dataSource: DataSource,
|
||||
name: string
|
||||
): GroupByConfig | undefined => {
|
||||
const svc = getGroupByService(dataSource);
|
||||
const all: GroupByConfig[] = [
|
||||
...svc.allExternalGroupByConfig(),
|
||||
...groupByMatchers,
|
||||
];
|
||||
return all.find(c => c.name === name);
|
||||
};
|
||||
|
||||
export class GroupByService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ export class BooleanGroupView extends BaseGroup<boolean, NonNullable<unknown>> {
|
||||
`;
|
||||
|
||||
protected override render(): unknown {
|
||||
// Handle null/undefined values
|
||||
if (this.value == null) {
|
||||
const displayName = `No ${this.group.property.name$.value ?? 'value'}`;
|
||||
return html` <div class="data-view-group-title-boolean-view">
|
||||
${displayName}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html` <div class="data-view-group-title-boolean-view">
|
||||
${this.value
|
||||
? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` })
|
||||
|
||||
@@ -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 {
|
||||
if (this.value == null) {
|
||||
return html` <div>Ungroups</div>`;
|
||||
const displayName = `No ${this.group.property.name$.value}`;
|
||||
return html` <div>${displayName}</div>`;
|
||||
}
|
||||
if (this.value >= 10) {
|
||||
return html` <div
|
||||
|
||||
@@ -84,10 +84,11 @@ export class SelectGroupView extends BaseGroup<
|
||||
protected override render(): unknown {
|
||||
const tag = this.tag;
|
||||
if (!tag) {
|
||||
const displayName = `No ${this.group.property.name$.value}`;
|
||||
return html` <div
|
||||
style="font-size: 14px;color: var(--affine-text-primary-color);line-height: 22px;"
|
||||
>
|
||||
Ungroups
|
||||
${displayName}
|
||||
</div>`;
|
||||
}
|
||||
const style = styleMap({
|
||||
|
||||
@@ -41,7 +41,8 @@ export class StringGroupView extends BaseGroup<string, NonNullable<unknown>> {
|
||||
|
||||
protected override render(): unknown {
|
||||
if (!this.value) {
|
||||
return html` <div>Ungroups</div>`;
|
||||
const displayName = `No ${this.group.property.name$.value}`;
|
||||
return html` <div>${displayName}</div>`;
|
||||
}
|
||||
return html` <div
|
||||
@click="${this._click}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
dropdownSubMenuMiddleware,
|
||||
menu,
|
||||
type MenuConfig,
|
||||
type MenuOptions,
|
||||
@@ -6,9 +7,12 @@ import {
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
@@ -28,6 +32,24 @@ import { getGroupByService } from './matcher.js';
|
||||
import type { GroupTrait } from './trait.js';
|
||||
import type { GroupRenderProps } from './types.js';
|
||||
|
||||
const dateModeLabel = (key?: string) => {
|
||||
switch (key) {
|
||||
case 'date-relative':
|
||||
return 'Relative';
|
||||
case 'date-day':
|
||||
return 'Day';
|
||||
case 'date-week-mon':
|
||||
case 'date-week-sun':
|
||||
return 'Week';
|
||||
case 'date-month':
|
||||
return 'Month';
|
||||
case 'date-year':
|
||||
return 'Year';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export class GroupSetting extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@@ -39,13 +61,44 @@ export class GroupSetting extends SignalWatcher(
|
||||
${unsafeCSS(dataViewCssVariable())};
|
||||
}
|
||||
|
||||
.group-sort-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
max-height: 200px;
|
||||
overflow: hidden auto;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* WebKit-based browser scrollbar styling */
|
||||
.group-sort-setting::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.group-sort-setting::-webkit-scrollbar-thumb {
|
||||
background-color: #b0b0b0; /* Grey slider */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.group-sort-setting::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.group-sort-setting {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #b0b0b0 transparent;
|
||||
}
|
||||
.group-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.group-item {
|
||||
display: flex;
|
||||
padding: 4px 12px;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.group-item-drag-bar {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
@@ -57,18 +110,49 @@ export class GroupSetting extends SignalWatcher(
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.group-item:hover .group-item-drag-bar {
|
||||
background-color: #c0bfc1;
|
||||
}
|
||||
.group-item-op-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.group-item-op-icon:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
.group-item-op-icon svg {
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.group-item-name {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.properties-group-op {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${unsafeCSS(cssVarV2.button.primary)};
|
||||
}
|
||||
|
||||
.properties-group-op:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor groupTrait!: GroupTrait;
|
||||
|
||||
groups$ = computed(() => {
|
||||
return this.groupTrait.groupsDataList$.value;
|
||||
});
|
||||
groups$ = computed(() => this.groupTrait.groupsDataListAll$.value);
|
||||
|
||||
sortContext = createSortContext({
|
||||
activators: defaultActivators,
|
||||
@@ -78,99 +162,101 @@ export class GroupSetting extends SignalWatcher(
|
||||
const activeId = evt.active.id;
|
||||
const groups = this.groups$.value;
|
||||
if (over && over.id !== activeId && groups) {
|
||||
const activeIndex = groups.findIndex(data => data?.key === activeId);
|
||||
const overIndex = groups.findIndex(data => data?.key === over.id);
|
||||
|
||||
const aIndex = groups.findIndex(g => g?.key === activeId);
|
||||
const oIndex = groups.findIndex(g => g?.key === over.id);
|
||||
this.groupTrait.moveGroupTo(
|
||||
activeId,
|
||||
activeIndex > overIndex
|
||||
? {
|
||||
before: true,
|
||||
id: over.id,
|
||||
}
|
||||
: {
|
||||
before: false,
|
||||
id: over.id,
|
||||
}
|
||||
aIndex > oIndex
|
||||
? { before: true, id: over.id }
|
||||
: { before: false, id: over.id }
|
||||
);
|
||||
}
|
||||
},
|
||||
modifiers: [
|
||||
({ transform }) => {
|
||||
return {
|
||||
...transform,
|
||||
x: 0,
|
||||
};
|
||||
},
|
||||
],
|
||||
items: computed(() => {
|
||||
return (
|
||||
this.groupTrait.groupsDataList$.value?.map(
|
||||
v => v?.key ?? 'default key'
|
||||
) ?? []
|
||||
);
|
||||
}),
|
||||
modifiers: [({ transform }) => ({ ...transform, x: 0 })],
|
||||
items: computed(
|
||||
() =>
|
||||
this.groupTrait.groupsDataListAll$.value?.map(v => v?.key ?? '') ?? []
|
||||
),
|
||||
strategy: verticalListSortingStrategy,
|
||||
});
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e =>
|
||||
e.stopPropagation()
|
||||
);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
const groups = this.groupTrait.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
protected override render() {
|
||||
const groups = this.groupTrait.groupsDataListAll$.value;
|
||||
if (!groups) return;
|
||||
const map = this.groupTrait.groupDataMap$.value;
|
||||
const isAllShowed = map
|
||||
? Object.keys(map).every(k => !this.groupTrait.isGroupHidden(k))
|
||||
: true;
|
||||
const clickChangeAll = () => {
|
||||
if (!map) return;
|
||||
Object.keys(map).forEach(key => {
|
||||
this.groupTrait.setGroupHide(key, isAllShowed);
|
||||
});
|
||||
};
|
||||
return html`
|
||||
<div style="padding: 7px 0;">
|
||||
<div
|
||||
style="padding:7px 0;display:flex;justify-content:space-between;align-items:center;"
|
||||
>
|
||||
<div
|
||||
style="padding: 0 4px; font-size: 12px;color: var(--affine-text-secondary-color);line-height: 20px;"
|
||||
style="padding:0 4px;font-size:12px;color:var(--affine-text-secondary-color);line-height:20px;"
|
||||
>
|
||||
Groups
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="properties-group-op" @click="${clickChangeAll}">
|
||||
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="display:flex;flex-direction: column;gap: 4px;"
|
||||
class="group-sort-setting"
|
||||
>
|
||||
|
||||
<div class="group-sort-setting">
|
||||
${repeat(
|
||||
groups,
|
||||
group => group?.key ?? 'default key',
|
||||
group => {
|
||||
const type = group.property.dataType$.value;
|
||||
g => g?.key ?? 'k',
|
||||
g => {
|
||||
if (!g) return;
|
||||
const type = g.property.dataType$.value;
|
||||
if (!type) return;
|
||||
const props: GroupRenderProps = {
|
||||
group,
|
||||
readonly: true,
|
||||
};
|
||||
return html` <div
|
||||
${sortable(group.key)}
|
||||
${dragHandler(group.key)}
|
||||
class="dv-hover dv-round-4 group-item"
|
||||
>
|
||||
<div class="group-item-drag-bar"></div>
|
||||
const props: GroupRenderProps = { group: g, readonly: true };
|
||||
const icon = g.hide$.value ? InvisibleIcon() : ViewIcon();
|
||||
return html`
|
||||
<div
|
||||
style="padding: 0 4px;position:relative;pointer-events: none;max-width: 330px"
|
||||
${sortable(g.key)}
|
||||
${dragHandler(g.key)}
|
||||
class="dv-hover dv-round-4 group-item ${g.hide$.value
|
||||
? 'group-hidden'
|
||||
: ''}"
|
||||
>
|
||||
${renderUniLit(group.view, props)}
|
||||
<div class="group-item-drag-bar"></div>
|
||||
<div
|
||||
style="position:absolute;left: 0;top: 0;right: 0;bottom: 0;"
|
||||
></div>
|
||||
class="group-item-name"
|
||||
style="padding:0 4px;position:relative;pointer-events:none;max-width:330px;"
|
||||
>
|
||||
${renderUniLit(g.view, props)}
|
||||
<div
|
||||
style="position:absolute;left:0;top:0;right:0;bottom:0;"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="group-item-op-icon"
|
||||
@click="${() => g.hideSet(!g.hide$.value)}"
|
||||
>
|
||||
${icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.group-sort-setting')
|
||||
accessor groupContainer!: HTMLElement;
|
||||
@query('.group-sort-setting') accessor groupContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const selectGroupByProperty = (
|
||||
@@ -184,10 +270,7 @@ export const selectGroupByProperty = (
|
||||
const view = group.view;
|
||||
return {
|
||||
onClose: ops?.onClose,
|
||||
title: {
|
||||
text: 'Group by',
|
||||
onBack: ops?.onBack,
|
||||
},
|
||||
title: { text: 'Group by', onBack: ops?.onBack, onClose: ops?.onClose },
|
||||
items: [
|
||||
menu.group({
|
||||
items: view.propertiesRaw$.value
|
||||
@@ -219,7 +302,7 @@ export const selectGroupByProperty = (
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || group.property$.value == null,
|
||||
view instanceof KanbanSingleView || !group.property$.value,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
select: () => {
|
||||
@@ -232,77 +315,305 @@ export const selectGroupByProperty = (
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const popSelectGroupByProperty = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: () => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
ops?: { onSelect?: () => void; onClose?: () => void; onBack?: () => void },
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
popMenu(target, {
|
||||
const handler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, ops),
|
||||
middleware,
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
export const popGroupSetting = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
onBack: () => void
|
||||
onBack: () => void,
|
||||
onClose?: () => void,
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const view = group.view;
|
||||
const groupProperty = group.property$.value;
|
||||
if (groupProperty == null) {
|
||||
return;
|
||||
}
|
||||
const type = groupProperty.type$.value;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const icon = groupProperty.icon;
|
||||
const gProp = group.property$.value;
|
||||
if (!gProp) return;
|
||||
const type = gProp.type$.value;
|
||||
if (!type) return;
|
||||
|
||||
const icon = gProp.icon;
|
||||
const menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Group',
|
||||
onBack: onBack,
|
||||
onBack,
|
||||
onClose,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
menu.action({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap: 4px;font-size: 12px;line-height: 20px;color: var(--affine-text-secondary-color);margin-right: 4px;margin-left: 8px;"
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${groupProperty.name$.value}
|
||||
${renderUniLit(icon, {})} ${gProp.name$.value}
|
||||
</div>
|
||||
`,
|
||||
label: () => html`
|
||||
<div style="color: var(--affine-text-secondary-color);">
|
||||
Group By
|
||||
</div>
|
||||
`,
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(target, group, onBack);
|
||||
select: () => {
|
||||
const subHandler = popMenu(target, {
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onBack: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(
|
||||
target,
|
||||
group,
|
||||
onBack,
|
||||
onClose,
|
||||
middleware
|
||||
);
|
||||
},
|
||||
onClose,
|
||||
}),
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
...(type === 'date'
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Date by',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:30px;"
|
||||
>
|
||||
${dateModeLabel(group.groupInfo$.value?.config.name)}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Relative', 'date-relative'],
|
||||
['Day', 'date-day'],
|
||||
[
|
||||
'Week',
|
||||
group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'date-week-mon'
|
||||
: 'date-week-sun',
|
||||
],
|
||||
['Month', 'date-month'],
|
||||
['Year', 'date-year'],
|
||||
] as [string, string][]
|
||||
).map(
|
||||
([label, key]): MenuConfig =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config.name ===
|
||||
key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name === key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
|
||||
...(group.groupInfo$.value?.config.name?.startsWith('date-week')
|
||||
? [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Start week on',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.groupInfo$.value?.config.name ===
|
||||
'date-week-mon'
|
||||
? 'Monday'
|
||||
: 'Sunday'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() =>
|
||||
(
|
||||
[
|
||||
['Monday', 'date-week-mon'],
|
||||
['Sunday', 'date-week-sun'],
|
||||
] as [string, string][]
|
||||
).map(([label, key]) =>
|
||||
menu.action({
|
||||
name: label,
|
||||
label: () => {
|
||||
const isSelected =
|
||||
group.groupInfo$.value?.config
|
||||
.name === key;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>${label}</span
|
||||
>`;
|
||||
},
|
||||
isSelected:
|
||||
group.groupInfo$.value?.config.name ===
|
||||
key,
|
||||
select: () => {
|
||||
group.changeGroupMode(key);
|
||||
return false;
|
||||
},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.subMenu({
|
||||
name: 'Sort',
|
||||
openOnHover: false,
|
||||
middleware: dropdownSubMenuMiddleware,
|
||||
autoHeight: true,
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap:4px;font-size:14px;line-height:20px;color:var(--affine-text-secondary-color);margin-left:8px;"
|
||||
>
|
||||
${group.sortAsc$.value
|
||||
? 'Oldest first'
|
||||
: 'Newest first'}
|
||||
</div>
|
||||
`,
|
||||
options: {
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Oldest first',
|
||||
label: () => {
|
||||
const isSelected = group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Oldest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(true);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Newest first',
|
||||
label: () => {
|
||||
const isSelected = !group.sortAsc$.value;
|
||||
return html`<span
|
||||
style="font-size:14px;color:${isSelected
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)'}"
|
||||
>Newest first</span
|
||||
>`;
|
||||
},
|
||||
isSelected: !group.sortAsc$.value,
|
||||
select: () => {
|
||||
group.setDateSortOrder(false);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => [
|
||||
menu.action({
|
||||
name: 'Hide empty groups',
|
||||
isSelected: group.hideEmpty$.value,
|
||||
select: () => {
|
||||
group.setHideEmpty(!group.hideEmpty$.value);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu =>
|
||||
html` <data-view-group-setting
|
||||
@mouseenter="${() => menu.closeSubMenu()}"
|
||||
.groupTrait="${group}"
|
||||
.columnId="${groupProperty.id}"
|
||||
></data-view-group-setting>`,
|
||||
menu => html`
|
||||
<data-view-group-setting
|
||||
@mouseenter=${() => menu.closeSubMenu()}
|
||||
.groupTrait=${group}
|
||||
.columnId=${gProp.id}
|
||||
></data-view-group-setting>
|
||||
`,
|
||||
],
|
||||
}),
|
||||
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
@@ -312,11 +623,14 @@ export const popGroupSetting = (
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
middleware,
|
||||
});
|
||||
menuHandler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
@@ -2,7 +2,12 @@ import {
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
type ReadonlySignal,
|
||||
signal,
|
||||
} from '@preact/signals-core';
|
||||
|
||||
import type { GroupBy, GroupProperty } from '../common/types.js';
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
@@ -11,8 +16,10 @@ import { computedLock } from '../utils/lock.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
import type { Row } from '../view-manager/row.js';
|
||||
import type { SingleView } from '../view-manager/single-view.js';
|
||||
import { compareDateKeys } from './compare-date-keys.js';
|
||||
import { defaultGroupBy } from './default.js';
|
||||
import { getGroupByService } from './matcher.js';
|
||||
import { findGroupByConfigByName, getGroupByService } from './matcher.js';
|
||||
// Test
|
||||
import type { GroupByConfig } from './types.js';
|
||||
|
||||
export type GroupInfo<
|
||||
@@ -42,138 +49,71 @@ export class Group<
|
||||
get property() {
|
||||
return this.groupInfo.property;
|
||||
}
|
||||
|
||||
name$ = computed(() => {
|
||||
const type = this.property.dataType$.value;
|
||||
if (!type) {
|
||||
return '';
|
||||
}
|
||||
return this.groupInfo.config.groupName(type, this.value);
|
||||
return type ? this.groupInfo.config.groupName(type, this.value) : '';
|
||||
});
|
||||
|
||||
private get config() {
|
||||
return this.groupInfo.config;
|
||||
}
|
||||
|
||||
get tType() {
|
||||
return this.groupInfo.tType;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.config.view;
|
||||
}
|
||||
|
||||
hide$ = computed(() => {
|
||||
const groupHide =
|
||||
this.manager.groupPropertiesMap$.value[this.key]?.hide ?? false;
|
||||
const emptyHidden = this.manager.hideEmpty$.value && this.rows.length === 0;
|
||||
return groupHide || emptyHidden;
|
||||
});
|
||||
|
||||
hideSet(hide: boolean) {
|
||||
this.manager.setGroupHide(this.key, hide);
|
||||
}
|
||||
}
|
||||
|
||||
function hasGroupProperties(
|
||||
data: unknown
|
||||
): data is { groupProperties?: GroupProperty[] } {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
if (!('groupProperties' in data)) {
|
||||
return false;
|
||||
}
|
||||
const value = (data as { groupProperties?: unknown }).groupProperties;
|
||||
return value === undefined || Array.isArray(value);
|
||||
}
|
||||
|
||||
export class GroupTrait {
|
||||
groupInfo$ = computed<GroupInfo | undefined>(() => {
|
||||
const groupBy = this.groupBy$.value;
|
||||
if (!groupBy) {
|
||||
return;
|
||||
}
|
||||
const property = this.view.propertyGetOrCreate(groupBy.columnId);
|
||||
if (!property) {
|
||||
return;
|
||||
}
|
||||
const tType = property.dataType$.value;
|
||||
if (!tType) {
|
||||
return;
|
||||
}
|
||||
const groupByService = getGroupByService(this.view.manager.dataSource);
|
||||
const result = groupByService?.matcher.match(tType);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
config: result,
|
||||
property,
|
||||
tType: tType,
|
||||
};
|
||||
hideEmpty$ = signal<boolean>(true);
|
||||
sortAsc$ = signal<boolean>(true);
|
||||
|
||||
groupProperties$ = computed(() => {
|
||||
const data = this.view.data$.value;
|
||||
return hasGroupProperties(data) ? (data.groupProperties ?? []) : [];
|
||||
});
|
||||
|
||||
staticInfo$ = computed(() => {
|
||||
const groupInfo = this.groupInfo$.value;
|
||||
if (!groupInfo) {
|
||||
return;
|
||||
}
|
||||
const staticMap = Object.fromEntries(
|
||||
groupInfo.config
|
||||
.defaultKeys(groupInfo.tType)
|
||||
.map(({ key, value }) => [key, new Group(key, value, groupInfo, this)])
|
||||
);
|
||||
return {
|
||||
staticMap,
|
||||
groupInfo,
|
||||
};
|
||||
});
|
||||
|
||||
groupDataMap$ = computed(() => {
|
||||
const staticInfo = this.staticInfo$.value;
|
||||
if (!staticInfo) {
|
||||
return;
|
||||
}
|
||||
const { staticMap, groupInfo } = staticInfo;
|
||||
const groupMap: Record<string, Group> = {};
|
||||
Object.entries(staticMap).forEach(([key, group]) => {
|
||||
groupMap[key] = new Group(key, group.value, groupInfo, this);
|
||||
groupPropertiesMap$ = computed(() => {
|
||||
const map: Record<string, GroupProperty> = {};
|
||||
this.groupProperties$.value.forEach(g => {
|
||||
map[g.key] = g;
|
||||
});
|
||||
this.view.rows$.value.forEach(row => {
|
||||
const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id)
|
||||
.jsonValue$.value;
|
||||
const keys = groupInfo.config.valuesGroup(value, groupInfo.tType);
|
||||
keys.forEach(({ key, value }) => {
|
||||
if (!groupMap[key]) {
|
||||
groupMap[key] = new Group(key, value, groupInfo, this);
|
||||
}
|
||||
groupMap[key].rows.push(row);
|
||||
});
|
||||
});
|
||||
return groupMap;
|
||||
});
|
||||
|
||||
groupsDataList$ = computedLock(
|
||||
computed(() => {
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
}
|
||||
const sortedGroup = this.ops.sortGroup(Object.keys(groupMap));
|
||||
sortedGroup.forEach(key => {
|
||||
if (!groupMap[key]) return;
|
||||
groupMap[key].rows = this.ops.sortRow(key, groupMap[key].rows);
|
||||
});
|
||||
return sortedGroup
|
||||
.map(key => groupMap[key])
|
||||
.filter((v): v is Group => v != null);
|
||||
}),
|
||||
this.view.isLocked$
|
||||
);
|
||||
|
||||
updateData = (data: NonNullable<unknown>) => {
|
||||
const property = this.property$.value;
|
||||
if (!property) {
|
||||
return;
|
||||
}
|
||||
this.view.propertyGetOrCreate(property.id).dataUpdate(() => data);
|
||||
};
|
||||
|
||||
get addGroup() {
|
||||
return this.property$.value?.meta$.value?.config.addGroup;
|
||||
}
|
||||
|
||||
property$ = computed(() => {
|
||||
const groupInfo = this.groupInfo$.value;
|
||||
if (!groupInfo) {
|
||||
return;
|
||||
}
|
||||
return groupInfo.property;
|
||||
return map;
|
||||
});
|
||||
|
||||
/**
|
||||
* Synchronize sortAsc$ with the GroupBy sort descriptor
|
||||
*/
|
||||
constructor(
|
||||
private readonly groupBy$: ReadonlySignal<GroupBy | undefined>,
|
||||
public view: SingleView,
|
||||
private readonly ops: {
|
||||
groupBySet: (groupBy: GroupBy | undefined) => void;
|
||||
sortGroup: (keys: string[]) => string[];
|
||||
groupBySet: (g: GroupBy | undefined) => void;
|
||||
sortGroup: (keys: string[], asc?: boolean) => string[];
|
||||
sortRow: (groupKey: string, rows: Row[]) => Row[];
|
||||
changeGroupSort: (keys: string[]) => void;
|
||||
changeRowSort: (
|
||||
@@ -181,11 +121,188 @@ export class GroupTrait {
|
||||
groupKey: string,
|
||||
keys: string[]
|
||||
) => void;
|
||||
changeGroupHide?: (key: string, hide: boolean) => void;
|
||||
}
|
||||
) {}
|
||||
) {
|
||||
effect(() => {
|
||||
const desc = this.groupBy$.value?.sort?.desc;
|
||||
if (desc != null && this.sortAsc$.value === desc) {
|
||||
this.sortAsc$.value = !desc;
|
||||
}
|
||||
});
|
||||
|
||||
// Sync hideEmpty state with GroupBy data
|
||||
effect(() => {
|
||||
const hide = this.groupBy$.value?.hideEmpty;
|
||||
if (hide != null && this.hideEmpty$.value !== hide) {
|
||||
this.hideEmpty$.value = hide;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
groupInfo$ = computed<GroupInfo | undefined>(() => {
|
||||
const groupBy = this.groupBy$.value;
|
||||
if (!groupBy) return;
|
||||
|
||||
const property = this.view.propertyGetOrCreate(groupBy.columnId);
|
||||
if (!property) return;
|
||||
|
||||
const tType = property.dataType$.value;
|
||||
if (!tType) return;
|
||||
|
||||
const svc = getGroupByService(this.view.manager.dataSource);
|
||||
const res =
|
||||
groupBy.name != null
|
||||
? (findGroupByConfigByName(
|
||||
this.view.manager.dataSource,
|
||||
groupBy.name
|
||||
) ?? svc?.matcher.match(tType))
|
||||
: svc?.matcher.match(tType);
|
||||
|
||||
if (!res) return;
|
||||
return { config: res, property, tType };
|
||||
});
|
||||
|
||||
staticInfo$ = computed(() => {
|
||||
const info = this.groupInfo$.value;
|
||||
if (!info) return;
|
||||
const staticMap = Object.fromEntries(
|
||||
info.config
|
||||
.defaultKeys(info.tType)
|
||||
.map(({ key, value }) => [key, new Group(key, value, info, this)])
|
||||
);
|
||||
return { staticMap, groupInfo: info };
|
||||
});
|
||||
|
||||
groupDataMap$ = computed(() => {
|
||||
const si = this.staticInfo$.value;
|
||||
if (!si) return;
|
||||
const { staticMap, groupInfo } = si;
|
||||
// Create fresh Group instances with empty rows arrays
|
||||
const map: Record<string, Group> = {};
|
||||
Object.entries(staticMap).forEach(([key, group]) => {
|
||||
map[key] = new Group(key, group.value, groupInfo, this);
|
||||
});
|
||||
// Assign rows to their respective groups
|
||||
this.view.rows$.value.forEach(row => {
|
||||
const cell = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id);
|
||||
const jv = cell.jsonValue$.value;
|
||||
const keys = groupInfo.config.valuesGroup(jv, groupInfo.tType);
|
||||
keys.forEach(({ key, value }) => {
|
||||
if (!map[key]) map[key] = new Group(key, value, groupInfo, this);
|
||||
map[key].rows.push(row);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
});
|
||||
|
||||
groupsDataList$ = computedLock(
|
||||
computed(() => {
|
||||
const map = this.groupDataMap$.value;
|
||||
if (!map) return;
|
||||
|
||||
const gi = this.groupInfo$.value;
|
||||
let ordered: string[];
|
||||
|
||||
if (gi?.config.matchType.name === 'Date') {
|
||||
ordered = Object.keys(map).sort(
|
||||
compareDateKeys(gi.config.name, this.sortAsc$.value)
|
||||
);
|
||||
} else {
|
||||
ordered = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
|
||||
}
|
||||
|
||||
return ordered
|
||||
.map(k => map[k])
|
||||
.filter(
|
||||
(g): g is Group =>
|
||||
!!g &&
|
||||
!this.isGroupHidden(g.key) &&
|
||||
(!this.hideEmpty$.value || g.rows.length > 0)
|
||||
);
|
||||
}),
|
||||
this.view.isLocked$
|
||||
);
|
||||
|
||||
/**
|
||||
* Computed list of groups including hidden ones, used by settings UI.
|
||||
*/
|
||||
groupsDataListAll$ = computedLock(
|
||||
computed(() => {
|
||||
const map = this.groupDataMap$.value;
|
||||
const info = this.groupInfo$.value;
|
||||
if (!map || !info) return;
|
||||
|
||||
let orderedKeys: string[];
|
||||
if (info.config.matchType.name === 'Date') {
|
||||
orderedKeys = Object.keys(map).sort(
|
||||
compareDateKeys(info.config.name, this.sortAsc$.value)
|
||||
);
|
||||
} else {
|
||||
orderedKeys = this.ops.sortGroup(Object.keys(map), this.sortAsc$.value);
|
||||
}
|
||||
|
||||
const visible: Group[] = [];
|
||||
const hidden: Group[] = [];
|
||||
orderedKeys
|
||||
.map(key => map[key])
|
||||
.filter((g): g is Group => g != null)
|
||||
.forEach(g => {
|
||||
if (g.hide$.value) {
|
||||
hidden.push(g);
|
||||
} else {
|
||||
visible.push(g);
|
||||
}
|
||||
});
|
||||
return [...visible, ...hidden];
|
||||
}),
|
||||
this.view.isLocked$
|
||||
);
|
||||
|
||||
/** Whether all groups are currently hidden */
|
||||
allHidden$ = computed(() => {
|
||||
const map = this.groupDataMap$.value;
|
||||
if (!map) return false;
|
||||
return Object.keys(map).every(key => this.isGroupHidden(key));
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle hiding of empty groups.
|
||||
*/
|
||||
|
||||
setHideEmpty(value: boolean) {
|
||||
this.hideEmpty$.value = value;
|
||||
const gb = this.groupBy$.value;
|
||||
if (gb) {
|
||||
this.ops.groupBySet({ ...gb, hideEmpty: value });
|
||||
}
|
||||
}
|
||||
|
||||
isGroupHidden(key: string): boolean {
|
||||
return this.groupPropertiesMap$.value[key]?.hide ?? false;
|
||||
}
|
||||
|
||||
setGroupHide(key: string, hide: boolean) {
|
||||
this.ops.changeGroupHide?.(key, hide);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sort order for date groupings and update GroupBy sort descriptor.
|
||||
*/
|
||||
setDateSortOrder(asc: boolean) {
|
||||
this.sortAsc$.value = asc;
|
||||
|
||||
const gb = this.groupBy$.value;
|
||||
if (gb) {
|
||||
this.ops.groupBySet({
|
||||
...gb,
|
||||
sort: { desc: !asc },
|
||||
hideEmpty: gb.hideEmpty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addToGroup(rowId: string, key: string) {
|
||||
this.view.lockRows(false);
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
const groupInfo = this.groupInfo$.value;
|
||||
if (!groupMap || !groupInfo) {
|
||||
@@ -205,18 +322,34 @@ export class GroupTrait {
|
||||
.cellGetOrCreate(rowId, groupInfo.property.id)
|
||||
.valueSet(newValue);
|
||||
}
|
||||
}
|
||||
const map = this.groupDataMap$.value;
|
||||
const info = this.groupInfo$.value;
|
||||
if (!map || !info) return;
|
||||
|
||||
changeCardSort(groupKey: string, cardIds: string[]) {
|
||||
const groups = this.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
this.ops.changeRowSort(
|
||||
groups.map(v => v.key),
|
||||
groupKey,
|
||||
cardIds
|
||||
const addFn = info.config.addToGroup;
|
||||
if (addFn === false) return;
|
||||
|
||||
const group = map[key];
|
||||
if (!group) return;
|
||||
|
||||
const current = group.value;
|
||||
// Handle both null and non-null values to ensure proper group assignment
|
||||
const newVal = addFn(
|
||||
current,
|
||||
this.view.cellGetOrCreate(rowId, info.property.id).jsonValue$.value
|
||||
);
|
||||
this.view.cellGetOrCreate(rowId, info.property.id).valueSet(newVal);
|
||||
}
|
||||
changeGroupMode(modeName: string) {
|
||||
const propId = this.property$.value?.id;
|
||||
if (!propId) return;
|
||||
this.ops.groupBySet({
|
||||
type: 'groupBy',
|
||||
columnId: propId,
|
||||
name: modeName,
|
||||
sort: { desc: !this.sortAsc$.value },
|
||||
hideEmpty: this.hideEmpty$.value,
|
||||
});
|
||||
}
|
||||
|
||||
changeGroup(columnId: string | undefined) {
|
||||
@@ -225,31 +358,38 @@ export class GroupTrait {
|
||||
return;
|
||||
}
|
||||
const column = this.view.propertyGetOrCreate(columnId);
|
||||
const propertyMeta = this.view.manager.dataSource.propertyMetaGet(
|
||||
const meta = this.view.manager.dataSource.propertyMetaGet(
|
||||
column.type$.value
|
||||
);
|
||||
if (propertyMeta) {
|
||||
this.ops.groupBySet(
|
||||
defaultGroupBy(
|
||||
this.view.manager.dataSource,
|
||||
propertyMeta,
|
||||
column.id,
|
||||
column.data$.value
|
||||
)
|
||||
if (meta) {
|
||||
const gb = defaultGroupBy(
|
||||
this.view.manager.dataSource,
|
||||
meta,
|
||||
column.id,
|
||||
column.data$.value
|
||||
);
|
||||
if (gb) {
|
||||
gb.sort = { desc: !this.sortAsc$.value };
|
||||
gb.hideEmpty = this.hideEmpty$.value;
|
||||
}
|
||||
this.ops.groupBySet(gb);
|
||||
}
|
||||
}
|
||||
|
||||
changeGroupSort(keys: string[]) {
|
||||
this.ops.changeGroupSort(keys);
|
||||
property$ = computed(() => this.groupInfo$.value?.property);
|
||||
|
||||
get addGroup() {
|
||||
return this.property$.value?.meta$.value?.config.addGroup;
|
||||
}
|
||||
|
||||
defaultGroupProperty(key: string): GroupProperty {
|
||||
return {
|
||||
key,
|
||||
hide: false,
|
||||
manuallyCardSort: [],
|
||||
};
|
||||
updateData = (data: NonNullable<unknown>) => {
|
||||
const prop = this.property$.value;
|
||||
if (!prop) return;
|
||||
this.view.propertyGetOrCreate(prop.id).dataUpdate(() => data);
|
||||
};
|
||||
|
||||
changeGroupSort(keys: string[]) {
|
||||
this.ops.changeGroupSort(keys);
|
||||
}
|
||||
|
||||
moveCardTo(
|
||||
@@ -258,7 +398,6 @@ export class GroupTrait {
|
||||
toGroupKey: string,
|
||||
position: InsertToPosition
|
||||
) {
|
||||
this.view.lockRows(false);
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
@@ -291,16 +430,16 @@ export class GroupTrait {
|
||||
.map(row => row.rowId) ?? [];
|
||||
const index = insertPositionToIndex(position, rows, row => row);
|
||||
rows.splice(index, 0, rowId);
|
||||
this.changeCardSort(toGroupKey, rows);
|
||||
const groupKeys = Object.keys(groupMap);
|
||||
this.ops.changeRowSort(groupKeys, toGroupKey, rows);
|
||||
}
|
||||
|
||||
moveGroupTo(groupKey: string, position: InsertToPosition) {
|
||||
this.view.lockRows(false);
|
||||
const groups = this.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
const keys = groups.map(v => v.key);
|
||||
const keys = groups.map(v => v!.key);
|
||||
keys.splice(
|
||||
keys.findIndex(key => key === groupKey),
|
||||
1
|
||||
@@ -311,7 +450,6 @@ export class GroupTrait {
|
||||
}
|
||||
|
||||
removeFromGroup(rowId: string, key: string) {
|
||||
this.view.lockRows(false);
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
@@ -330,7 +468,6 @@ export class GroupTrait {
|
||||
}
|
||||
|
||||
updateValue(rows: string[], value: unknown) {
|
||||
this.view.lockRows(false);
|
||||
const propertyId = this.property$.value?.id;
|
||||
if (!propertyId) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
|
||||
import { renderUniLit } from '../utils/index.js';
|
||||
import type { SortUtils } from './utils.js';
|
||||
@@ -13,9 +14,13 @@ export const popCreateSort = (
|
||||
sortUtils: SortUtils;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
},
|
||||
ops?: {
|
||||
middleware?: Middleware[];
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
const subHandler = popMenu(target, {
|
||||
middleware: ops?.middleware,
|
||||
options: {
|
||||
onClose: props.onClose,
|
||||
title: {
|
||||
@@ -50,4 +55,5 @@ export const popCreateSort = (
|
||||
],
|
||||
},
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export type MainProperties = {
|
||||
};
|
||||
|
||||
export interface SingleView {
|
||||
data$: any;
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly manager: ViewManager;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const dateValueContainerStyle = css({
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '17px',
|
||||
lineHeight: '22px',
|
||||
height: '46px',
|
||||
height: '30px',
|
||||
});
|
||||
|
||||
export const datePickerContainerStyle = css({
|
||||
|
||||
@@ -74,12 +74,15 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
};
|
||||
});
|
||||
},
|
||||
sortGroup: ids =>
|
||||
sortByManually(
|
||||
sortGroup: (ids, asc) => {
|
||||
const sorted = sortByManually(
|
||||
ids,
|
||||
v => v,
|
||||
this.view?.groupProperties.map(v => v.key) ?? []
|
||||
),
|
||||
);
|
||||
// If descending order is requested, reverse the sorted array
|
||||
return asc === false ? sorted.reverse() : sorted;
|
||||
},
|
||||
sortRow: (key, rows) => {
|
||||
const property = this.view?.groupProperties.find(v => v.key === key);
|
||||
return sortByManually(
|
||||
@@ -136,6 +139,33 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
};
|
||||
});
|
||||
},
|
||||
changeGroupHide: (key, hide) => {
|
||||
this.dataUpdate(() => {
|
||||
const list = [...(this.view?.groupProperties ?? [])];
|
||||
const idx = list.findIndex(g => g.key === key);
|
||||
if (idx >= 0) {
|
||||
const target = list[idx];
|
||||
if (!target) {
|
||||
return { groupProperties: list };
|
||||
}
|
||||
list[idx] = { ...target, hide };
|
||||
} else {
|
||||
// maintain existing order when inserting a new entry
|
||||
const order = (this.groupTrait.groupsDataListAll$.value ?? [])
|
||||
.map(g => g?.key)
|
||||
.filter((k): k is string => typeof k === 'string');
|
||||
let insertPos = 0;
|
||||
for (const k of order) {
|
||||
if (k === key) break;
|
||||
if (list.findIndex(g => g.key === k) !== -1) {
|
||||
insertPos++;
|
||||
}
|
||||
}
|
||||
list.splice(insertPos, 0, { key, hide, manuallyCardSort: [] });
|
||||
}
|
||||
return { groupProperties: list };
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ export class MobileKanbanViewUI extends DataViewUIBase<MobileKanbanViewUILogic>
|
||||
if (!groups) {
|
||||
return html``;
|
||||
}
|
||||
const groupEntries = groups.filter(
|
||||
(group): group is NonNullable<(typeof groups)[number]> => group != null
|
||||
);
|
||||
const vPadding = this.logic.root.config.virtualPadding$.value;
|
||||
const wrapperStyle = styleMap({
|
||||
marginLeft: `-${vPadding}px`,
|
||||
@@ -149,7 +152,7 @@ export class MobileKanbanViewUI extends DataViewUIBase<MobileKanbanViewUILogic>
|
||||
})}
|
||||
<div class="${mobileKanbanGroups}" style="${wrapperStyle}">
|
||||
${repeat(
|
||||
groups,
|
||||
groupEntries,
|
||||
group => group.key,
|
||||
group => {
|
||||
return html` <mobile-kanban-group
|
||||
|
||||
@@ -25,6 +25,9 @@ export const popCardMenu = (
|
||||
if (!groupTrait) {
|
||||
return;
|
||||
}
|
||||
const groups = (groupTrait.groupsDataList$.value ?? []).filter(
|
||||
(v): v is NonNullable<typeof v> => v != null
|
||||
);
|
||||
popFilterableSimpleMenu(ele, [
|
||||
menu.group({
|
||||
items: [
|
||||
@@ -47,12 +50,10 @@ export const popCardMenu = (
|
||||
prefix: ArrowRightBigIcon(),
|
||||
options: {
|
||||
items:
|
||||
groupTrait.groupsDataList$.value
|
||||
?.filter(v => {
|
||||
return v.key !== groupKey;
|
||||
})
|
||||
.map(group => {
|
||||
return menu.action({
|
||||
groups
|
||||
.filter(v => v.key !== groupKey)
|
||||
.map(group =>
|
||||
menu.action({
|
||||
name: group.value != null ? group.name$.value : 'Ungroup',
|
||||
select: () => {
|
||||
groupTrait.moveCardTo(
|
||||
@@ -62,8 +63,8 @@ export const popCardMenu = (
|
||||
'start'
|
||||
);
|
||||
},
|
||||
});
|
||||
}) ?? [],
|
||||
})
|
||||
) ?? [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -202,7 +202,11 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`${groups.map(group => {
|
||||
const safeGroups = groups.filter(
|
||||
(group): group is NonNullable<(typeof groups)[number]> => group != null
|
||||
);
|
||||
|
||||
return html`${safeGroups.map(group => {
|
||||
return html` <affine-data-view-kanban-group
|
||||
${sortable(group.key)}
|
||||
data-key="${group.key}"
|
||||
@@ -226,8 +230,13 @@ export class KanbanViewUI extends DataViewUIBase<KanbanViewUILogic> {
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const groups = this.logic.groups$.value;
|
||||
if (!groups) {
|
||||
const groups = this.logic.groups$.value?.filter(
|
||||
(
|
||||
group
|
||||
): group is NonNullable<(typeof this.logic.groups$.value)[number]> =>
|
||||
group != null
|
||||
);
|
||||
if (!groups || groups.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export const popCardMenu = (
|
||||
rowId: string,
|
||||
selection: KanbanSelectionController
|
||||
) => {
|
||||
const groups = (selection.view.groupTrait.groupsDataList$.value ?? []).filter(
|
||||
(v): v is NonNullable<typeof v> => v != null
|
||||
);
|
||||
popFilterableSimpleMenu(ele, [
|
||||
menu.action({
|
||||
name: 'Expand Card',
|
||||
@@ -50,22 +53,23 @@ export const popCardMenu = (
|
||||
prefix: ArrowRightBigIcon(),
|
||||
options: {
|
||||
items:
|
||||
selection.view.groupTrait.groupsDataList$.value
|
||||
?.filter(v => {
|
||||
groups
|
||||
.filter(v => {
|
||||
const cardSelection = selection.selection;
|
||||
if (cardSelection?.selectionType === 'card') {
|
||||
return v.key !== cardSelection?.cards[0].groupKey;
|
||||
const currentGroup = cardSelection.cards[0]?.groupKey;
|
||||
return currentGroup ? v.key !== currentGroup : true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(group => {
|
||||
return menu.action({
|
||||
.map(group =>
|
||||
menu.action({
|
||||
name: group.value != null ? group.name$.value : 'Ungroup',
|
||||
select: () => {
|
||||
selection.moveCard(rowId, group.key);
|
||||
},
|
||||
});
|
||||
}) ?? [],
|
||||
})
|
||||
) ?? [],
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
|
||||
@@ -108,10 +108,13 @@ export class MobileTableViewUI extends DataViewUIBase<MobileTableViewUILogic> {
|
||||
private renderTable() {
|
||||
const groups = this.logic.view.groupTrait.groupsDataList$.value;
|
||||
if (groups) {
|
||||
const groupEntries = groups.filter(
|
||||
(group): group is NonNullable<(typeof groups)[number]> => group != null
|
||||
);
|
||||
return html`
|
||||
<div style="display:flex;flex-direction: column;gap: 16px;">
|
||||
${repeat(
|
||||
groups,
|
||||
groupEntries,
|
||||
v => v.key,
|
||||
group => {
|
||||
return html` <mobile-table-group
|
||||
|
||||
@@ -18,9 +18,11 @@ export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
|
||||
accessor gridGroup!: TableGridGroup;
|
||||
|
||||
group$ = computed(() => {
|
||||
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find(
|
||||
g => g.key === this.gridGroup.groupId
|
||||
);
|
||||
const groups =
|
||||
this.tableViewLogic.groupTrait$.value?.groupsDataList$.value ?? [];
|
||||
return groups
|
||||
.filter((group): group is NonNullable<typeof group> => group != null)
|
||||
.find(g => g.key === this.gridGroup.groupId);
|
||||
});
|
||||
|
||||
get selectionController() {
|
||||
|
||||
@@ -35,9 +35,11 @@ export class TableGroupHeader extends SignalWatcher(
|
||||
}
|
||||
|
||||
group$ = computed(() => {
|
||||
return this.tableViewLogic.groupTrait$.value?.groupsDataList$.value?.find(
|
||||
g => g.key === this.gridGroup.groupId
|
||||
);
|
||||
const groups =
|
||||
this.tableViewLogic.groupTrait$.value?.groupsDataList$.value ?? [];
|
||||
return groups
|
||||
.filter((group): group is NonNullable<typeof group> => group != null)
|
||||
.find(g => g.key === this.gridGroup.groupId);
|
||||
});
|
||||
|
||||
groupKey$ = computed(() => {
|
||||
|
||||
@@ -95,7 +95,14 @@ export class VirtualTableViewUILogic extends DataViewUILogicBase<
|
||||
},
|
||||
];
|
||||
}
|
||||
return groupTrait.groupsDataList$.value.map(group => ({
|
||||
const groups = groupTrait.groupsDataList$.value.filter(
|
||||
(
|
||||
group
|
||||
): group is NonNullable<
|
||||
(typeof groupTrait.groupsDataList$.value)[number]
|
||||
> => group != null
|
||||
);
|
||||
return groups.map(group => ({
|
||||
id: group.key,
|
||||
rows: group.rows.map(v => v.rowId),
|
||||
}));
|
||||
|
||||
@@ -92,6 +92,17 @@ export const addGroupIconStyle = css({
|
||||
fill: 'var(--affine-icon-color)',
|
||||
},
|
||||
});
|
||||
export const groupsHiddenMessageStyle = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '80px',
|
||||
zIndex: 0,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: '14px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
const cellDividerStyle = css({
|
||||
width: '1px',
|
||||
height: '100%',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { GroupTrait } from '../../../core/group-by/trait.js';
|
||||
import type { Group, GroupTrait } from '../../../core/group-by/trait.js';
|
||||
import {
|
||||
createUniComponentFromWebComponent,
|
||||
renderUniLit,
|
||||
@@ -30,6 +30,7 @@ import { TableSelectionController } from './controller/selection.js';
|
||||
import {
|
||||
addGroupIconStyle,
|
||||
addGroupStyle,
|
||||
groupsHiddenMessageStyle,
|
||||
tableGroupsContainerStyle,
|
||||
tableScrollContainerStyle,
|
||||
tableViewStyle,
|
||||
@@ -154,26 +155,27 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
|
||||
}
|
||||
|
||||
private renderTable() {
|
||||
const groups = this.logic.view.groupTrait.groupsDataList$.value;
|
||||
if (groups) {
|
||||
const groups = this.logic.view.groupTrait.groupsDataList$.value?.filter(
|
||||
(g): g is Group => g !== undefined
|
||||
);
|
||||
if (groups && groups.length) {
|
||||
return html`
|
||||
<div class="${tableGroupsContainerStyle}">
|
||||
${repeat(
|
||||
groups,
|
||||
v => v.key,
|
||||
group => {
|
||||
return html` <affine-data-view-table-group
|
||||
group => group.key,
|
||||
group =>
|
||||
html`<affine-data-view-table-group
|
||||
data-group-key="${group.key}"
|
||||
.tableViewLogic="${this.logic}"
|
||||
.group="${group}"
|
||||
></affine-data-view-table-group>`;
|
||||
}
|
||||
></affine-data-view-table-group>`
|
||||
)}
|
||||
${this.logic.renderAddGroup(this.logic.view.groupTrait)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html` <affine-data-view-table-group
|
||||
return html`<affine-data-view-table-group
|
||||
.tableViewLogic="${this.logic}"
|
||||
></affine-data-view-table-group>`;
|
||||
}
|
||||
@@ -205,7 +207,11 @@ export class TableViewUI extends DataViewUIBase<TableViewUILogic> {
|
||||
class="affine-database-table-container"
|
||||
style="${containerStyle}"
|
||||
>
|
||||
${this.renderTable()}
|
||||
${this.logic.view.groupTrait.allHidden$.value
|
||||
? html`<div class="${groupsHiddenMessageStyle}">
|
||||
All groups are hidden
|
||||
</div>`
|
||||
: this.renderTable()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,12 +101,15 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
};
|
||||
});
|
||||
},
|
||||
sortGroup: ids =>
|
||||
sortByManually(
|
||||
sortGroup: (ids, asc) => {
|
||||
const sorted = sortByManually(
|
||||
ids,
|
||||
v => v,
|
||||
this.groupProperties.map(v => v.key)
|
||||
),
|
||||
);
|
||||
// If descending order is requested, reverse the sorted array
|
||||
return asc === false ? sorted.reverse() : sorted;
|
||||
},
|
||||
sortRow: (key, rows) => {
|
||||
const property = this.groupProperties.find(v => v.key === key);
|
||||
return sortByManually(
|
||||
@@ -163,6 +166,30 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
|
||||
};
|
||||
});
|
||||
},
|
||||
changeGroupHide: (key, hide) => {
|
||||
this.dataUpdate(() => {
|
||||
const list = [...this.groupProperties];
|
||||
const idx = list.findIndex(g => g.key === key);
|
||||
if (idx >= 0) {
|
||||
const target = list[idx];
|
||||
if (!target) {
|
||||
return { groupProperties: list };
|
||||
}
|
||||
list[idx] = { ...target, hide };
|
||||
} else {
|
||||
const order = (this.groupTrait.groupsDataListAll$.value ?? [])
|
||||
.map(g => g?.key)
|
||||
.filter((k): k is string => !!k);
|
||||
let insertPos = 0;
|
||||
for (const k of order) {
|
||||
if (k === key) break;
|
||||
if (list.some(g => g.key === k)) insertPos++;
|
||||
}
|
||||
list.splice(insertPos, 0, { key, hide, manuallyCardSort: [] });
|
||||
}
|
||||
return { groupProperties: list };
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
popupTargetFromElement,
|
||||
subMenuMiddleware,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
DeleteIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
@@ -99,6 +99,11 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
|
||||
return;
|
||||
}
|
||||
const handler = popMenu(target, {
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||
offset({ mainAxis: 4, crossAxis: 0 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
options: {
|
||||
items: [
|
||||
menu.group({
|
||||
@@ -107,7 +112,7 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
|
||||
name: fn.label,
|
||||
postfix: ArrowRightSmallIcon(),
|
||||
select: ele => {
|
||||
popMenu(popupTargetFromElement(ele), {
|
||||
const subHandler = popMenu(popupTargetFromElement(ele), {
|
||||
options: {
|
||||
items: [
|
||||
menu.group({
|
||||
@@ -117,8 +122,18 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
|
||||
}),
|
||||
],
|
||||
},
|
||||
middleware: subMenuMiddleware,
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start'],
|
||||
}),
|
||||
offset({ mainAxis: 4, crossAxis: 0 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
// allow submenu height and width to adjust to content
|
||||
subHandler.menu.menuElement.style.minHeight = 'fit-content';
|
||||
subHandler.menu.menuElement.style.maxHeight = 'fit-content';
|
||||
subHandler.menu.menuElement.style.minWidth = '200px';
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
@@ -142,6 +157,10 @@ export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
|
||||
],
|
||||
},
|
||||
});
|
||||
// allow main menu height and width to adjust to calendar size
|
||||
handler.menu.menuElement.style.minHeight = 'fit-content';
|
||||
handler.menu.menuElement.style.maxHeight = 'fit-content';
|
||||
handler.menu.menuElement.style.minWidth = '200px';
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
menu,
|
||||
popFilterableSimpleMenu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
popupTargetFromElement,
|
||||
subMenuMiddleware,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import {
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
PlusIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { type Middleware, offset } from '@floating-ui/dom';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
@@ -208,66 +207,64 @@ export class FilterRootView extends SignalWatcher(ShadowlessElement) {
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
popFilterableSimpleMenu(popupTargetFromElement(target), [
|
||||
menu.action({
|
||||
name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
|
||||
prefix: ConvertIcon(),
|
||||
onHover: hover => {
|
||||
this.containerClass = hover
|
||||
? { index: i, class: 'hover-style' }
|
||||
: undefined;
|
||||
},
|
||||
hide: () => getDepth(filter) > 3,
|
||||
select: () => {
|
||||
this.onChange({
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [this.filterGroup.value],
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
onHover: hover => {
|
||||
this.containerClass = hover
|
||||
? { index: i, class: 'hover-style' }
|
||||
: undefined;
|
||||
},
|
||||
select: () => {
|
||||
const conditions = [...this.filterGroup.value.conditions];
|
||||
conditions.splice(
|
||||
i + 1,
|
||||
0,
|
||||
JSON.parse(JSON.stringify(conditions[i]))
|
||||
);
|
||||
this.onChange({ ...this.filterGroup.value, conditions: conditions });
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
const handler = popMenu(popupTargetFromElement(target), {
|
||||
placement: 'bottom-end',
|
||||
middleware: [offset({ mainAxis: 12, crossAxis: 0 })],
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
onHover: hover => {
|
||||
this.containerClass = hover
|
||||
? { index: i, class: 'delete-style' }
|
||||
: undefined;
|
||||
},
|
||||
name:
|
||||
filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
|
||||
prefix: ConvertIcon(),
|
||||
hide: () => getDepth(filter) > 3,
|
||||
select: () => {
|
||||
const conditions = [...this.filterGroup.value.conditions];
|
||||
conditions.splice(i, 1);
|
||||
this.onChange({
|
||||
...this.filterGroup.value,
|
||||
conditions,
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [this.filterGroup.value],
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
select: () => {
|
||||
const conditions = [...this.filterGroup.value.conditions];
|
||||
conditions.splice(
|
||||
i + 1,
|
||||
0,
|
||||
JSON.parse(JSON.stringify(conditions[i]))
|
||||
);
|
||||
this.onChange({
|
||||
...this.filterGroup.value,
|
||||
conditions: conditions,
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
select: () => {
|
||||
const conditions = [...this.filterGroup.value.conditions];
|
||||
conditions.splice(i, 1);
|
||||
this.onChange({
|
||||
...this.filterGroup.value,
|
||||
conditions,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
handler.menu.menuElement.style.minWidth = '200px';
|
||||
handler.menu.menuElement.style.maxWidth = 'fit-content';
|
||||
handler.menu.menuElement.style.minHeight = 'fit-content';
|
||||
}
|
||||
|
||||
private deleteFilter(i: number) {
|
||||
@@ -378,16 +375,20 @@ export const popFilterRoot = (
|
||||
props: {
|
||||
filterTrait: FilterTrait;
|
||||
onBack: () => void;
|
||||
onClose?: () => void;
|
||||
dataViewLogic: DataViewUILogicBase;
|
||||
}
|
||||
},
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const filterTrait = props.filterTrait;
|
||||
const view = filterTrait.view;
|
||||
popMenu(target, {
|
||||
const handler = popMenu(target, {
|
||||
middleware,
|
||||
options: {
|
||||
title: {
|
||||
text: 'Filters',
|
||||
onBack: props.onBack,
|
||||
onClose: props.onClose,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
@@ -409,23 +410,16 @@ export const popFilterRoot = (
|
||||
prefix: PlusIcon(),
|
||||
select: ele => {
|
||||
const value = filterTrait.filter$.value;
|
||||
popCreateFilter(
|
||||
popupTargetFromElement(ele),
|
||||
{
|
||||
vars: view.vars$,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...value,
|
||||
conditions: [...value.conditions, filter],
|
||||
});
|
||||
props.dataViewLogic.eventTrace(
|
||||
'CreateDatabaseFilter',
|
||||
{}
|
||||
);
|
||||
},
|
||||
popCreateFilter(popupTargetFromElement(ele), {
|
||||
vars: view.vars$,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...value,
|
||||
conditions: [...value.conditions, filter],
|
||||
});
|
||||
props.dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
{ middleware: subMenuMiddleware }
|
||||
);
|
||||
});
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
@@ -434,4 +428,5 @@ export const popFilterRoot = (
|
||||
],
|
||||
},
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PlusIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
@@ -203,11 +204,14 @@ export const popSortRoot = (
|
||||
title?: {
|
||||
text: string;
|
||||
onBack?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
},
|
||||
middleware?: Array<Middleware | null | undefined | false>
|
||||
) => {
|
||||
const sortUtils = props.sortUtils;
|
||||
popMenu(target, {
|
||||
const handler = popMenu(target, {
|
||||
middleware,
|
||||
options: {
|
||||
title: props.title,
|
||||
items: [
|
||||
@@ -237,4 +241,5 @@ export const popSortRoot = (
|
||||
],
|
||||
},
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MoreHorizontalIcon,
|
||||
SortIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { autoPlacement, offset, shift } from '@floating-ui/dom';
|
||||
import { css, html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
@@ -97,7 +98,8 @@ declare global {
|
||||
const createSettingMenus = (
|
||||
target: PopupTarget,
|
||||
dataViewLogic: DataViewUILogicBase,
|
||||
reopen: () => void
|
||||
reopen: () => void,
|
||||
closeMenu: () => void
|
||||
) => {
|
||||
const view = dataViewLogic.view;
|
||||
const settingItems: MenuConfig[] = [];
|
||||
@@ -105,15 +107,25 @@ const createSettingMenus = (
|
||||
menu.action({
|
||||
name: 'Properties',
|
||||
prefix: InfoIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${view.properties$.value.length} shown
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
popPropertiesSetting(target, {
|
||||
view: view,
|
||||
onBack: reopen,
|
||||
});
|
||||
popPropertiesSetting(
|
||||
target,
|
||||
{
|
||||
view: view,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({ allowedPlacements: ['bottom-start', 'top-start'] }),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -124,6 +136,7 @@ const createSettingMenus = (
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${filterCount === 0
|
||||
? ''
|
||||
@@ -134,28 +147,66 @@ const createSettingMenus = (
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
if (!filterTrait.filter$.value.conditions.length) {
|
||||
popCreateFilter(target, {
|
||||
vars: view.vars$,
|
||||
onBack: reopen,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
||||
conditions: [...filterTrait.filter$.value.conditions, filter],
|
||||
});
|
||||
popFilterRoot(target, {
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
dataViewLogic: dataViewLogic,
|
||||
});
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
popCreateFilter(
|
||||
target,
|
||||
{
|
||||
vars: view.vars$,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
onSelect: filter => {
|
||||
filterTrait.filterSet({
|
||||
...(filterTrait.filter$.value ?? emptyFilterGroup),
|
||||
conditions: [
|
||||
...filterTrait.filter$.value.conditions,
|
||||
filter,
|
||||
],
|
||||
});
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
dataViewLogic.eventTrace('CreateDatabaseFilter', {});
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popFilterRoot(target, {
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
dataViewLogic: dataViewLogic,
|
||||
});
|
||||
popFilterRoot(
|
||||
target,
|
||||
{
|
||||
filterTrait: filterTrait,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
dataViewLogic: dataViewLogic,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -168,6 +219,7 @@ const createSettingMenus = (
|
||||
menu.action({
|
||||
name: 'Sort',
|
||||
prefix: SortIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${sortCount === 0
|
||||
? ''
|
||||
@@ -183,18 +235,42 @@ const createSettingMenus = (
|
||||
dataViewLogic.eventTrace
|
||||
);
|
||||
if (!sortList.length) {
|
||||
popCreateSort(target, {
|
||||
sortUtils: sortUtils,
|
||||
onBack: reopen,
|
||||
});
|
||||
} else {
|
||||
popSortRoot(target, {
|
||||
sortUtils: sortUtils,
|
||||
title: {
|
||||
text: 'Sort',
|
||||
popCreateSort(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
});
|
||||
{
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
popSortRoot(
|
||||
target,
|
||||
{
|
||||
sortUtils: sortUtils,
|
||||
title: {
|
||||
text: 'Sort',
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -206,6 +282,7 @@ const createSettingMenus = (
|
||||
menu.action({
|
||||
name: 'Group',
|
||||
prefix: GroupingIcon(),
|
||||
closeOnSelect: false,
|
||||
postfix: html` <div style="font-size: 14px;">
|
||||
${groupTrait.property$.value?.name$.value ?? ''}
|
||||
</div>
|
||||
@@ -213,12 +290,37 @@ const createSettingMenus = (
|
||||
select: () => {
|
||||
const groupBy = groupTrait.property$.value;
|
||||
if (!groupBy) {
|
||||
popSelectGroupByProperty(target, groupTrait, {
|
||||
onSelect: () => popGroupSetting(target, groupTrait, reopen),
|
||||
onBack: reopen,
|
||||
});
|
||||
popSelectGroupByProperty(
|
||||
target,
|
||||
groupTrait,
|
||||
{
|
||||
onSelect: () =>
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]),
|
||||
onBack: reopen,
|
||||
onClose: closeMenu,
|
||||
},
|
||||
[
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
popGroupSetting(target, groupTrait, reopen);
|
||||
popGroupSetting(target, groupTrait, reopen, closeMenu, [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -308,7 +410,7 @@ export const popViewOptions = (
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
});
|
||||
popMenu(target, {
|
||||
const subHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
onBack: reopen,
|
||||
@@ -338,7 +440,15 @@ export const popViewOptions = (
|
||||
// }),
|
||||
],
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['bottom-start', 'top-start'],
|
||||
}),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
||||
},
|
||||
prefix: LayoutIcon(),
|
||||
}),
|
||||
@@ -348,7 +458,9 @@ export const popViewOptions = (
|
||||
|
||||
items.push(
|
||||
menu.group({
|
||||
items: createSettingMenus(target, dataViewLogic, reopen),
|
||||
items: createSettingMenus(target, dataViewLogic, reopen, () =>
|
||||
handler.close()
|
||||
),
|
||||
})
|
||||
);
|
||||
items.push(
|
||||
@@ -357,6 +469,7 @@ export const popViewOptions = (
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.duplicate();
|
||||
},
|
||||
@@ -364,6 +477,7 @@ export const popViewOptions = (
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
closeOnSelect: false,
|
||||
select: () => {
|
||||
view.delete();
|
||||
},
|
||||
@@ -372,13 +486,22 @@ export const popViewOptions = (
|
||||
],
|
||||
})
|
||||
);
|
||||
popMenu(target, {
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
handler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'View settings',
|
||||
onClose: () => handler.close(),
|
||||
},
|
||||
items,
|
||||
onClose: onClose,
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['bottom-start'] }),
|
||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
||||
shift({ crossAxis: true }),
|
||||
],
|
||||
});
|
||||
handler.menu.menuElement.style.minHeight = '550px';
|
||||
return handler;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user