mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
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`

### 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`

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

<!-- 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:
@@ -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);
|
||||
},
|
||||
|
||||
+6
-57
@@ -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}
|
||||
/>
|
||||
|
||||
+3
-3
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user