feat(core): adjust collection rules (#12268)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Trashed page titles are now visually indicated with a strikethrough style in collection editor dialogs.

- **Bug Fixes**
  - Trashed pages are now properly excluded from allowed lists and filtered views.

- **Refactor**
  - Improved filtering logic for collections and page lists, separating user filters from system filters for more consistent results.
  - Enhanced filter configuration options for more flexible and maintainable filtering behavior.

- **Style**
  - Added a new style for displaying trashed items with a strikethrough effect.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-14 07:04:56 +00:00
parent cecf545590
commit fa3b08274c
6 changed files with 159 additions and 81 deletions
@@ -114,21 +114,34 @@ export const SelectPage = ({
useEffect(() => { useEffect(() => {
const subscription = collectionRulesService const subscription = collectionRulesService
.watch([ .watch({
...filters, filters:
{ filters.length > 0
type: 'system', ? filters
key: 'empty-journal', : [
method: 'is', // if no filters are present, match all non-trash documents
value: 'false', {
}, type: 'system',
{ key: 'trash',
type: 'system', method: 'is',
key: 'trash', value: 'false',
method: 'is', },
value: 'false', ],
}, extraFilters: [
]) {
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
})
.subscribe(result => { .subscribe(result => {
setFilteredDocIds(result.groups.flatMap(group => group.items)); setFilteredDocIds(result.groups.flatMap(group => group.items));
}); });
@@ -37,6 +37,9 @@ export const includeItemTitle = style({
overflow: 'hidden', overflow: 'hidden',
fontWeight: 600, fontWeight: 600,
}); });
export const trashTitle = style({
textDecoration: 'line-through',
});
export const includeItemContentIs = style({ export const includeItemContentIs = style({
padding: '0 8px', padding: '0 8px',
color: cssVar('textSecondaryColor'), color: cssVar('textSecondaryColor'),
@@ -49,21 +49,23 @@ export const RulesMode = ({
useEffect(() => { useEffect(() => {
const subscription = collectionRulesService const subscription = collectionRulesService
.watch( .watch({
collection.rules.filters.length > 0 filters: collection.rules.filters,
? [ extraFilters: [
...collection.rules.filters, {
{ type: 'system',
type: 'system', key: 'trash',
key: 'trash', method: 'is',
method: 'is', value: 'false',
value: 'false', },
}, {
] type: 'system',
: [], key: 'empty-journal',
undefined, method: 'is',
undefined value: 'false',
) },
],
})
.subscribe(rules => { .subscribe(rules => {
setRulesPageIds(rules.groups.flatMap(group => group.items)); setRulesPageIds(rules.groups.flatMap(group => group.items));
}); });
@@ -82,7 +84,8 @@ export const RulesMode = ({
return allPageListConfig.allPages.filter(meta => { return allPageListConfig.allPages.filter(meta => {
return ( return (
collection.allowList.includes(meta.id) && collection.allowList.includes(meta.id) &&
!rulesPageIds.includes(meta.id) !rulesPageIds.includes(meta.id) &&
!meta.trash
); );
}); });
}, [allPageListConfig.allPages, collection.allowList, rulesPageIds]); }, [allPageListConfig.allPages, collection.allowList, rulesPageIds]);
@@ -196,6 +199,7 @@ export const RulesMode = ({
<div <div
className={clsx( className={clsx(
styles.includeItemTitle, styles.includeItemTitle,
page?.trash && styles.trashTitle,
styles.ellipsis styles.ellipsis
)} )}
> >
@@ -143,9 +143,22 @@ export const AllPage = () => {
const collectionRulesService = useService(CollectionRulesService); const collectionRulesService = useService(CollectionRulesService);
useEffect(() => { useEffect(() => {
const subscription = collectionRulesService const subscription = collectionRulesService
.watch( .watch({
[ filters:
...(filters ?? []), filters && filters.length > 0
? filters
: [
// if no filters are present, match all non-trash documents
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
groupBy,
orderBy,
extraFilters: [
{ {
type: 'system', type: 'system',
key: 'empty-journal', key: 'empty-journal',
@@ -159,9 +172,7 @@ export const AllPage = () => {
value: 'false', value: 'false',
}, },
], ],
groupBy, })
orderBy
)
.subscribe({ .subscribe({
next: result => { next: result => {
explorerContextValue.groups$.next(result.groups); explorerContextValue.groups$.next(result.groups);
@@ -3,7 +3,6 @@ import {
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
firstValueFrom,
map, map,
type Observable, type Observable,
of, of,
@@ -19,27 +18,51 @@ export class CollectionRulesService extends Service {
super(); super();
} }
watch( watch(options: {
filters: FilterParams[], /**
groupBy?: GroupByParams, * Primary filters
orderBy?: OrderByParams, *
extraAllowList?: string[] * If filters.length === 0, no items will be matched
): Observable<{ */
filters?: FilterParams[];
groupBy?: GroupByParams;
orderBy?: OrderByParams;
/**
* Additional allowed items that bypass primary filters but are still subject to extraFilters
*/
extraAllowList?: string[];
/**
* Additional filters that will be applied after the primary filters and extraAllowList
*
* Useful for applying system-level filters such as trash, empty journal, etc.
*
* Note: If the primary filters match no items, these extraFilters will not be applied.
*/
extraFilters?: FilterParams[];
}): Observable<{
groups: { groups: {
key: string; key: string;
items: string[]; items: string[];
}[]; }[];
filterErrors: any[]; filterErrors: any[];
}> { }> {
const {
filters = [],
groupBy,
orderBy,
extraAllowList,
extraFilters = [],
} = options;
// STEP 1: FILTER // STEP 1: FILTER
const filterProviders = this.framework.getAll(FilterProvider); const filterProviders = this.framework.getAll(FilterProvider);
const filtered$: Observable<{ const primaryFiltered$: Observable<{
filtered: Set<string>; filtered: Set<string>;
filterErrors: any[]; // errors from the filter providers filterErrors: any[]; // errors from the filter providers
}> = }> =
filters.length === 0 filters.length === 0
? of({ ? of({
filtered: new Set<string>(extraAllowList ?? []), filtered: new Set<string>([]),
filterErrors: [], filterErrors: [],
}) })
: combineLatest( : combineLatest(
@@ -75,17 +98,51 @@ export class CollectionRulesService extends Service {
const filtered = const filtered =
'error' in aggregated ? new Set<string>() : aggregated; 'error' in aggregated ? new Set<string>() : aggregated;
const finalSet = filtered.union(
new Set<string>(extraAllowList ?? [])
);
return { return {
filtered: finalSet, filtered: filtered,
filterErrors: results.map(i => ('error' in i ? i.error : null)), filterErrors: results.map(i => ('error' in i ? i.error : null)),
}; };
}) })
); );
const extraFiltered$ =
extraFilters.length === 0
? of(null)
: combineLatest(
extraFilters.map(filter => {
const provider = filterProviders.get(filter.type);
if (!provider) {
throw new Error(`Unsupported filter type: ${filter.type}`);
}
return provider.filter$(filter).pipe(
distinctUntilChanged((prev, curr) => {
return prev.isSubsetOf(curr) && curr.isSubsetOf(prev);
})
);
})
).pipe(
map(results => {
return results.reduce((acc, result) => {
return acc.intersection(result);
});
})
);
const finalFiltered$ = combineLatest([
primaryFiltered$,
extraFiltered$,
]).pipe(
map(([primary, extra]) => ({
filtered:
extra === null
? primary.filtered.union(new Set(extraAllowList ?? []))
: primary.filtered
.union(new Set(extraAllowList ?? []))
.intersection(extra),
filterErrors: primary.filterErrors,
}))
);
// STEP 2: ORDER BY // STEP 2: ORDER BY
const orderByProvider = orderBy const orderByProvider = orderBy
? this.framework.getOptional(OrderByProvider(orderBy.type)) ? this.framework.getOptional(OrderByProvider(orderBy.type))
@@ -94,7 +151,7 @@ export class CollectionRulesService extends Service {
ordered: string[]; ordered: string[];
filtered: Set<string>; filtered: Set<string>;
filterErrors: any[]; filterErrors: any[];
}> = filtered$.pipe(last$ => { }> = finalFiltered$.pipe(last$ => {
if (orderBy && orderByProvider) { if (orderBy && orderByProvider) {
const shared$ = last$.pipe(share()); const shared$ = last$.pipe(share());
const items$ = shared$.pipe( const items$ = shared$.pipe(
@@ -171,7 +228,7 @@ export class CollectionRulesService extends Service {
}[]; }[];
filterErrors: any[]; filterErrors: any[];
}> = grouped$.pipe( }> = grouped$.pipe(
throttleTime(300, undefined, { leading: false, trailing: true }), // throttle the results to avoid too many re-renders throttleTime(300, undefined, { leading: true, trailing: true }), // throttle the results to avoid too many re-renders
map(({ grouped, ordered, filtered, filterErrors }) => { map(({ grouped, ordered, filtered, filterErrors }) => {
const result: { key: string; items: string[] }[] = []; const result: { key: string; items: string[] }[] = [];
@@ -216,15 +273,4 @@ export class CollectionRulesService extends Service {
return final$; return final$;
} }
compute(
filters: FilterParams[],
groupBy?: GroupByParams,
orderBy?: OrderByParams,
extraAllowList?: string[]
) {
return firstValueFrom(
this.watch(filters, groupBy, orderBy, extraAllowList)
);
}
} }
@@ -48,23 +48,24 @@ export class Collection extends Entity<{ id: string }> {
return this.info$.pipe( return this.info$.pipe(
switchMap(info => { switchMap(info => {
return this.rulesService return this.rulesService
.watch( .watch({
info.rules.filters.length > 0 filters: info.rules.filters,
? [ extraAllowList: info.allowList,
...info.rules.filters, extraFilters: [
// if we have more than one filter, we need to add a system filter to exclude trash {
{ type: 'system',
type: 'system', key: 'trash',
key: 'trash', method: 'is',
method: 'is', value: 'false',
value: 'false', },
}, {
] type: 'system',
: [], // If no filters are provided, an empty filter list will match no documents key: 'empty-journal',
undefined, method: 'is',
undefined, value: 'false',
info.allowList },
) ],
})
.pipe(map(result => result.groups.map(group => group.items).flat())); .pipe(map(result => result.groups.map(group => group.items).flat()));
}) })
); );