feat(core): add collection rules module (#11683)

whats changed:

### orm

add a new `select$` method, can subscribe on only one field to improve batch subscribe performance

### yjs-observable

add a new `yjsObservePath` method, which can subscribe to changes from specific path in yjs. Improves batch subscribe performance

```ts
yjsGetPath(
      this.workspaceService.workspace.rootYDoc.getMap('meta'),
      'pages'
    ).pipe(
      switchMap(pages => yjsObservePath(pages, '*.tags')),
      map(pages => {
          // only when tags changed
      })
)
```

### standard property naming

All `DocProperty` components renamed to `WorkspaceProperty` which is consistent with the product definition.

### `WorkspacePropertyService`

Split the workspace property management logic from the `doc` module and create a new `WorkspacePropertyService`. The new service manages the creation and modification of properties, and the `docService` is only responsible for storing the property value data.

### new `<Filters />` component

in `core/component/filter`

### new `<ExplorerDisplayMenuButton />` component

in `core/component/explorer/display-menu`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/c47ab43c-ac53-4ab6-922e-03127d07bef3.png)

### new `/workspace/xxx/all-new` route

New route for test components and functions

### new collection role service

Implemented some filter group order rules

see `collection-rules/index.ts`

### standard property type definition

define type in `modules\workspace-property\types.ts`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/4324453a-4fab-4d1e-83bb-53693e68e87a.png)

define components (name,icon,....) in `components\workspace-property-types\index.ts`

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/93a23947-aaff-480d-a158-dd4075baae17.png)

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

- **New Features**
  - Introduced comprehensive filtering, grouping, and ordering capabilities for workspace documents with reactive updates.
  - Added a new "All Pages" workspace view supporting dynamic filters and display preferences.
  - Developed UI components for filter creation, condition editing, and display menu controls.
  - Launched enhanced tag management with inline editors, selection, creation, and deletion workflows.
  - Added workspace property types with dedicated filter UIs including checkbox, date, tags, and text.
  - Introduced workspace property management replacing document property handling.
  - Added modular providers for filters, group-by, and order-by operations supporting various property types and system attributes.

- **Improvements**
  - Standardized tag and property naming conventions across the application (using `name` instead of `value` or `title`).
  - Migrated document property handling to workspace property-centric logic.
  - Enhanced internationalization with additional filter and display menu labels.
  - Improved styling for filter conditions, display menus, and workspace pages.
  - Optimized reactive data subscriptions and state management for performance.
  - Refined schema typings and type safety for workspace properties.
  - Updated imports and component references to workspace property equivalents throughout frontend.

- **Bug Fixes**
  - Resolved tag property inconsistencies affecting display and filtering.
  - Fixed filter and tag selection behaviors for accurate and reliable UI interactions.

- **Chores**
  - Added and refined test cases for ORM, observables, and filtering logic.
  - Cleaned up legacy document property code and improved type safety.
  - Modularized and restructured components for better maintainability.
  - Introduced new CSS styles for workspace pages and display menus.
  - Added framework module configurations for collection rules and workspace property features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-08 08:38:56 +00:00
parent f9e003d220
commit 8399d99e79
174 changed files with 5644 additions and 980 deletions
@@ -6,10 +6,9 @@ import {
PropertyCollapsibleSection,
} from '@affine/component';
import { BacklinkGroups } from '@affine/core/blocksuite/block-suite-editor/bi-directional-link-panel';
import { CreatePropertyMenuItems } from '@affine/core/components/doc-properties/menu/create-doc-property';
import { DocPropertyRow } from '@affine/core/components/doc-properties/table';
import { CreatePropertyMenuItems } from '@affine/core/components/properties/menu/create-doc-property';
import { WorkspacePropertyRow } from '@affine/core/components/properties/table';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import type {
DatabaseRow,
@@ -17,6 +16,7 @@ import type {
} from '@affine/core/modules/doc-info/types';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
@@ -34,17 +34,18 @@ export const InfoTable = ({
onClose: () => void;
}) => {
const t = useI18n();
const { docsSearchService, docsService, guardService } = useServices({
DocsSearchService,
DocsService,
GuardService,
});
const { docsSearchService, workspacePropertyService, guardService } =
useServices({
DocsSearchService,
WorkspacePropertyService,
GuardService,
});
const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update')
);
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
const properties = useLiveData(workspacePropertyService.sortedProperties$);
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
@@ -136,7 +137,7 @@ export const InfoTable = ({
}
>
{properties.map(property => (
<DocPropertyRow
<WorkspacePropertyRow
key={property.id}
propertyInfo={property}
readonly={!canEditProperty}
@@ -62,7 +62,7 @@ export const TagSelectorDialog = ({
const filteredTagMetas = useMemo(() => {
return tagMetas.filter(tag => {
const reg = new RegExp(keyword, 'i');
return reg.test(tag.title);
return reg.test(tag.name);
});
}, [keyword, tagMetas]);
@@ -3,7 +3,6 @@ import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import type { PageLayoutMode } from '@affine/core/components/doc-properties/types/types';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -19,7 +18,7 @@ export const Page = () => {
const radioItems = useMemo<RadioItem[]>(
() => [
{
value: 'standard' as PageLayoutMode,
value: 'standard',
label:
t[
'com.affine.settings.editorSettings.page.default-page-width.standard'
@@ -27,7 +26,7 @@ export const Page = () => {
testId: 'standard-width-trigger',
},
{
value: 'fullWidth' as PageLayoutMode,
value: 'fullWidth',
label:
t[
'com.affine.settings.editorSettings.page.default-page-width.full-width'
@@ -39,7 +38,7 @@ export const Page = () => {
);
const handleFullWidthLayoutChange = useCallback(
(value: PageLayoutMode) => {
(value: string) => {
const checked = value === 'fullWidth';
editorSetting.set('fullWidthLayout', checked);
},
@@ -1,5 +1,5 @@
import { Button } from '@affine/component';
import { type TagLike, TagsInlineEditor } from '@affine/core/components/tags';
import { WorkspaceTagsInlineEditor } from '@affine/core/components/tags';
import {
IntegrationService,
IntegrationTypeIcon,
@@ -8,7 +8,7 @@ import type { ReadwiseConfig } from '@affine/core/modules/integration/type';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useLiveData, useService } from '@toeverything/infra';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import {
@@ -245,47 +245,21 @@ const TagsSetting = () => {
const t = useI18n();
const tagService = useService(TagService);
const readwise = useService(IntegrationService).readwise;
const allTags = useLiveData(tagService.tagList.tags$);
const tagColors = tagService.tagColors;
const tagMetas = useLiveData(tagService.tagList.tagMetas$);
const tagIds = useLiveData(
useMemo(() => readwise.setting$('tags'), [readwise])
);
const adaptedTags = useLiveData(
useMemo(() => {
return LiveData.computed(get => {
return allTags.map(tag => ({
id: tag.id,
value: get(tag.value$),
color: get(tag.color$),
}));
});
}, [allTags])
);
const adaptedTagColors = useMemo(() => {
return tagColors.map(color => ({
id: color[0],
value: color[1],
name: color[0],
}));
}, [tagColors]);
const updateReadwiseTags = useCallback(
(tagIds: string[]) => {
readwise.updateSetting(
'tags',
tagIds.filter(id => !!allTags.some(tag => tag.id === id))
tagIds.filter(id => !!tagMetas.some(tag => tag.id === id))
);
},
[allTags, readwise]
[tagMetas, readwise]
);
const onCreateTag = useCallback(
(name: string, color: string) => {
const tag = tagService.tagList.createTag(name, color);
return { id: tag.id, value: tag.value$.value, color: tag.color$.value };
},
[tagService.tagList]
);
const onSelectTag = useCallback(
(tagId: string) => {
trackModifySetting('Tag', 'on');
@@ -300,32 +274,12 @@ const TagsSetting = () => {
},
[tagIds, updateReadwiseTags]
);
const onDeleteTag = useCallback(
(tagId: string) => {
if (tagIds?.includes(tagId)) {
trackModifySetting('Tag', 'off');
}
tagService.tagList.deleteTag(tagId);
updateReadwiseTags(tagIds ?? []);
},
[tagIds, updateReadwiseTags, tagService.tagList]
);
const onTagChange = useCallback(
(id: string, property: keyof TagLike, value: string) => {
if (property === 'value') {
tagService.tagList.tagByTagId$(id).value?.rename(value);
} else if (property === 'color') {
tagService.tagList.tagByTagId$(id).value?.changeColor(value);
}
},
[tagService.tagList]
);
return (
<li>
<h6 className={styles.tagsLabel}>
{t['com.affine.integration.readwise.setting.tags-label']()}
</h6>
<TagsInlineEditor
<WorkspaceTagsInlineEditor
placeholder={
<span className={styles.tagsPlaceholder}>
{t['com.affine.integration.readwise.setting.tags-placeholder']()}
@@ -333,14 +287,9 @@ const TagsSetting = () => {
}
className={styles.tagsEditor}
tagMode="inline-tag"
tags={adaptedTags}
selectedTags={tagIds ?? []}
onCreateTag={onCreateTag}
onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onDeleteTag}
modalMenu={true}
menuClassName={styles.tagsMenu}
/>
@@ -1,8 +1,8 @@
import { Button, Menu } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { DocPropertyManager } from '@affine/core/components/doc-properties/manager';
import { CreatePropertyMenuItems } from '@affine/core/components/doc-properties/menu/create-doc-property';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspacePropertyManager } from '@affine/core/components/properties/manager';
import { CreatePropertyMenuItems } from '@affine/core/components/properties/menu/create-doc-property';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { Trans, useI18n } from '@affine/i18n';
@@ -41,7 +41,7 @@ const WorkspaceSettingPropertiesMain = () => {
</Button>
</Menu>
</div>
<DocPropertyManager onPropertyInfoChange={onPropertyInfoChange} />
<WorkspacePropertyManager onPropertyInfoChange={onPropertyInfoChange} />
</div>
);
};
@@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
width: '100%',
paddingBottom: '32px',
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const headerCreateNewCollectionIconButton = style({
padding: '4px 8px',
fontSize: '16px',
width: '32px',
height: '28px',
borderRadius: '8px',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});
@@ -0,0 +1,81 @@
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import type { ExplorerPreference } from '@affine/core/components/explorer/types';
import { Filters } from '@affine/core/components/filter';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import {
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './all-page.css';
export const AllPage = () => {
const t = useI18n();
const [explorerPreference, setExplorerPreference] =
useState<ExplorerPreference>({});
const [groups, setGroups] = useState<any>([]);
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
.watch(
explorerPreference.filters ?? [],
explorerPreference.groupBy,
explorerPreference.orderBy
)
.subscribe({
next: result => {
setGroups(result.groups);
},
error: error => {
console.error(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [collectionRulesService, explorerPreference]);
const handleFilterChange = useCallback((filters: FilterParams[]) => {
setExplorerPreference(prev => ({
...prev,
filters,
}));
}, []);
return (
<>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader></ViewHeader>
<ViewBody>
<div className={styles.body}>
<div>
<Filters
filters={explorerPreference.filters ?? []}
onChange={handleFilterChange}
/>
<ExplorerDisplayMenuButton
preference={explorerPreference}
onChange={setExplorerPreference}
/>
</div>
<pre>{JSON.stringify(explorerPreference, null, 2)}</pre>
<pre>{JSON.stringify(groups, null, 2)}</pre>
</div>
</ViewBody>
<AllDocSidebarTabs />
</>
);
};
export const Component = () => {
return <AllPage />;
};
@@ -7,13 +7,13 @@ import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { GlobalPageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { DocPropertySidebar } from '@affine/core/components/doc-properties/sidebar';
import { useGuard } from '@affine/core/components/guard';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
import { useRegisterBlocksuiteEditorCommands } from '@affine/core/components/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { WorkspacePropertySidebar } from '@affine/core/components/properties/sidebar';
import { TrashPageFooter } from '@affine/core/components/pure/trash-page-footer';
import { TopTip } from '@affine/core/components/top-tip';
import { DocService } from '@affine/core/modules/doc';
@@ -327,7 +327,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
<ViewSidebarTab tabId="properties" icon={<PropertyIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<DocPropertySidebar />
<WorkspacePropertySidebar />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
@@ -5,6 +5,10 @@ export const workbenchRoutes = [
path: '/all',
lazy: () => import('./pages/workspace/all-page/all-page'),
},
{
path: '/all-new',
lazy: () => import('./pages/workspace/all-page-new/all-page'),
},
{
path: '/collection',
lazy: () => import('./pages/workspace/all-collection'),