diff --git a/packages/common/env/src/filter.ts b/packages/common/env/src/filter.ts index 82e63b1ee6..c92ff6cb9d 100644 --- a/packages/common/env/src/filter.ts +++ b/packages/common/env/src/filter.ts @@ -74,14 +74,4 @@ export type DeleteCollectionInfo = { } | null; export type DeletedCollection = z.input; -export const tagSchema = z.object({ - id: z.string(), - value: z.string(), - color: z.string(), - parentId: z.string().optional(), - createDate: z.union([z.date(), z.number()]).optional(), - updateDate: z.union([z.date(), z.number()]).optional(), -}); -export type Tag = z.input; - export type PropertiesMeta = DocsPropertiesMeta; diff --git a/packages/common/infra/src/orm/core/__tests__/yjs.spec.ts b/packages/common/infra/src/orm/core/__tests__/yjs.spec.ts index 71b2a14b69..ac46f4d472 100644 --- a/packages/common/infra/src/orm/core/__tests__/yjs.spec.ts +++ b/packages/common/infra/src/orm/core/__tests__/yjs.spec.ts @@ -102,6 +102,102 @@ describe('ORM entity CRUD', () => { expect(user2).toEqual(user); }); + test('should be able to select', t => { + const { client } = t; + + client.users.create({ + name: 'u1', + email: 'e1@example.com', + }); + + client.users.create({ + name: 'u2', + }); + + const users = client.users.select('name'); + + expect(users).toStrictEqual([ + { id: expect.any(Number), name: 'u1' }, + { id: expect.any(Number), name: 'u2' }, + ]); + + const user2 = client.users.select('email'); + + expect(user2).toStrictEqual([ + { id: expect.any(Number), email: 'e1@example.com' }, + { id: expect.any(Number), email: undefined }, + ]); + + const user3 = client.users.select('name', { + email: null, + }); + + expect(user3).toStrictEqual([{ id: expect.any(Number), name: 'u2' }]); + }); + + test('should be able to observe select', t => { + const { client } = t; + + const t1 = client.tags.create({ + name: 't1', + color: 'red', + }); + + const t2 = client.tags.create({ + name: 't2', + color: 'blue', + }); + + let currentValue: any; + let callbackCount = 0; + + client.tags.select$('name', { color: 'red' }).subscribe(data => { + currentValue = data; + callbackCount++; + }); + + expect(currentValue).toStrictEqual([ + { id: expect.any(String), name: 't1' }, + ]); + expect(callbackCount).toBe(1); + + const t3 = client.tags.create({ + name: 't3', + color: 'blue', + }); + + expect(currentValue).toStrictEqual([ + { id: expect.any(String), name: 't1' }, + ]); + expect(callbackCount).toBe(1); + + client.tags.update(t1.id, { + name: 't1-updated', + }); + expect(currentValue).toStrictEqual([ + { id: expect.any(String), name: 't1-updated' }, + ]); + expect(callbackCount).toBe(2); + + client.tags.update(t2.id, { + color: 'red', + }); + expect(currentValue).toStrictEqual([ + { id: expect.any(String), name: 't1-updated' }, + { id: expect.any(String), name: 't2' }, + ]); + expect(callbackCount).toBe(3); + + client.tags.delete(t1.id); + expect(currentValue).toStrictEqual([ + { id: expect.any(String), name: 't2' }, + ]); + expect(callbackCount).toBe(4); + + client.tags.delete(t3.id); + expect(callbackCount).toBe(4); + }); + test('should be able to filter with nullable condition', t => { const { client } = t; diff --git a/packages/common/infra/src/orm/core/adapters/yjs/table.ts b/packages/common/infra/src/orm/core/adapters/yjs/table.ts index 72ca0d60a9..3c80b62e15 100644 --- a/packages/common/infra/src/orm/core/adapters/yjs/table.ts +++ b/packages/common/infra/src/orm/core/adapters/yjs/table.ts @@ -6,6 +6,7 @@ import { type Transaction, } from 'yjs'; +import { shallowEqual } from '../../../../utils/shallow-equal'; import { validators } from '../../validators'; import { HookAdapter } from '../mixins'; import type { @@ -133,7 +134,16 @@ export class YjsTableAdapter implements TableAdapter { if (isMatch && isPrevMatched) { const newValue = this.value(record, select); - if (prevMatch !== newValue) { + if ( + !( + prevMatch === newValue || + (!select && // if select is set, we will check the value + select !== '*' && + select !== 'key' && + // skip if the value is the same + shallowEqual(prevMatch, newValue)) + ) + ) { results.set(key, newValue); hasChanged = true; } diff --git a/packages/common/infra/src/orm/core/table.ts b/packages/common/infra/src/orm/core/table.ts index 3d02411d2c..20c42d1634 100644 --- a/packages/common/infra/src/orm/core/table.ts +++ b/packages/common/infra/src/orm/core/table.ts @@ -273,6 +273,62 @@ export class Table { }); } + select>( + selectKey: Key, + where?: FindEntityInput + ): Pick, Key | PrimaryKeyField>[] { + const items = this.adapter.find({ + where: !where + ? undefined + : Object.entries(where) + .map(([field, value]) => ({ + field, + value, + })) + .filter(({ value }) => value !== undefined), + }); + + return items.map(item => { + const { [this.keyField]: key, [selectKey]: selected } = item; + return { + [this.keyField]: key, + [selectKey]: selected, + } as Pick, Key | PrimaryKeyField>; + }); + } + + select$>( + selectKey: Key, + where?: FindEntityInput + ): Observable, Key | PrimaryKeyField>[]> { + return new Observable(subscriber => { + const unsubscribe = this.adapter.observe({ + where: !where + ? undefined + : Object.entries(where) + .map(([field, value]) => ({ + field, + value, + })) + .filter(({ value }) => value !== undefined), + select: [this.keyField, selectKey as string], + callback: data => { + subscriber.next( + data.map(item => { + const { [this.keyField]: key, [selectKey]: selected } = item; + return { + [this.keyField]: key, + [selectKey]: selected, + } as Pick, Key | PrimaryKeyField>; + }) + ); + }, + }); + + return unsubscribe; + }); + } + keys(): PrimaryKeyFieldType[] { return this.adapter.find({ select: 'key', diff --git a/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts b/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts index c5dabb3c99..8fb3b89d7a 100644 --- a/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts +++ b/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from 'vitest'; -import { Doc as YDoc, Map as YMap } from 'yjs'; +import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; -import { yjsObserveByPath } from '../yjs-observable'; +import { yjsGetPath, yjsObservePath } from '../yjs-observable'; describe('yjs observable', () => { test('basic', async () => { const ydoc = new YDoc(); let currentValue: any = false; - yjsObserveByPath(ydoc.getMap('foo'), 'key.subkey').subscribe( + yjsGetPath(ydoc.getMap('foo'), 'key.subkey').subscribe( v => (currentValue = v) ); expect(currentValue).toBe(undefined); @@ -28,4 +28,84 @@ describe('yjs observable', () => { ydoc.getMap('foo').set('key', 'text'); expect(currentValue).toBe(undefined); }); + + test('observe with path', async () => { + const ydoc = new YDoc(); + + /** + * { + * metas: { + * pages: [ + * { + * id: '1', + * title: 'page 1', + * tags: ['tag1', 'tag2'] + * } + * ] + * } + * } + */ + + let currentValue: any = false; + let callbackCount = 0; + + yjsObservePath(ydoc.getMap('metas'), 'pages.*.tags').subscribe(v => { + callbackCount++; + currentValue = (v as any) + .toJSON() + .pages?.map((page: any) => ({ id: page.id, tags: page.tags ?? [] })); + }); + + expect(callbackCount).toBe(1); + + ydoc.getMap('metas').set('pages', new YArray()); + + expect(callbackCount).toBe(2); + expect(currentValue).toStrictEqual([]); + + const pages = ydoc.getMap('metas').get('pages') as YArray; + pages.push([ + new YMap([ + ['id', '1'], + ['title', 'page 1'], + ['tags', YArray.from(['tag1', 'tag2'])], + ]), + ]); + + expect(callbackCount).toBe(3); + expect(currentValue).toStrictEqual([{ id: '1', tags: ['tag1', 'tag2'] }]); + + pages.get(0).set('title', 'page 1*'); + + expect(callbackCount).toBe(3); // no change + + pages.get(0).get('tags').push(['tag3']); + + expect(callbackCount).toBe(4); + expect(currentValue).toStrictEqual([ + { id: '1', tags: ['tag1', 'tag2', 'tag3'] }, + ]); + + ydoc.getMap('metas').set('otherMeta', 'true'); + + expect(callbackCount).toBe(4); // no change + + pages.push([ + new YMap([ + ['id', '2'], + ['title', 'page 2'], + ]), + ]); + + expect(callbackCount).toBe(5); + expect(currentValue).toStrictEqual([ + { id: '1', tags: ['tag1', 'tag2', 'tag3'] }, + { id: '2', tags: [] }, + ]); + + pages.delete(0); + + expect(callbackCount).toBe(6); + expect(currentValue).toStrictEqual([{ id: '2', tags: [] }]); + }); }); diff --git a/packages/common/infra/src/utils/yjs-observable.ts b/packages/common/infra/src/utils/yjs-observable.ts index bb77439dbe..3863eee36e 100644 --- a/packages/common/infra/src/utils/yjs-observable.ts +++ b/packages/common/infra/src/utils/yjs-observable.ts @@ -3,6 +3,9 @@ import { AbstractType as YAbstractType, Array as YArray, Map as YMap, + YArrayEvent, + type YEvent, + YMapEvent, } from 'yjs'; /** @@ -13,7 +16,11 @@ function parsePath(path: string): (string | number)[] { const parts = path.split('.'); return parts.map(part => { if (part.startsWith('[') && part.endsWith(']')) { - const index = parseInt(part.slice(1, -1), 10); + const token = part.slice(1, -1); + if (token === '*') { + return '*'; + } + const index = parseInt(token, 10); if (isNaN(index)) { throw new Error(`index: ${part} is not a number`); } @@ -65,11 +72,11 @@ function _yjsDeepWatch( * this function is optimized for deep watch performance. * * @example - * yjsObserveByPath(yjs, 'pages.[0].id') -> only emit when pages[0].id changed - * yjsObserveByPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> emit when any of pages[0] or its children changed - * yjsObserveByPath(yjs, 'pages.[0]').switchMap(yjsObserveDeep) -> emit when pages[0] or any of its deep children changed + * yjsGetPath(yjs, 'pages.[0].id') -> get pages[0].id and emit when changed + * yjsGetPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> get pages[0] and emit when any of pages[0] or its children changed + * yjsGetPath(yjs, 'pages.[0]').switchMap(yjsObserveDeep) -> get pages[0] and emit when pages[0] or any of its deep children changed */ -export function yjsObserveByPath(yjs: YAbstractType, path: string) { +export function yjsGetPath(yjs: YAbstractType, path: string) { const parsedPath = parsePath(path); return _yjsDeepWatch(yjs, parsedPath); } @@ -97,6 +104,79 @@ export function yjsObserveDeep(yjs?: any) { }); } +/** + * convert yjs type to observable. + * observable will automatically update when data changed on the path. + * + * @example + * yjsObservePath(yjs, 'pages.[0].id') -> only emit when pages[0].id changed + * yjsObservePath(yjs, 'pages.*.tags') -> only emit when tags of any page changed + */ +export function yjsObservePath(yjs?: any, path?: string) { + const parsedPath = path ? parsePath(path) : []; + + return new Observable(subscriber => { + const refresh = (event?: YEvent[]) => { + if (!event) { + subscriber.next(yjs); + return; + } + + const changedPaths: (string | number)[][] = []; + event.forEach(e => { + if (e instanceof YArrayEvent) { + changedPaths.push(e.path); + } else if (e instanceof YMapEvent) { + for (const key of e.keysChanged) { + changedPaths.push([...e.path, key]); + } + } + }); + + for (const changedPath of changedPaths) { + let changed = true; + for (let i = 0; i < parsedPath.length; i++) { + const changedKey = changedPath[i]; + const parsedKey = parsedPath[i]; + if (changedKey === undefined) { + changed = true; + break; + } + + if (parsedKey === undefined) { + changed = true; + break; + } + + if (changedKey === parsedKey) { + continue; + } + + if (parsedKey === '*') { + continue; + } + + changed = false; + break; + } + + if (changed) { + subscriber.next(yjs); + return; + } + } + }; + refresh(); + if (yjs instanceof YAbstractType) { + yjs.observeDeep(refresh); + return () => { + yjs.unobserveDeep(refresh); + }; + } + return; + }); +} + /** * convert yjs type to observable. * observable will automatically update when yjs data changed. diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index 57c162aa0c..d1af0ae38f 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -24,7 +24,7 @@ export type InputProps = { endFix?: ReactNode; type?: HTMLInputElement['type']; inputStyle?: CSSProperties; - onEnter?: () => void; + onEnter?: (value: string) => void; } & Omit, 'onChange' | 'size' | 'onBlur'>; export const Input = forwardRef(function Input( diff --git a/packages/frontend/component/src/ui/input/row-input.tsx b/packages/frontend/component/src/ui/input/row-input.tsx index 6b6da253ad..93c84b96fc 100644 --- a/packages/frontend/component/src/ui/input/row-input.tsx +++ b/packages/frontend/component/src/ui/input/row-input.tsx @@ -19,7 +19,7 @@ export type RowInputProps = { autoSelect?: boolean; type?: HTMLInputElement['type']; style?: CSSProperties; - onEnter?: () => void; + onEnter?: (value: string) => void; [key: `data-${string}`]: string; } & Omit, 'onChange' | 'size' | 'onBlur'>; @@ -84,7 +84,7 @@ export const RowInput = forwardRef( if (e.key !== 'Enter' || composing) { return; } - onEnter?.(); + onEnter?.(e.currentTarget.value); }, [onKeyDown, composing, onEnter] ); diff --git a/packages/frontend/component/src/ui/property/property.tsx b/packages/frontend/component/src/ui/property/property.tsx index ba65f5b9de..aa20effb70 100644 --- a/packages/frontend/component/src/ui/property/property.tsx +++ b/packages/frontend/component/src/ui/property/property.tsx @@ -292,7 +292,7 @@ export const PropertyName = ({ name?: ReactNode; menuItems?: ReactNode; defaultOpenMenu?: boolean; -} & HTMLProps) => { +} & Omit, 'name'>) => { const [menuOpen, setMenuOpen] = useState(defaultOpenMenu); const hasMenu = !!menuItems; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/tag-chip.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/tag-chip.ts index 6fba7507a7..ccb8a21448 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/tag-chip.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/tag-chip.ts @@ -37,9 +37,9 @@ export class ChatPanelTagChip extends SignalWatcher( override render() { const { state } = this.chip; - const { title, color } = this.tag; + const { name, color } = this.tag; const isLoading = state === 'processing'; - const tooltip = getChipTooltip(state, title, this.chip.tooltip); + const tooltip = getChipTooltip(state, name, this.chip.tooltip); const tagIcon = html`
@@ -49,7 +49,7 @@ export class ChatPanelTagChip extends SignalWatcher( return html` - void; -} - -export const TagsInlineEditor = ({ - pageId, - readonly, - placeholder, - className, - onChange, -}: TagsInlineEditorProps) => { - const workspace = useService(WorkspaceService); - const tagService = useService(TagService); - const tagIds$ = tagService.tagList.tagIdsByPageId$(pageId); - const tagIds = useLiveData(tagIds$); - const tags = useLiveData(tagService.tagList.tags$); - const tagColors = tagService.tagColors; - - const onCreateTag = useCallback( - (name: string, color: string) => { - const newTag = tagService.tagList.createTag(name, color); - return { - id: newTag.id, - value: newTag.value$.value, - color: newTag.color$.value, - }; - }, - [tagService.tagList] - ); - - const onSelectTag = useCallback( - (tagId: string) => { - tagService.tagList.tagByTagId$(tagId).value?.tag(pageId); - onChange?.(tagIds$.value); - }, - [onChange, pageId, tagIds$, tagService.tagList] - ); - - const onDeselectTag = useCallback( - (tagId: string) => { - tagService.tagList.tagByTagId$(tagId).value?.untag(pageId); - onChange?.(tagIds$.value); - }, - [onChange, pageId, tagIds$, 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); - } - onChange?.(tagIds$.value); - }, - [onChange, tagIds$, tagService.tagList] - ); - - const deleteTags = useDeleteTagConfirmModal(); - - const onTagDelete = useAsyncCallback( - async (id: string) => { - await deleteTags([id]); - onChange?.(tagIds$.value); - }, - [onChange, tagIds$, deleteTags] - ); - - const adaptedTags = useLiveData( - useMemo(() => { - return LiveData.computed(get => { - return tags.map(tag => ({ - id: tag.id, - value: get(tag.value$), - color: get(tag.color$), - })); - }); - }, [tags]) - ); - - const adaptedTagColors = useMemo(() => { - return tagColors.map(color => ({ - id: color[0], - value: color[1], - name: color[0], - })); - }, [tagColors]); - - const navigator = useNavigateHelper(); - - const jumpToTag = useCallback( - (id: string) => { - navigator.jumpToTag(workspace.workspace.id, id); - }, - [navigator, workspace.workspace.id] - ); - - const t = useI18n(); - - return ( - - - {t['Tags']()} - - } - /> - ); -}; diff --git a/packages/frontend/core/src/components/doc-properties/types/checkbox.tsx b/packages/frontend/core/src/components/doc-properties/types/checkbox.tsx deleted file mode 100644 index 8022d2642d..0000000000 --- a/packages/frontend/core/src/components/doc-properties/types/checkbox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Checkbox, PropertyValue } from '@affine/component'; -import { useCallback } from 'react'; - -import * as styles from './checkbox.css'; -import type { PropertyValueProps } from './types'; - -export const CheckboxValue = ({ - value, - onChange, - readonly, -}: PropertyValueProps) => { - const parsedValue = value === 'true' ? true : false; - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (readonly) { - return; - } - onChange(parsedValue ? 'false' : 'true'); - }, - [onChange, parsedValue, readonly] - ); - return ( - - {}} - disabled={readonly} - /> - - ); -}; diff --git a/packages/frontend/core/src/components/doc-properties/types/constant.tsx b/packages/frontend/core/src/components/doc-properties/types/constant.tsx deleted file mode 100644 index 68c00eec54..0000000000 --- a/packages/frontend/core/src/components/doc-properties/types/constant.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { I18nString } from '@affine/i18n'; -import { - CheckBoxCheckLinearIcon, - DateTimeIcon, - EdgelessIcon, - FileIcon, - HistoryIcon, - LongerIcon, - MemberIcon, - NumberIcon, - TagIcon, - TemplateIcon, - TextIcon, - TodayIcon, -} from '@blocksuite/icons/rc'; - -import { CheckboxValue } from './checkbox'; -import { CreatedByValue, UpdatedByValue } from './created-updated-by'; -import { CreateDateValue, DateValue, UpdatedDateValue } from './date'; -import { DocPrimaryModeValue } from './doc-primary-mode'; -import { EdgelessThemeValue } from './edgeless-theme'; -import { JournalValue } from './journal'; -import { NumberValue } from './number'; -import { PageWidthValue } from './page-width'; -import { TagsValue } from './tags'; -import { TemplateValue } from './template'; -import { TextValue } from './text'; -import type { PropertyValueProps } from './types'; - -export const DocPropertyTypes = { - tags: { - icon: TagIcon, - value: TagsValue, - name: 'com.affine.page-properties.property.tags', - uniqueId: 'tags', - renameable: false, - description: 'com.affine.page-properties.property.tags.tooltips', - }, - text: { - icon: TextIcon, - value: TextValue, - name: 'com.affine.page-properties.property.text', - description: 'com.affine.page-properties.property.text.tooltips', - }, - number: { - icon: NumberIcon, - value: NumberValue, - name: 'com.affine.page-properties.property.number', - description: 'com.affine.page-properties.property.number.tooltips', - }, - checkbox: { - icon: CheckBoxCheckLinearIcon, - value: CheckboxValue, - name: 'com.affine.page-properties.property.checkbox', - description: 'com.affine.page-properties.property.checkbox.tooltips', - }, - date: { - icon: DateTimeIcon, - value: DateValue, - name: 'com.affine.page-properties.property.date', - description: 'com.affine.page-properties.property.date.tooltips', - }, - createdBy: { - icon: MemberIcon, - value: CreatedByValue, - name: 'com.affine.page-properties.property.createdBy', - description: 'com.affine.page-properties.property.createdBy.tooltips', - }, - updatedBy: { - icon: MemberIcon, - value: UpdatedByValue, - name: 'com.affine.page-properties.property.updatedBy', - description: 'com.affine.page-properties.property.updatedBy.tooltips', - }, - updatedAt: { - icon: DateTimeIcon, - value: UpdatedDateValue, - name: 'com.affine.page-properties.property.updatedAt', - description: 'com.affine.page-properties.property.updatedAt.tooltips', - renameable: false, - }, - createdAt: { - icon: HistoryIcon, - value: CreateDateValue, - name: 'com.affine.page-properties.property.createdAt', - description: 'com.affine.page-properties.property.createdAt.tooltips', - renameable: false, - }, - docPrimaryMode: { - icon: FileIcon, - value: DocPrimaryModeValue, - name: 'com.affine.page-properties.property.docPrimaryMode', - description: 'com.affine.page-properties.property.docPrimaryMode.tooltips', - }, - journal: { - icon: TodayIcon, - value: JournalValue, - name: 'com.affine.page-properties.property.journal', - description: 'com.affine.page-properties.property.journal.tooltips', - }, - edgelessTheme: { - icon: EdgelessIcon, - value: EdgelessThemeValue, - name: 'com.affine.page-properties.property.edgelessTheme', - description: 'com.affine.page-properties.property.edgelessTheme.tooltips', - }, - pageWidth: { - icon: LongerIcon, - value: PageWidthValue, - name: 'com.affine.page-properties.property.pageWidth', - description: 'com.affine.page-properties.property.pageWidth.tooltips', - }, - template: { - icon: TemplateIcon, - value: TemplateValue, - name: 'com.affine.page-properties.property.template', - renameable: true, - description: 'com.affine.page-properties.property.template.tooltips', - }, -} as Record< - string, - { - icon: React.FC>; - value?: React.FC; - /** - * set a unique id for property type, make the property type can only be created once. - */ - uniqueId?: string; - name: I18nString; - renameable?: boolean; - description?: I18nString; - } ->; - -export const isSupportedDocPropertyType = (type?: string): boolean => { - return type ? type in DocPropertyTypes : false; -}; diff --git a/packages/frontend/core/src/components/doc-properties/types/tags.tsx b/packages/frontend/core/src/components/doc-properties/types/tags.tsx deleted file mode 100644 index aabe313b36..0000000000 --- a/packages/frontend/core/src/components/doc-properties/types/tags.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { PropertyValue } from '@affine/component'; -import { DocService } from '@affine/core/modules/doc'; -import { TagService } from '@affine/core/modules/tag'; -import { useI18n } from '@affine/i18n'; -import { useLiveData, useService } from '@toeverything/infra'; - -import { TagsInlineEditor } from '../tags-inline-editor'; -import * as styles from './tags.css'; -import type { PropertyValueProps } from './types'; - -export const TagsValue = ({ readonly }: PropertyValueProps) => { - const t = useI18n(); - - const doc = useService(DocService).doc; - - const tagList = useService(TagService).tagList; - const tagIds = useLiveData(tagList.tagIdsByPageId$(doc.id)); - const empty = !tagIds || tagIds.length === 0; - - return ( - - {}} - readonly={readonly} - /> - - ); -}; diff --git a/packages/frontend/core/src/components/explorer/display-menu/group.tsx b/packages/frontend/core/src/components/explorer/display-menu/group.tsx new file mode 100644 index 0000000000..2cf9f2b2e5 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/group.tsx @@ -0,0 +1,85 @@ +import { MenuItem } from '@affine/component'; +import type { GroupByParams } from '@affine/core/modules/collection-rules/types'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { useI18n } from '@affine/i18n'; +import { DoneIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; + +import { WorkspacePropertyName } from '../../properties'; +import { + isSupportedSystemPropertyType, + SystemPropertyTypes, +} from '../../system-property-types'; +import { + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; + +const PropertyGroupByName = ({ groupBy }: { groupBy: GroupByParams }) => { + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyInfo = useLiveData( + workspacePropertyService.propertyInfo$(groupBy.key) + ); + + return propertyInfo ? ( + + ) : null; +}; + +export const GroupByName = ({ groupBy }: { groupBy: GroupByParams }) => { + const t = useI18n(); + if (groupBy.type === 'property') { + return ; + } + if (groupBy.type === 'system') { + const type = isSupportedSystemPropertyType(groupBy.key) + ? SystemPropertyTypes[groupBy.key] + : null; + return type ? t.t(type.name) : null; + } + return null; +}; + +export const GroupByList = ({ + groupBy, + onChange, +}: { + groupBy?: GroupByParams; + onChange?: (next: GroupByParams) => void; +}) => { + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyList = useLiveData(workspacePropertyService.properties$); + + return ( + <> + {propertyList.map(v => { + const allowInGroupBy = isSupportedWorkspacePropertyType(v.type) + ? WorkspacePropertyTypes[v.type].allowInGroupBy + : false; + if (!allowInGroupBy) { + return null; + } + return ( + { + e.preventDefault(); + onChange?.({ + type: 'property', + key: v.id, + }); + }} + suffixIcon={ + groupBy?.type === 'property' && groupBy?.key === v.id ? ( + + ) : null + } + > + + + ); + })} + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/display-menu/index.tsx b/packages/frontend/core/src/components/explorer/display-menu/index.tsx new file mode 100644 index 0000000000..3ca159b0fd --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/index.tsx @@ -0,0 +1,112 @@ +import { Button, Menu, MenuSub } from '@affine/component'; +import type { + GroupByParams, + OrderByParams, +} from '@affine/core/modules/collection-rules/types'; +import { useI18n } from '@affine/i18n'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/rc'; +import type React from 'react'; +import { useCallback } from 'react'; + +import type { ExplorerPreference } from '../types'; +import { GroupByList, GroupByName } from './group'; +import { OrderByList, OrderByName } from './order'; +import * as styles from './styles.css'; + +const ExplorerDisplayMenu = ({ + preference, + onChange, +}: { + preference: ExplorerPreference; + onChange?: (preference: ExplorerPreference) => void; +}) => { + const t = useI18n(); + + const handleGroupByChange = useCallback( + (groupBy: GroupByParams) => { + onChange?.({ + ...preference, + groupBy, + }); + }, + [onChange, preference] + ); + + const handleOrderByChange = useCallback( + (orderBy: OrderByParams) => { + onChange?.({ + ...preference, + orderBy, + }); + }, + [onChange, preference] + ); + + return ( +
+ + } + > +
+ {t['com.affine.explorer.display-menu.grouping']()} + + {preference.groupBy ? ( + + ) : null} + +
+
+ + } + > +
+ {t['com.affine.explorer.display-menu.ordering']()} + + {preference.orderBy ? ( + + ) : null} + +
+
+
+ ); +}; + +export const ExplorerDisplayMenuButton = ({ + style, + className, + preference, + onChange, +}: { + style?: React.CSSProperties; + className?: string; + preference: ExplorerPreference; + onChange?: (preference: ExplorerPreference) => void; +}) => { + const t = useI18n(); + return ( + + } + > + + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/display-menu/order.tsx b/packages/frontend/core/src/components/explorer/display-menu/order.tsx new file mode 100644 index 0000000000..bc4d4ddf66 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/order.tsx @@ -0,0 +1,91 @@ +import { MenuItem } from '@affine/component'; +import type { OrderByParams } from '@affine/core/modules/collection-rules/types'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { useI18n } from '@affine/i18n'; +import { SortDownIcon, SortUpIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; + +import { WorkspacePropertyName } from '../../properties'; +import { + isSupportedSystemPropertyType, + SystemPropertyTypes, +} from '../../system-property-types'; +import { + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; + +const PropertyOrderByName = ({ orderBy }: { orderBy: OrderByParams }) => { + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyInfo = useLiveData( + workspacePropertyService.propertyInfo$(orderBy.key) + ); + + return propertyInfo ? ( + + ) : null; +}; + +export const OrderByName = ({ orderBy }: { orderBy: OrderByParams }) => { + const t = useI18n(); + if (orderBy.type === 'property') { + return ; + } + if (orderBy.type === 'system') { + const type = isSupportedSystemPropertyType(orderBy.key) + ? SystemPropertyTypes[orderBy.key] + : null; + return type ? t.t(type.name) : null; + } + return null; +}; + +export const OrderByList = ({ + orderBy, + onChange, +}: { + orderBy?: OrderByParams; + onChange?: (next: OrderByParams) => void; +}) => { + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyList = useLiveData(workspacePropertyService.properties$); + + return ( + <> + {propertyList.map(v => { + const allowInOrderBy = isSupportedWorkspacePropertyType(v.type) + ? WorkspacePropertyTypes[v.type].allowInOrderBy + : false; + const active = orderBy?.type === 'property' && orderBy?.key === v.id; + if (!allowInOrderBy) { + return null; + } + return ( + { + e.preventDefault(); + onChange?.({ + type: 'property', + key: v.id, + desc: !active ? false : !orderBy.desc, + }); + }} + suffixIcon={ + active ? ( + !orderBy.desc ? ( + + ) : ( + + ) + ) : null + } + > + + + ); + })} + + ); +}; diff --git a/packages/frontend/core/src/components/explorer/display-menu/styles.css.ts b/packages/frontend/core/src/components/explorer/display-menu/styles.css.ts new file mode 100644 index 0000000000..0b28e2e675 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/display-menu/styles.css.ts @@ -0,0 +1,15 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const displayMenuContainer = style({ + width: '280px', +}); + +export const subMenuSelectorContainer = style({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const subMenuSelectorSelected = style({ + color: cssVarV2('text/secondary'), +}); diff --git a/packages/frontend/core/src/components/explorer/types.ts b/packages/frontend/core/src/components/explorer/types.ts new file mode 100644 index 0000000000..51604c45d6 --- /dev/null +++ b/packages/frontend/core/src/components/explorer/types.ts @@ -0,0 +1,11 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import type { + GroupByParams, + OrderByParams, +} from '@affine/core/modules/collection-rules/types'; + +export interface ExplorerPreference { + filters?: FilterParams[]; + groupBy?: GroupByParams; + orderBy?: OrderByParams; +} diff --git a/packages/frontend/core/src/components/filter/add-filter.tsx b/packages/frontend/core/src/components/filter/add-filter.tsx new file mode 100644 index 0000000000..d9438dd93a --- /dev/null +++ b/packages/frontend/core/src/components/filter/add-filter.tsx @@ -0,0 +1,77 @@ +import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; + +import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties'; +import { WorkspacePropertyTypes } from '../workspace-property-types'; +import * as styles from './styles.css'; + +export const AddFilterMenu = ({ + onAdd, +}: { + onAdd: (params: FilterParams) => void; +}) => { + const t = useI18n(); + const workspacePropertyService = useService(WorkspacePropertyService); + const workspaceProperties = useLiveData(workspacePropertyService.properties$); + + return ( + <> +
+ {t['com.affine.filter']()} +
+ + {workspaceProperties.map(property => { + const type = WorkspacePropertyTypes[property.type]; + const defaultFilter = type?.defaultFilter; + if (!defaultFilter) { + return null; + } + return ( + + } + key={property.id} + onClick={() => { + onAdd({ + type: 'property', + key: property.id, + ...defaultFilter, + }); + }} + > + + + + + ); + })} + + ); +}; + +export const AddFilter = ({ + onAdd, +}: { + onAdd: (params: FilterParams) => void; +}) => { + return ( + } + contentOptions={{ + className: styles.addFilterMenuContent, + }} + > + + + + + ); +}; diff --git a/packages/frontend/core/src/components/filter/conditions/condition.tsx b/packages/frontend/core/src/components/filter/conditions/condition.tsx new file mode 100644 index 0000000000..ccd5dfad91 --- /dev/null +++ b/packages/frontend/core/src/components/filter/conditions/condition.tsx @@ -0,0 +1,65 @@ +import { Menu, MenuItem } from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import clsx from 'clsx'; +import type React from 'react'; + +import * as styles from './styles.css'; + +export const Condition = ({ + filter, + icon, + name, + methods, + onChange, + value, +}: { + filter: FilterParams; + icon?: React.ReactNode; + name: React.ReactNode; + methods?: [string, React.ReactNode][]; + onChange?: (filter: FilterParams) => void; + value?: React.ReactNode; +}) => { + return ( + <> +
+ {icon &&
{icon}
} + {name} +
+ {methods && ( + ( + { + onChange?.({ + ...filter, + method, + }); + }} + selected={filter.method === method} + key={method} + > + {name} + + ))} + > +
+ {methods.find(([method]) => method === filter.method)?.[1] ?? + 'unknown'} +
+
+ )} + {value && ( +
+ {value} +
+ )} + + ); +}; diff --git a/packages/frontend/core/src/components/filter/conditions/property.tsx b/packages/frontend/core/src/components/filter/conditions/property.tsx new file mode 100644 index 0000000000..ae72dcdb9f --- /dev/null +++ b/packages/frontend/core/src/components/filter/conditions/property.tsx @@ -0,0 +1,53 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; + +import { WorkspacePropertyIcon, WorkspacePropertyName } from '../../properties'; +import { + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; +import { Condition } from './condition'; +import { UnknownFilterCondition } from './unknown'; + +export const PropertyFilterCondition = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + const workspacePropertyService = useService(WorkspacePropertyService); + const propertyInfo = useLiveData( + workspacePropertyService.propertyInfo$(filter.key) + ); + + const propertyType = propertyInfo?.type; + + const type = isSupportedWorkspacePropertyType(propertyType) + ? WorkspacePropertyTypes[propertyType] + : undefined; + + const methods = type?.filterMethod; + const Value = type?.filterValue; + + if (!propertyInfo || !type || !methods) { + return ; + } + + return ( + } + name={} + methods={Object.entries(methods).map(([key, i18nKey]) => [ + key, + t.t(i18nKey as string), + ])} + value={Value && } + onChange={onChange} + /> + ); +}; diff --git a/packages/frontend/core/src/components/filter/conditions/styles.css.ts b/packages/frontend/core/src/components/filter/conditions/styles.css.ts new file mode 100644 index 0000000000..3e3e9a18f3 --- /dev/null +++ b/packages/frontend/core/src/components/filter/conditions/styles.css.ts @@ -0,0 +1,72 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const filterTypeStyle = style({ + fontSize: cssVar('fontSm'), + display: 'flex', + alignItems: 'center', + padding: '0px 4px', + lineHeight: '22px', + color: cssVar('textPrimaryColor'), +}); + +export const filterValueStyle = style({ + fontSize: cssVar('fontSm'), + display: 'flex', + alignItems: 'center', + padding: '0px 4px', + lineHeight: '22px', + height: '22px', + color: cssVar('textPrimaryColor'), + selectors: { + '&:has(>:hover)': { + cursor: 'pointer', + background: cssVar('hoverColor'), + borderRadius: '4px', + }, + }, +}); + +export const filterValueEmptyStyle = style({ + color: cssVarV2('text/placeholder'), +}); + +export const ellipsisTextStyle = style({ + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', +}); + +export const filterTypeIconStyle = style({ + fontSize: '18px', + marginRight: '6px', + padding: '1px 0', + display: 'flex', + alignItems: 'center', + color: cssVar('iconColor'), +}); + +export const filterTypeIconUnknownStyle = style({ + color: cssVarV2('status/error'), +}); + +export const filterTypeUnknownNameStyle = style({ + color: cssVarV2('text/disable'), +}); + +export const switchStyle = style({ + fontSize: cssVar('fontSm'), + color: cssVar('textSecondaryColor'), + padding: '0px 4px', + lineHeight: '22px', + transition: 'background 0.15s ease-in-out', + display: 'flex', + alignItems: 'center', + minWidth: '18px', + ':hover': { + cursor: 'pointer', + background: cssVar('hoverColor'), + borderRadius: '4px', + }, +}); diff --git a/packages/frontend/core/src/components/filter/conditions/system.tsx b/packages/frontend/core/src/components/filter/conditions/system.tsx new file mode 100644 index 0000000000..122a4ad4f1 --- /dev/null +++ b/packages/frontend/core/src/components/filter/conditions/system.tsx @@ -0,0 +1,43 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { useI18n } from '@affine/i18n'; + +import { + isSupportedSystemPropertyType, + SystemPropertyTypes, +} from '../../system-property-types'; +import { Condition } from './condition'; +import { UnknownFilterCondition } from './unknown'; + +export const SystemFilterCondition = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + const type = isSupportedSystemPropertyType(filter.key) + ? SystemPropertyTypes[filter.key] + : undefined; + + if (!type) { + return ; + } + + const methods = type.filterMethod; + const Value = type.filterValue; + + return ( + } + name={t.t(type.name)} + methods={Object.entries(methods).map(([key, i18nKey]) => [ + key, + t.t(i18nKey as string), + ])} + value={Value && } + onChange={onChange} + /> + ); +}; diff --git a/packages/frontend/core/src/components/filter/conditions/unknown.tsx b/packages/frontend/core/src/components/filter/conditions/unknown.tsx new file mode 100644 index 0000000000..6fffcd820f --- /dev/null +++ b/packages/frontend/core/src/components/filter/conditions/unknown.tsx @@ -0,0 +1,19 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { WarningIcon } from '@blocksuite/icons/rc'; + +import { Condition } from './condition'; +import * as styles from './styles.css'; + +export const UnknownFilterCondition = ({ + filter, +}: { + filter: FilterParams; +}) => { + return ( + } + name={Unknown} + /> + ); +}; diff --git a/packages/frontend/core/src/components/filter/filter.tsx b/packages/frontend/core/src/components/filter/filter.tsx new file mode 100644 index 0000000000..b676cd2540 --- /dev/null +++ b/packages/frontend/core/src/components/filter/filter.tsx @@ -0,0 +1,30 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { CloseIcon } from '@blocksuite/icons/rc'; + +import { PropertyFilterCondition } from './conditions/property'; +import { SystemFilterCondition } from './conditions/system'; +import * as styles from './styles.css'; + +export const Filter = ({ + filter, + onDelete, + onChange, +}: { + filter: FilterParams; + onDelete: () => void; + onChange: (filter: FilterParams) => void; +}) => { + const type = filter.type; + return ( +
+ {type === 'property' ? ( + + ) : type === 'system' ? ( + + ) : null} +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/filter/filters.tsx b/packages/frontend/core/src/components/filter/filters.tsx new file mode 100644 index 0000000000..20f80a5630 --- /dev/null +++ b/packages/frontend/core/src/components/filter/filters.tsx @@ -0,0 +1,46 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; + +import { AddFilter } from './add-filter'; +import { Filter } from './filter'; +import * as styles from './styles.css'; + +export const Filters = ({ + filters, + onChange, +}: { + filters: FilterParams[]; + onChange?: (filters: FilterParams[]) => void; +}) => { + const handleDelete = (index: number) => { + onChange?.(filters.filter((_, i) => i !== index)); + }; + + const handleChange = (index: number, filter: FilterParams) => { + onChange?.(filters.map((f, i) => (i === index ? filter : f))); + }; + + return ( +
+ {filters.map((filter, index) => { + return ( + { + handleDelete(index); + }} + onChange={filter => { + handleChange(index, filter); + }} + /> + ); + })} + { + onChange?.(filters.concat(filter)); + }} + /> +
+ ); +}; diff --git a/packages/frontend/core/src/components/filter/index.ts b/packages/frontend/core/src/components/filter/index.ts new file mode 100644 index 0000000000..428d6ee4d4 --- /dev/null +++ b/packages/frontend/core/src/components/filter/index.ts @@ -0,0 +1,2 @@ +export * from './filter'; +export * from './filters'; diff --git a/packages/frontend/core/src/components/filter/styles.css.ts b/packages/frontend/core/src/components/filter/styles.css.ts new file mode 100644 index 0000000000..879a11974b --- /dev/null +++ b/packages/frontend/core/src/components/filter/styles.css.ts @@ -0,0 +1,53 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + flexWrap: 'wrap', + gap: 10, + alignItems: 'center', +}); + +export const filterItemStyle = style({ + display: 'flex', + border: `1px solid ${cssVar('borderColor')}`, + borderRadius: '8px', + background: cssVar('white'), + padding: '4px 8px', + gap: '4px', + height: '32px', + overflow: 'hidden', + justifyContent: 'space-between', + userSelect: 'none', + alignItems: 'center', +}); + +export const filterItemCloseStyle = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + marginLeft: '4px', +}); + +export const variableSelectTitleStyle = style({ + margin: '2px 12px', + fontWeight: 500, + lineHeight: '22px', + fontSize: cssVar('fontSm'), + color: cssVar('textPrimaryColor'), +}); + +export const filterTypeItemIcon = style({ + fontSize: '20px', + color: cssVar('iconColor'), +}); + +export const filterTypeItemName = style({ + fontSize: cssVar('fontSm'), + color: cssVar('textPrimaryColor'), +}); + +export const addFilterMenuContent = style({ + width: '230px', +}); diff --git a/packages/frontend/core/src/components/member-selector/index.tsx b/packages/frontend/core/src/components/member-selector/index.tsx new file mode 100644 index 0000000000..33762598be --- /dev/null +++ b/packages/frontend/core/src/components/member-selector/index.tsx @@ -0,0 +1,342 @@ +import { Avatar, Divider, Menu, RowInput, Scrollable } from '@affine/component'; +import { + type Member, + MemberSearchService, +} from '@affine/core/modules/permissions'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { clamp, debounce } from 'lodash-es'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { ConfigModal } from '../mobile'; +import { InlineMemberList } from './inline-member-list'; +import * as styles from './styles.css'; + +export interface MemberSelectorProps { + selected: string[]; + style?: React.CSSProperties; + className?: string; + onChange: (selected: string[]) => void; +} + +export interface MemberSelectorInlineProps extends MemberSelectorProps { + modalMenu?: boolean; + menuClassName?: string; + readonly?: boolean; + title?: ReactNode; // only used for mobile + placeholder?: ReactNode; +} + +interface MemberSelectItemProps { + member: Member; + style?: React.CSSProperties; +} + +const MemberSelectItem = ({ member, style }: MemberSelectItemProps) => { + const { name, avatarUrl } = member; + + return ( +
+ +
{name}
+
+ ); +}; + +export const MemberSelector = ({ + selected, + className, + onChange, + style, +}: MemberSelectorProps) => { + const [inputValue, setInputValue] = useState(''); + const memberSearchService = useService(MemberSearchService); + + const searchedMembers = useLiveData(memberSearchService.result$); + + useEffect(() => { + // reset the search text when the component is mounted + memberSearchService.reset(); + memberSearchService.loadMore(); + }, [memberSearchService]); + + const debouncedSearch = useMemo( + () => debounce((value: string) => memberSearchService.search(value), 300), + [memberSearchService] + ); + + const inputRef = useRef(null); + + const [focusedIndex, setFocusedIndex] = useState(-1); + const [focusedInlineIndex, setFocusedInlineIndex] = useState(-1); + + // -1: no focus + const safeFocusedIndex = clamp(focusedIndex, -1, searchedMembers.length - 1); + // inline tags focus index can go beyond the length of tagIds + // using -1 and tagIds.length to make keyboard navigation easier + const safeInlineFocusedIndex = clamp(focusedInlineIndex, -1, selected.length); + + const scrollContainerRef = useRef(null); + + const onInputChange = useCallback( + (value: string) => { + setInputValue(value); + if (value.length > 0) { + setFocusedInlineIndex(selected.length); + } + console.log('onInputChange', value); + debouncedSearch(value.trim()); + }, + [debouncedSearch, selected.length] + ); + + const onToggleMember = useCallback( + (id: string) => { + if (!selected.includes(id)) { + onChange([...selected, id]); + } else { + onChange(selected.filter(itemId => itemId !== id)); + } + }, + [selected, onChange] + ); + + const focusInput = useCallback(() => { + inputRef.current?.focus(); + }, []); + + const onSelectTagOption = useCallback( + (member: Member) => { + onToggleMember(member.id); + setInputValue(''); + focusInput(); + setFocusedIndex(-1); + setFocusedInlineIndex(selected.length + 1); + }, + [onToggleMember, focusInput, selected.length] + ); + const onEnter = useCallback(() => { + if (safeFocusedIndex >= 0) { + onSelectTagOption(searchedMembers[safeFocusedIndex]); + } + }, [onSelectTagOption, safeFocusedIndex, searchedMembers]); + + const handleUnselectMember = useCallback( + (id: string) => { + onToggleMember(id); + focusInput(); + }, + [onToggleMember, focusInput] + ); + + const onInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Backspace') { + if (inputValue.length > 0 || selected.length === 0) { + return; + } + e.preventDefault(); + const index = + safeInlineFocusedIndex < 0 || + safeInlineFocusedIndex >= selected.length + ? selected.length - 1 + : safeInlineFocusedIndex; + const memberToRemove = selected.at(index); + if (memberToRemove) { + handleUnselectMember(memberToRemove); + } + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const newFocusedIndex = clamp( + safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1), + 0, + searchedMembers.length - 1 + ); + scrollContainerRef.current + ?.querySelector( + `.${styles.memberSelectorItem}:nth-child(${newFocusedIndex + 1})` + ) + ?.scrollIntoView({ block: 'nearest' }); + setFocusedIndex(newFocusedIndex); + // reset inline focus + setFocusedInlineIndex(selected.length + 1); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (inputValue.length > 0 || selected.length === 0) { + return; + } + const newItemToFocus = + e.key === 'ArrowLeft' + ? safeInlineFocusedIndex - 1 + : safeInlineFocusedIndex + 1; + + e.preventDefault(); + setFocusedInlineIndex(newItemToFocus); + // reset tag list focus + setFocusedIndex(-1); + } + }, + [ + inputValue.length, + selected, + safeInlineFocusedIndex, + handleUnselectMember, + safeFocusedIndex, + searchedMembers.length, + ] + ); + + return ( +
+
+ + + + {BUILD_CONFIG.isMobileEdition ? null : ( + + )} +
+
+ + + {searchedMembers.length === 0 && ( +
Nothing here yet
+ )} + + {searchedMembers.map((member, idx) => { + const commonProps = { + ...(safeFocusedIndex === idx ? { focused: 'true' } : {}), + onClick: () => onSelectTagOption(member), + onMouseEnter: () => setFocusedIndex(idx), + ['data-testid']: 'tag-selector-item', + ['data-focused']: safeFocusedIndex === idx, + className: styles.memberSelectorItem, + }; + + return ( +
+ +
+ ); + })} +
+ +
+
+
+ ); +}; + +const MobileMemberSelectorInline = ({ + readonly, + placeholder, + className, + title, + style, + ...props +}: MemberSelectorInlineProps) => { + const [editing, setEditing] = useState(false); + + const empty = !props.selected || props.selected.length === 0; + return ( + <> + setEditing(false)} + > + + +
setEditing(true)} + style={style} + > + {empty ? placeholder : } +
+ + ); +}; + +const DesktopMemberSelectorInline = ({ + readonly, + placeholder, + className, + modalMenu, + menuClassName, + style, + selected, + ...props +}: MemberSelectorInlineProps) => { + const empty = !selected || selected.length === 0; + return ( + } + > +
+ {empty ? placeholder : } +
+
+ ); +}; + +export const MemberSelectorInline = BUILD_CONFIG.isMobileEdition + ? MobileMemberSelectorInline + : DesktopMemberSelectorInline; diff --git a/packages/frontend/core/src/components/member-selector/inline-member-list.tsx b/packages/frontend/core/src/components/member-selector/inline-member-list.tsx new file mode 100644 index 0000000000..d60a7f8b65 --- /dev/null +++ b/packages/frontend/core/src/components/member-selector/inline-member-list.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; + +import { MemberItem } from './item'; +import * as styles from './styles.css'; + +interface InlineMemberListProps + extends Omit, 'onChange'> { + members: string[]; + focusedIndex?: number; + onRemove?: (id: string) => void; +} + +export const InlineMemberList = ({ + className, + children, + members, + focusedIndex, + onRemove, + ...props +}: InlineMemberListProps) => { + return ( +
+ {members.map((member, idx) => ( + onRemove(member) : undefined} + /> + ))} + {children} +
+ ); +}; diff --git a/packages/frontend/core/src/components/member-selector/item.tsx b/packages/frontend/core/src/components/member-selector/item.tsx new file mode 100644 index 0000000000..a176c1c76c --- /dev/null +++ b/packages/frontend/core/src/components/member-selector/item.tsx @@ -0,0 +1,109 @@ +import { Avatar, Skeleton } from '@affine/component'; +import { PublicUserService } from '@affine/core/modules/cloud'; +import { useI18n } from '@affine/i18n'; +import { CloseIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { type MouseEventHandler, useCallback, useEffect } from 'react'; + +import * as styles from './styles.css'; + +export interface MemberItemProps { + userId: string; + idx?: number; + maxWidth?: number | string; + focused?: boolean; + onRemove?: () => void; + style?: React.CSSProperties; +} + +export const MemberItem = ({ + userId, + idx, + focused, + onRemove, + style, + maxWidth, +}: MemberItemProps) => { + const t = useI18n(); + const handleRemove: MouseEventHandler = useCallback( + e => { + e.stopPropagation(); + onRemove?.(); + }, + [onRemove] + ); + + const publicUserService = useService(PublicUserService); + const member = useLiveData(publicUserService.publicUser$(userId)); + const isLoading = useLiveData(publicUserService.isLoading$(userId)); + useEffect(() => { + if (userId) { + publicUserService.revalidate(userId); + } + }, [userId, publicUserService]); + + if (!member || ('removed' in member && member.removed)) { + return ( +
+
+
+ {!isLoading ? ( + + + + + ) : ( + t['Unknown User']() + )} +
+ {onRemove ? ( +
+ +
+ ) : null} +
+
+ ); + } + const { name, avatarUrl } = member; + + return ( +
+
+ +
{name}
+ {onRemove ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/member-selector/styles.css.ts b/packages/frontend/core/src/components/member-selector/styles.css.ts new file mode 100644 index 0000000000..64c962e3d3 --- /dev/null +++ b/packages/frontend/core/src/components/member-selector/styles.css.ts @@ -0,0 +1,216 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const membersSelectorInline = style({ + selectors: { + '&[data-empty=true]': { + color: cssVar('placeholderColor'), + }, + '&[data-readonly="true"]': { + pointerEvents: 'none', + }, + }, +}); + +export const memberSelectorRoot = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + gap: '4px', +}); + +export const memberSelectorRootMobile = style([ + memberSelectorRoot, + { + gap: 20, + }, +]); + +export const memberSelectorMenu = style({ + padding: 0, + position: 'relative', + top: 'calc(-3.5px + var(--radix-popper-anchor-height) * -1)', + left: '-3.5px', + width: 'calc(var(--radix-popper-anchor-width) + 16px)', + overflow: 'hidden', + minWidth: 400, +}); + +export const memberSelectorSelectedTags = style({ + display: 'flex', + flexWrap: 'wrap', + padding: '10px 12px 0px', + minHeight: 42, + selectors: { + [`${memberSelectorRootMobile} &`]: { + borderRadius: 12, + paddingBottom: '10px', + backgroundColor: cssVarV2('layer/background/primary'), + }, + }, +}); + +export const memberDivider = style({ + borderBottomColor: cssVarV2('tab/divider/divider'), +}); + +export const searchInput = style({ + flexGrow: 1, + height: '30px', + border: 'none', + outline: 'none', + fontSize: '14px', + fontFamily: 'inherit', + color: 'inherit', + backgroundColor: 'transparent', + '::placeholder': { + color: cssVarV2('text/placeholder'), + }, +}); + +export const memberSelectorBody = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', + padding: '0 8px 8px 8px', + maxHeight: '400px', + overflow: 'auto', + selectors: { + [`${memberSelectorRootMobile} &`]: { + padding: 0, + maxHeight: 'none', + }, + }, +}); + +export const memberSelectorScrollContainer = style({ + overflowX: 'hidden', + position: 'relative', + maxHeight: '200px', + gap: '8px', + selectors: { + [`${memberSelectorRootMobile} &`]: { + borderRadius: 12, + backgroundColor: cssVarV2('layer/background/primary'), + gap: 0, + padding: 4, + maxHeight: 'none', + }, + }, +}); + +export const memberSelectorItem = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: '0 8px', + height: '34px', + gap: 8, + cursor: 'pointer', + borderRadius: '4px', + selectors: { + '&[data-focused=true]': { + backgroundColor: cssVar('hoverColor'), + }, + [`${memberSelectorRootMobile} &`]: { + height: 44, + }, + [`${memberSelectorRootMobile} &[data-focused="true"]`]: { + height: 44, + backgroundColor: 'transparent', + }, + }, +}); + +export const memberSelectorEmpty = style({ + padding: '10px 8px', + fontSize: cssVar('fontSm'), + color: cssVar('textSecondaryColor'), + height: '34px', + selectors: { + [`${memberSelectorRootMobile} &`]: { + height: 44, + }, + }, +}); + +export const memberItem = style({ + height: '22px', + display: 'flex', + minWidth: 0, + alignItems: 'center', + justifyContent: 'space-between', + ':last-child': { + minWidth: 'max-content', + }, +}); + +export const memberItemInlineMode = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 8px', + color: cssVar('textPrimaryColor'), + borderColor: cssVar('borderColor'), + selectors: { + '&[data-focused=true]': { + borderColor: cssVar('primaryColor'), + }, + }, + fontSize: 'inherit', + borderRadius: '10px', + columnGap: '4px', + borderWidth: '1px', + borderStyle: 'solid', + background: cssVar('backgroundPrimaryColor'), + maxWidth: '128px', + height: '100%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const memberItemListMode = style({ + fontSize: 'inherit', + padding: '4px 4px', + columnGap: '8px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'flex', + minWidth: 0, + gap: '4px', + alignItems: 'center', + justifyContent: 'space-between', +}); +export const memberItemLabel = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + userSelect: 'none', +}); + +export const memberItemRemove = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 12, + height: 12, + borderRadius: '50%', + flexShrink: 0, + cursor: 'pointer', + ':hover': { + background: 'var(--affine-hover-color)', + }, +}); + +export const memberItemAvatar = style({ + marginRight: '0.5em', +}); + +export const inlineMemberList = style({ + display: 'flex', + gap: '6px', + flexWrap: 'wrap', + width: '100%', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx index 2393bf7219..98efd0c936 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx @@ -41,7 +41,7 @@ export const TagItem = ({ tag, ...props }: TagItemProps) => { mode={props.mode === 'inline' ? 'inline-tag' : 'list-tag'} tag={{ id: tag?.id, - value: value, + name: value, color: color, }} /> diff --git a/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx b/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx index 6674eb8944..14bee1f66d 100644 --- a/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx +++ b/packages/frontend/core/src/components/page-list/filter/literal-matcher.tsx @@ -1,7 +1,8 @@ import { Input, Menu, MenuItem } from '@affine/component'; -import type { LiteralValue, Tag } from '@affine/env/filter'; +import type { LiteralValue } from '@affine/env/filter'; import type { ReactNode } from 'react'; +import type { TagMeta } from '../types'; import { DateSelect } from './date-select'; import { FilterTag } from './filter-tag-translation'; import { inputStyle } from './index.css'; @@ -70,7 +71,7 @@ literalMatcher.register(tDate.create(), { ), }); -const getTagsOfArrayTag = (type: TType): Tag[] => { +const getTagsOfArrayTag = (type: TType): TagMeta[] => { if (type.type === 'array') { if (tTag.is(type.ele)) { return type.ele.data?.tags ?? []; @@ -86,8 +87,8 @@ literalMatcher.register(tArray(tTag.create()), { onChange(value)} - options={getTagsOfArrayTag(type).map(v => ({ - label: v.value, + options={getTagsOfArrayTag(type).map((v: any) => ({ + label: v.name, value: v.id, }))} > diff --git a/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts b/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts index 3e01b8155a..97bdad377b 100644 --- a/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts +++ b/packages/frontend/core/src/components/page-list/filter/logical/custom-type.ts @@ -1,5 +1,4 @@ -import type { Tag } from '@affine/env/filter'; - +import type { TagMeta } from '../../types'; import { DataHelper, typesystem } from './typesystem'; export const tNumber = typesystem.defineData( @@ -15,7 +14,7 @@ export const tDate = typesystem.defineData( DataHelper.create<{ value: number }>('Date') ); -export const tTag = typesystem.defineData<{ tags: Tag[] }>({ +export const tTag = typesystem.defineData<{ tags: TagMeta[] }>({ name: 'Tag', supers: [], }); diff --git a/packages/frontend/core/src/components/page-list/filter/shared-types.tsx b/packages/frontend/core/src/components/page-list/filter/shared-types.tsx index 0b8f10f0e7..4c212c7b8f 100644 --- a/packages/frontend/core/src/components/page-list/filter/shared-types.tsx +++ b/packages/frontend/core/src/components/page-list/filter/shared-types.tsx @@ -42,7 +42,17 @@ export const variableDefineMap = { icon: , }, Tags: { - type: meta => tArray(tTag.create({ tags: meta.tags?.options ?? [] })), + type: meta => + tArray( + tTag.create({ + tags: + meta.tags?.options.map(t => ({ + id: t.id, + name: t.value, + color: t.color, + })) ?? [], + }) + ), icon: , }, 'Is Public': { diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index 5f58a584b4..874ca0ec39 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -1,8 +1,7 @@ import { shallowEqual } from '@affine/component'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; -import type { Tag } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; -import type { DocMeta, Workspace } from '@blocksuite/affine/store'; +import type { DocMeta } from '@blocksuite/affine/store'; import { ToggleRightIcon, ViewLayersIcon } from '@blocksuite/icons/rc'; import * as Collapsible from '@radix-ui/react-collapsible'; import { useLiveData, useService } from '@toeverything/infra'; @@ -271,15 +270,6 @@ export const TagListItemRenderer = memo(function TagListItemRenderer( ); }); -function tagIdToTagOption( - tagId: string, - docCollection: Workspace -): Tag | undefined { - return docCollection.meta.properties.tags?.options.find( - opt => opt.id === tagId - ); -} - const PageTitle = ({ id }: { id: string }) => { const i18n = useI18n(); const docDisplayMetaService = useService(DocDisplayMetaService); @@ -326,10 +316,6 @@ function pageMetaToListItemProp( to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined, onClick: toggleSelection, icon: , - tags: - item.tags - ?.map(id => tagIdToTagOption(id, props.docCollection)) - .filter((v): v is Tag => v != null) ?? [], operations: props.operationsRenderer?.(item), selectable: props.selectable, selected: props.selectedIds?.includes(item.id), @@ -403,7 +389,7 @@ function tagMetaToListItemProp( : undefined; const itemProps: TagListItemProps = { tagId: item.id, - title: item.title, + title: item.name, to: props.rowAsLink && !props.selectable ? `/tag/${item.id}` : undefined, onClick: toggleSelection, color: item.color, diff --git a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx index dfb10282c5..1eef7246ad 100644 --- a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx +++ b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx @@ -136,12 +136,7 @@ const defaultSortingFn: SorterConfig>['sortingFn'] = ( return 0; }; -const validKeys: Set> = new Set([ - 'id', - 'title', - 'createDate', - 'updatedDate', -]); +const validKeys = new Set(['id', 'title', 'name', 'createDate', 'updatedDate']); const sorterStateAtom = atom>>({ key: DEFAULT_SORT_KEY, diff --git a/packages/frontend/core/src/components/page-list/tags/create-tag.tsx b/packages/frontend/core/src/components/page-list/tags/create-tag.tsx index b35cd9a0f7..25d3ad020e 100644 --- a/packages/frontend/core/src/components/page-list/tags/create-tag.tsx +++ b/packages/frontend/core/src/components/page-list/tags/create-tag.tsx @@ -34,7 +34,7 @@ export const CreateOrEditTag = ({ const t = useI18n(); const [menuOpen, setMenuOpen] = useState(false); - const [tagName, setTagName] = useState(tagMeta?.title || ''); + const [tagName, setTagName] = useState(tagMeta?.name || ''); const handleChangeName = useCallback((value: string) => { setTagName(value); }, []); @@ -89,7 +89,7 @@ export const CreateOrEditTag = ({ if (!tagName?.trim()) return; if ( tagOptions.some( - tag => tag.title === tagName.trim() && tag.id !== tagMeta?.id + tag => tag.name === tagName.trim() && tag.id !== tagMeta?.id ) ) { return toast(t['com.affine.tags.create-tag.toast.exist']()); @@ -131,9 +131,9 @@ export const CreateOrEditTag = ({ }, [open, onOpenChange, menuOpen, onClose]); useEffect(() => { - setTagName(tagMeta?.title || ''); + setTagName(tagMeta?.name || ''); setTagIcon(tagMeta?.color || tagService.randomTagColor()); - }, [tagMeta?.color, tagMeta?.title, tagService]); + }, [tagMeta?.color, tagMeta?.name, tagService]); if (!open) { return null; diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 6ed5775ee1..81f1092ef9 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -1,4 +1,4 @@ -import type { Collection, Tag } from '@affine/env/filter'; +import type { Collection } from '@affine/env/filter'; import type { DocMeta, Workspace } from '@blocksuite/affine/store'; import type { JSX, PropsWithChildren, ReactNode } from 'react'; import type { To } from 'react-router-dom'; @@ -13,7 +13,7 @@ export interface CollectionMeta extends Collection { export type TagMeta = { id: string; - title: string; + name: string; color: string; pageCount?: number; createDate?: Date | number; @@ -27,7 +27,6 @@ export type PageListItemProps = { icon: JSX.Element; title: ReactNode; // using ReactNode to allow for rich content rendering preview?: ReactNode; // using ReactNode to allow for rich content rendering - tags: Tag[]; createDate: Date; updatedDate?: Date; isPublicPage?: boolean; diff --git a/packages/frontend/core/src/components/doc-properties/icons/constant.ts b/packages/frontend/core/src/components/properties/icons/constant.ts similarity index 91% rename from packages/frontend/core/src/components/doc-properties/icons/constant.ts rename to packages/frontend/core/src/components/properties/icons/constant.ts index f588f52adb..2694017a2c 100644 --- a/packages/frontend/core/src/components/doc-properties/icons/constant.ts +++ b/packages/frontend/core/src/components/properties/icons/constant.ts @@ -7,7 +7,7 @@ type fromLibIconName = T extends `${infer N}Icon` ? Uncapitalize : never; -export const DocPropertyIconNames = [ +export const WorkspacePropertyIconNames = [ 'ai', 'email', 'text', @@ -88,4 +88,5 @@ export const DocPropertyIconNames = [ 'member', ] as const satisfies fromLibIconName[]; -export type DocPropertyIconName = (typeof DocPropertyIconNames)[number]; +export type WorkspacePropertyIconName = + (typeof WorkspacePropertyIconNames)[number]; diff --git a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts b/packages/frontend/core/src/components/properties/icons/icons-selector.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/icons/icons-selector.css.ts rename to packages/frontend/core/src/components/properties/icons/icons-selector.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx b/packages/frontend/core/src/components/properties/icons/icons-selector.tsx similarity index 80% rename from packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx rename to packages/frontend/core/src/components/properties/icons/icons-selector.tsx index 229c528492..fd3cd9a740 100644 --- a/packages/frontend/core/src/components/doc-properties/icons/icons-selector.tsx +++ b/packages/frontend/core/src/components/properties/icons/icons-selector.tsx @@ -3,20 +3,26 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import { useI18n } from '@affine/i18n'; import { chunk } from 'lodash-es'; -import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; -import { DocPropertyIcon, iconNameToComponent } from './doc-property-icon'; +import { + type WorkspacePropertyIconName, + WorkspacePropertyIconNames, +} from './constant'; import * as styles from './icons-selector.css'; +import { + iconNameToComponent, + WorkspacePropertyIcon, +} from './workspace-property-icon'; const iconsPerRow = 6; -const iconRows = chunk(DocPropertyIconNames, iconsPerRow); +const iconRows = chunk(WorkspacePropertyIconNames, iconsPerRow); const IconsSelectorPanel = ({ selectedIcon, onSelectedChange, }: { selectedIcon?: string | null; - onSelectedChange: (icon: DocPropertyIconName) => void; + onSelectedChange: (icon: WorkspacePropertyIconName) => void; }) => { const t = useI18n(); return ( @@ -53,19 +59,19 @@ const IconsSelectorPanel = ({ ); }; -export const DocPropertyIconSelector = ({ +export const WorkspacePropertyIconSelector = ({ propertyInfo, readonly, onSelectedChange, }: { propertyInfo: DocCustomPropertyInfo; readonly?: boolean; - onSelectedChange: (icon: DocPropertyIconName) => void; + onSelectedChange: (icon: WorkspacePropertyIconName) => void; }) => { if (readonly) { return (
- +
); } @@ -85,7 +91,7 @@ export const DocPropertyIconSelector = ({ } >
- +
); diff --git a/packages/frontend/core/src/components/doc-properties/icons/doc-property-icon.tsx b/packages/frontend/core/src/components/properties/icons/workspace-property-icon.tsx similarity index 53% rename from packages/frontend/core/src/components/doc-properties/icons/doc-property-icon.tsx rename to packages/frontend/core/src/components/properties/icons/workspace-property-icon.tsx index 94ecb0e5a0..41d71c48a1 100644 --- a/packages/frontend/core/src/components/doc-properties/icons/doc-property-icon.tsx +++ b/packages/frontend/core/src/components/properties/icons/workspace-property-icon.tsx @@ -3,15 +3,18 @@ import * as icons from '@blocksuite/icons/rc'; import type { SVGProps } from 'react'; import { - DocPropertyTypes, - isSupportedDocPropertyType, -} from '../types/constant'; -import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; +import { + type WorkspacePropertyIconName, + WorkspacePropertyIconNames, +} from './constant'; // assume all exports in icons are icon Components type LibIconComponentName = keyof typeof icons; -export const iconNameToComponent = (name: DocPropertyIconName) => { +export const iconNameToComponent = (name: WorkspacePropertyIconName) => { const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); const IconComponent = icons[`${capitalize(name)}Icon` as LibIconComponentName]; @@ -21,7 +24,7 @@ export const iconNameToComponent = (name: DocPropertyIconName) => { return IconComponent; }; -export const DocPropertyIcon = ({ +export const WorkspacePropertyIcon = ({ propertyInfo, ...props }: { @@ -29,11 +32,13 @@ export const DocPropertyIcon = ({ } & SVGProps) => { const Icon = propertyInfo.icon && - DocPropertyIconNames.includes(propertyInfo.icon as DocPropertyIconName) - ? iconNameToComponent(propertyInfo.icon as DocPropertyIconName) - : isSupportedDocPropertyType(propertyInfo.type) - ? DocPropertyTypes[propertyInfo.type].icon - : DocPropertyTypes.text.icon; + WorkspacePropertyIconNames.includes( + propertyInfo.icon as WorkspacePropertyIconName + ) + ? iconNameToComponent(propertyInfo.icon as WorkspacePropertyIconName) + : isSupportedWorkspacePropertyType(propertyInfo.type) + ? WorkspacePropertyTypes[propertyInfo.type].icon + : WorkspacePropertyTypes.text.icon; return ; }; diff --git a/packages/frontend/core/src/components/properties/index.ts b/packages/frontend/core/src/components/properties/index.ts new file mode 100644 index 0000000000..ccd0f6eac9 --- /dev/null +++ b/packages/frontend/core/src/components/properties/index.ts @@ -0,0 +1,3 @@ +export { WorkspacePropertyIcon } from './icons/workspace-property-icon'; +export { WorkspacePropertyName } from './name'; +export * from './table'; diff --git a/packages/frontend/core/src/components/doc-properties/manager/index.tsx b/packages/frontend/core/src/components/properties/manager/index.tsx similarity index 83% rename from packages/frontend/core/src/components/doc-properties/manager/index.tsx rename to packages/frontend/core/src/components/properties/manager/index.tsx index f5e1bd9f86..4c334987d6 100644 --- a/packages/frontend/core/src/components/doc-properties/manager/index.tsx +++ b/packages/frontend/core/src/components/properties/manager/index.tsx @@ -7,8 +7,8 @@ import { useDropTarget, } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocsService } from '@affine/core/modules/doc'; import { WorkspaceService } from '@affine/core/modules/workspace'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; @@ -17,12 +17,12 @@ import clsx from 'clsx'; import { type HTMLProps, useCallback, useState } from 'react'; import { useGuard } from '../../guard'; -import { DocPropertyIcon } from '../icons/doc-property-icon'; -import { EditDocPropertyMenuItems } from '../menu/edit-doc-property'; import { - DocPropertyTypes, - isSupportedDocPropertyType, -} from '../types/constant'; + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; +import { WorkspacePropertyIcon } from '../icons/workspace-property-icon'; +import { EditWorkspacePropertyMenuItems } from '../menu/edit-doc-property'; import * as styles from './styles.css'; const PropertyItem = ({ @@ -39,12 +39,12 @@ const PropertyItem = ({ }) => { const t = useI18n(); const workspaceService = useService(WorkspaceService); - const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu); const canEditPropertyInfo = useGuard('Workspace_Properties_Update'); - const typeInfo = isSupportedDocPropertyType(propertyInfo.type) - ? DocPropertyTypes[propertyInfo.type] + const typeInfo = isSupportedWorkspacePropertyType(propertyInfo.type) + ? WorkspacePropertyTypes[propertyInfo.type] : undefined; const handleClick = useCallback(() => { @@ -93,15 +93,20 @@ const PropertyItem = ({ if (edge !== 'bottom' && edge !== 'top') { return; } - docsService.propertyList.updatePropertyInfo(propertyId, { - index: docsService.propertyList.indexAt( + workspacePropertyService.updatePropertyInfo(propertyId, { + index: workspacePropertyService.indexAt( edge === 'bottom' ? 'after' : 'before', propertyInfo.id ), }); }, }), - [docsService, propertyInfo, workspaceService, canEditPropertyInfo] + [ + workspacePropertyService, + propertyInfo, + workspaceService, + canEditPropertyInfo, + ] ); return ( @@ -118,7 +123,7 @@ const PropertyItem = ({ onClick={handleClick} data-testid="doc-property-manager-item" > - @@ -140,7 +145,7 @@ const PropertyItem = ({ modal: true, }} items={ - void; }) => { - const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); - const properties = useLiveData(docsService.propertyList.sortedProperties$); + const properties = useLiveData(workspacePropertyService.sortedProperties$); return (
diff --git a/packages/frontend/core/src/components/doc-properties/manager/styles.css.ts b/packages/frontend/core/src/components/properties/manager/styles.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/manager/styles.css.ts rename to packages/frontend/core/src/components/properties/manager/styles.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.css.ts b/packages/frontend/core/src/components/properties/menu/create-doc-property.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/menu/create-doc-property.css.ts rename to packages/frontend/core/src/components/properties/menu/create-doc-property.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx b/packages/frontend/core/src/components/properties/menu/create-doc-property.tsx similarity index 72% rename from packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx rename to packages/frontend/core/src/components/properties/menu/create-doc-property.tsx index 036970ddc9..d27d4e3cea 100644 --- a/packages/frontend/core/src/components/doc-properties/menu/create-doc-property.tsx +++ b/packages/frontend/core/src/components/properties/menu/create-doc-property.tsx @@ -1,15 +1,18 @@ import { MenuItem, MenuSeparator } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocsService } from '@affine/core/modules/doc'; +import { + WorkspacePropertyService, + type WorkspacePropertyType, +} from '@affine/core/modules/workspace-property'; import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback } from 'react'; import { - DocPropertyTypes, - isSupportedDocPropertyType, -} from '../types/constant'; + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; import * as styles from './create-doc-property.css'; export const CreatePropertyMenuItems = ({ @@ -20,16 +23,15 @@ export const CreatePropertyMenuItems = ({ onCreated?: (property: DocCustomPropertyInfo) => void; }) => { const t = useI18n(); - const docsService = useService(DocsService); - const propertyList = docsService.propertyList; - const properties = useLiveData(propertyList.properties$); + const workspacePropertyService = useService(WorkspacePropertyService); + const properties = useLiveData(workspacePropertyService.properties$); const onAddProperty = useCallback( - (option: { type: string; name: string }) => { - if (!isSupportedDocPropertyType(option.type)) { + (option: { type: WorkspacePropertyType; name: string }) => { + if (!isSupportedWorkspacePropertyType(option.type)) { return; } - const typeDefined = DocPropertyTypes[option.type]; + const typeDefined = WorkspacePropertyTypes[option.type]; const nameExists = properties.some(meta => meta.name === option.name); const allNames = properties .map(meta => meta.name) @@ -38,16 +40,16 @@ export const CreatePropertyMenuItems = ({ ? generateUniqueNameInSequence(option.name, allNames) : option.name; const uniqueId = typeDefined.uniqueId; - const newProperty = propertyList.createProperty({ + const newProperty = workspacePropertyService.createProperty({ id: uniqueId, name, type: option.type, - index: propertyList.indexAt(at), + index: workspacePropertyService.indexAt(at), isDeleted: false, }); onCreated?.(newProperty); }, - [at, onCreated, propertyList, properties] + [at, onCreated, workspacePropertyService, properties] ); return ( @@ -56,7 +58,7 @@ export const CreatePropertyMenuItems = ({ {t['com.affine.page-properties.create-property.menu.header']()}
- {Object.entries(DocPropertyTypes).map(([type, info]) => { + {Object.entries(WorkspacePropertyTypes).map(([type, info]) => { const name = t.t(info.name); const uniqueId = info.uniqueId; const isUniqueExist = properties.some(meta => meta.id === uniqueId); @@ -69,7 +71,7 @@ export const CreatePropertyMenuItems = ({ onClick={() => { onAddProperty({ name: name, - type: type, + type: type as WorkspacePropertyType, }); }} data-testid="create-property-menu-item" diff --git a/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.css.ts b/packages/frontend/core/src/components/properties/menu/edit-doc-property.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.css.ts rename to packages/frontend/core/src/components/properties/menu/edit-doc-property.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx b/packages/frontend/core/src/components/properties/menu/edit-doc-property.tsx similarity index 78% rename from packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx rename to packages/frontend/core/src/components/properties/menu/edit-doc-property.tsx index dfc7bd2f3e..99ec08841d 100644 --- a/packages/frontend/core/src/components/doc-properties/menu/edit-doc-property.tsx +++ b/packages/frontend/core/src/components/properties/menu/edit-doc-property.tsx @@ -5,7 +5,7 @@ import { useConfirmModal, } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocsService } from '@affine/core/modules/doc'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; import { Trans, useI18n } from '@affine/i18n'; import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; @@ -17,15 +17,15 @@ import { useState, } from 'react'; -import { DocPropertyIcon } from '../icons/doc-property-icon'; -import { DocPropertyIconSelector } from '../icons/icons-selector'; import { - DocPropertyTypes, - isSupportedDocPropertyType, -} from '../types/constant'; + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; +import { WorkspacePropertyIconSelector } from '../icons/icons-selector'; +import { WorkspacePropertyIcon } from '../icons/workspace-property-icon'; import * as styles from './edit-doc-property.css'; -export const EditDocPropertyMenuItems = ({ +export const EditWorkspacePropertyMenuItems = ({ propertyId, onPropertyInfoChange, readonly, @@ -38,14 +38,14 @@ export const EditDocPropertyMenuItems = ({ ) => void; }) => { const t = useI18n(); - const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); const propertyInfo = useLiveData( - docsService.propertyList.propertyInfo$(propertyId) + workspacePropertyService.propertyInfo$(propertyId) ); const propertyType = propertyInfo?.type; const typeInfo = - propertyType && isSupportedDocPropertyType(propertyType) - ? DocPropertyTypes[propertyType] + propertyType && isSupportedWorkspacePropertyType(propertyType) + ? WorkspacePropertyTypes[propertyType] : undefined; const propertyName = propertyInfo?.name || @@ -64,31 +64,31 @@ export const EditDocPropertyMenuItems = ({ } if (e.key === 'Enter') { e.preventDefault(); - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { name: e.currentTarget.value, }); } }, - [docsService.propertyList, propertyId] + [workspacePropertyService, propertyId] ); const handleBlur = useCallback( (e: FocusEvent & { currentTarget: HTMLInputElement }) => { - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { name: e.currentTarget.value, }); onPropertyInfoChange?.('name', e.currentTarget.value); }, - [docsService.propertyList, propertyId, onPropertyInfoChange] + [workspacePropertyService, propertyId, onPropertyInfoChange] ); const handleIconChange = useCallback( (iconName: string) => { - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { icon: iconName, }); onPropertyInfoChange?.('icon', iconName); }, - [docsService.propertyList, propertyId, onPropertyInfoChange] + [workspacePropertyService, propertyId, onPropertyInfoChange] ); const handleNameChange = useCallback((e: string) => { @@ -98,37 +98,37 @@ export const EditDocPropertyMenuItems = ({ const handleClickAlwaysShow = useCallback( (e: MouseEvent) => { e.preventDefault(); // avoid radix-ui close the menu - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { show: 'always-show', }); onPropertyInfoChange?.('show', 'always-show'); }, - [docsService.propertyList, propertyId, onPropertyInfoChange] + [workspacePropertyService, propertyId, onPropertyInfoChange] ); const handleClickHideWhenEmpty = useCallback( (e: MouseEvent) => { e.preventDefault(); // avoid radix-ui close the menu - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { show: 'hide-when-empty', }); onPropertyInfoChange?.('show', 'hide-when-empty'); }, - [docsService.propertyList, propertyId, onPropertyInfoChange] + [workspacePropertyService, propertyId, onPropertyInfoChange] ); const handleClickAlwaysHide = useCallback( (e: MouseEvent) => { e.preventDefault(); // avoid radix-ui close the menu - docsService.propertyList.updatePropertyInfo(propertyId, { + workspacePropertyService.updatePropertyInfo(propertyId, { show: 'always-hide', }); onPropertyInfoChange?.('show', 'always-hide'); }, - [docsService.propertyList, propertyId, onPropertyInfoChange] + [workspacePropertyService, propertyId, onPropertyInfoChange] ); - if (!propertyInfo || !isSupportedDocPropertyType(propertyType)) { + if (!propertyInfo || !isSupportedWorkspacePropertyType(propertyType)) { return null; } @@ -142,7 +142,7 @@ export const EditDocPropertyMenuItems = ({ } data-testid="edit-property-menu-item" > - {t['com.affine.page-properties.create-property.menu.header']()}
- + {t[`com.affine.page-properties.property.${propertyType}`]()}
@@ -227,7 +227,7 @@ export const EditDocPropertyMenuItems = ({ ), confirmText: t['Confirm'](), onConfirm: () => { - docsService.propertyList.removeProperty(propertyId); + workspacePropertyService.removeProperty(propertyId); }, confirmButtonOptions: { variant: 'error', diff --git a/packages/frontend/core/src/components/properties/name.tsx b/packages/frontend/core/src/components/properties/name.tsx new file mode 100644 index 0000000000..b61ae12899 --- /dev/null +++ b/packages/frontend/core/src/components/properties/name.tsx @@ -0,0 +1,14 @@ +import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; +import { useI18n } from '@affine/i18n'; + +import { WorkspacePropertyTypes } from '../workspace-property-types'; + +export const WorkspacePropertyName = ({ + propertyInfo, +}: { + propertyInfo: DocCustomPropertyInfo; +}) => { + const t = useI18n(); + const type = WorkspacePropertyTypes[propertyInfo.type]; + return propertyInfo.name || (type?.name ? t.t(type.name) : t['unnamed']()); +}; diff --git a/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx b/packages/frontend/core/src/components/properties/sidebar/index.tsx similarity index 76% rename from packages/frontend/core/src/components/doc-properties/sidebar/index.tsx rename to packages/frontend/core/src/components/properties/sidebar/index.tsx index ec5e87fa34..810468ea94 100644 --- a/packages/frontend/core/src/components/doc-properties/sidebar/index.tsx +++ b/packages/frontend/core/src/components/properties/sidebar/index.tsx @@ -1,6 +1,9 @@ import { Divider, IconButton, Tooltip } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocsService } from '@affine/core/modules/doc'; +import { + WorkspacePropertyService, + type WorkspacePropertyType, +} from '@affine/core/modules/workspace-property'; import { generateUniqueNameInSequence } from '@affine/core/utils/unique-name'; import { useI18n } from '@affine/i18n'; import track from '@affine/track'; @@ -13,31 +16,30 @@ import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useState } from 'react'; import { useGuard } from '../../guard'; -import { DocPropertyManager } from '../manager'; import { - DocPropertyTypes, - isSupportedDocPropertyType, -} from '../types/constant'; + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../../workspace-property-types'; +import { WorkspacePropertyManager } from '../manager'; import { - AddDocPropertySidebarSection, - DocPropertyListSidebarSection, + AddWorkspacePropertySidebarSection, + WorkspacePropertyListSidebarSection, } from './section'; import * as styles from './styles.css'; -export const DocPropertySidebar = () => { +export const WorkspacePropertySidebar = () => { const t = useI18n(); const [newPropertyId, setNewPropertyId] = useState(); - const docsService = useService(DocsService); - const propertyList = docsService.propertyList; - const properties = useLiveData(propertyList.properties$); + const workspacePropertyService = useService(WorkspacePropertyService); + const properties = useLiveData(workspacePropertyService.properties$); const canEditPropertyInfo = useGuard('Workspace_Properties_Update'); const onAddProperty = useCallback( - (option: { type: string; name: string }) => { - if (!isSupportedDocPropertyType(option.type)) { + (option: { type: WorkspacePropertyType; name: string }) => { + if (!isSupportedWorkspacePropertyType(option.type)) { return; } - const typeDefined = DocPropertyTypes[option.type]; + const typeDefined = WorkspacePropertyTypes[option.type]; const nameExists = properties.some(meta => meta.name === option.name); const allNames = properties .map(meta => meta.name) @@ -45,11 +47,11 @@ export const DocPropertySidebar = () => { const name = nameExists ? generateUniqueNameInSequence(option.name, allNames) : option.name; - const newProperty = propertyList.createProperty({ + const newProperty = workspacePropertyService.createProperty({ id: typeDefined.uniqueId, name, type: option.type, - index: propertyList.indexAt('after'), + index: workspacePropertyService.indexAt('after'), isDeleted: false, }); setNewPropertyId(newProperty.id); @@ -58,7 +60,7 @@ export const DocPropertySidebar = () => { type: option.type, }); }, - [propertyList, properties] + [workspacePropertyService, properties] ); const onPropertyInfoChange = useCallback( @@ -74,9 +76,9 @@ export const DocPropertySidebar = () => { return (
- + - {
- +
- {Object.entries(DocPropertyTypes).map(([key, value]) => { + {Object.entries(WorkspacePropertyTypes).map(([key, value]) => { const Icon = value.icon; const name = t.t(value.name); const isUniqueExist = properties.some( @@ -109,7 +111,7 @@ export const DocPropertySidebar = () => { return; } onAddProperty({ - type: key, + type: key as WorkspacePropertyType, name, }); }} diff --git a/packages/frontend/core/src/components/doc-properties/sidebar/section.css.ts b/packages/frontend/core/src/components/properties/sidebar/section.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/sidebar/section.css.ts rename to packages/frontend/core/src/components/properties/sidebar/section.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/sidebar/section.tsx b/packages/frontend/core/src/components/properties/sidebar/section.tsx similarity index 89% rename from packages/frontend/core/src/components/doc-properties/sidebar/section.tsx rename to packages/frontend/core/src/components/properties/sidebar/section.tsx index e80a6f9e2c..0fb0d0ea62 100644 --- a/packages/frontend/core/src/components/doc-properties/sidebar/section.tsx +++ b/packages/frontend/core/src/components/properties/sidebar/section.tsx @@ -5,7 +5,7 @@ import { Trigger as CollapsibleTrigger } from '@radix-ui/react-collapsible'; import * as styles from './section.css'; -export const DocPropertyListSidebarSection = () => { +export const WorkspacePropertyListSidebarSection = () => { const t = useI18n(); return (
@@ -21,7 +21,7 @@ export const DocPropertyListSidebarSection = () => { ); }; -export const AddDocPropertySidebarSection = () => { +export const AddWorkspacePropertySidebarSection = () => { const t = useI18n(); return (
diff --git a/packages/frontend/core/src/components/doc-properties/sidebar/styles.css.ts b/packages/frontend/core/src/components/properties/sidebar/styles.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/sidebar/styles.css.ts rename to packages/frontend/core/src/components/properties/sidebar/styles.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/table.css.ts b/packages/frontend/core/src/components/properties/table.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/table.css.ts rename to packages/frontend/core/src/components/properties/table.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/table.tsx b/packages/frontend/core/src/components/properties/table.tsx similarity index 86% rename from packages/frontend/core/src/components/doc-properties/table.tsx rename to packages/frontend/core/src/components/properties/table.tsx index d739a5a33d..a894f16581 100644 --- a/packages/frontend/core/src/components/doc-properties/table.tsx +++ b/packages/frontend/core/src/components/properties/table.tsx @@ -9,7 +9,7 @@ import { useDropTarget, } from '@affine/component'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocService, DocsService } from '@affine/core/modules/doc'; +import { DocService } from '@affine/core/modules/doc'; import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info'; import type { DatabaseRow, @@ -17,6 +17,7 @@ import type { } from '@affine/core/modules/doc-info/types'; import { DocIntegrationPropertiesTable } from '@affine/core/modules/integration'; import { ViewService, WorkbenchService } from '@affine/core/modules/workbench'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; @@ -32,11 +33,15 @@ import type React from 'react'; import { forwardRef, useCallback, useMemo, useState } from 'react'; import { useGuard } from '../guard'; -import { DocPropertyIcon } from './icons/doc-property-icon'; +import { + isSupportedWorkspacePropertyType, + WorkspacePropertyTypes, +} from '../workspace-property-types'; +import { WorkspacePropertyIcon } from './icons/workspace-property-icon'; import { CreatePropertyMenuItems } from './menu/create-doc-property'; -import { EditDocPropertyMenuItems } from './menu/edit-doc-property'; +import { EditWorkspacePropertyMenuItems } from './menu/edit-doc-property'; +import { WorkspacePropertyName } from './name'; import * as styles from './table.css'; -import { DocPropertyTypes, isSupportedDocPropertyType } from './types/constant'; export type DefaultOpenProperty = | { @@ -49,7 +54,7 @@ export type DefaultOpenProperty = databaseRowId: string; }; -export interface DocPropertiesTableProps { +export interface WorkspacePropertiesTableProps { className?: string; defaultOpenProperty?: DefaultOpenProperty; onPropertyAdded?: (property: DocCustomPropertyInfo) => void; @@ -66,7 +71,7 @@ export interface DocPropertiesTableProps { ) => void; } -interface DocPropertiesTableHeaderProps { +interface WorkspacePropertiesTableHeaderProps { className?: string; style?: React.CSSProperties; open: boolean; @@ -75,12 +80,12 @@ interface DocPropertiesTableHeaderProps { // Info // ───────────────────────────────────────────────── -export const DocPropertiesTableHeader = ({ +export const WorkspacePropertiesTableHeader = ({ className, style, open, onOpenChange, -}: DocPropertiesTableHeaderProps) => { +}: WorkspacePropertiesTableHeaderProps) => { const handleCollapse = useCallback(() => { track.doc.inlineDocInfo.$.toggle(); onOpenChange(!open); @@ -108,7 +113,7 @@ export const DocPropertiesTableHeader = ({ ); }; -interface DocPropertyRowProps { +interface WorkspacePropertyRowProps { propertyInfo: DocCustomPropertyInfo; showAll?: boolean; defaultOpenEditMenu?: boolean; @@ -121,23 +126,22 @@ interface DocPropertyRowProps { ) => void; } -export const DocPropertyRow = ({ +export const WorkspacePropertyRow = ({ propertyInfo, defaultOpenEditMenu, onChange, propertyInfoReadonly, readonly, onPropertyInfoChange, -}: DocPropertyRowProps) => { - const t = useI18n(); +}: WorkspacePropertyRowProps) => { const docService = useService(DocService); - const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); const customPropertyValue = useLiveData( docService.doc.customProperty$(propertyInfo.id) ); - const typeInfo = isSupportedDocPropertyType(propertyInfo.type) - ? DocPropertyTypes[propertyInfo.type] + const typeInfo = isSupportedWorkspacePropertyType(propertyInfo.type) + ? WorkspacePropertyTypes[propertyInfo.type] : undefined; const hide = propertyInfo.show === 'always-hide'; @@ -200,15 +204,15 @@ export const DocPropertyRow = ({ if (edge !== 'bottom' && edge !== 'top') { return; } - docsService.propertyList.updatePropertyInfo(propertyId, { - index: docsService.propertyList.indexAt( + workspacePropertyService.updatePropertyInfo(propertyId, { + index: workspacePropertyService.indexAt( edge === 'bottom' ? 'after' : 'before', propertyInfo.id ), }); }, }), - [docId, docsService.propertyList, propertyInfo.id, propertyInfoReadonly] + [docId, workspacePropertyService, propertyInfo.id, propertyInfoReadonly] ); if (!ValueRenderer || typeof ValueRenderer !== 'function') return null; @@ -229,13 +233,10 @@ export const DocPropertyRow = ({ > } - name={ - propertyInfo.name || - (typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']()) - } + icon={} + name={} menuItems={ - ( ( { @@ -286,11 +287,11 @@ const DocWorkspacePropertiesTableBody = forwardRef< ref ) => { const t = useI18n(); - const docsService = useService(DocsService); + const workspacePropertyService = useService(WorkspacePropertyService); const workbenchService = useService(WorkbenchService); const viewService = useServiceOptional(ViewService); const docService = useService(DocService); - const properties = useLiveData(docsService.propertyList.sortedProperties$); + const properties = useLiveData(workspacePropertyService.sortedProperties$); const [addMoreCollapsed, setAddMoreCollapsed] = useState(true); const [newPropertyId, setNewPropertyId] = useState(null); @@ -344,7 +345,7 @@ const DocWorkspacePropertiesTableBody = forwardRef< } > {properties.map(property => ( - { +}: WorkspacePropertiesTableProps) => { const [expanded, setExpanded] = useState(!!defaultOpenProperty); const defaultOpen = useMemo(() => { return defaultOpenProperty?.type === 'database' @@ -438,7 +439,7 @@ const DocPropertiesTableInner = ({ return (
- } /> - { - return ; +export const WorkspacePropertiesTable = ( + props: WorkspacePropertiesTableProps +) => { + return ; }; diff --git a/packages/frontend/core/src/components/doc-properties/types/types.ts b/packages/frontend/core/src/components/properties/types.ts similarity index 85% rename from packages/frontend/core/src/components/doc-properties/types/types.ts rename to packages/frontend/core/src/components/properties/types.ts index 85a6c09fa6..0099369ecc 100644 --- a/packages/frontend/core/src/components/doc-properties/types/types.ts +++ b/packages/frontend/core/src/components/properties/types.ts @@ -6,5 +6,3 @@ export interface PropertyValueProps { readonly?: boolean; onChange: (value: any, skipCommit?: boolean) => void; // if skipCommit is true, the change will be handled in the component itself } - -export type PageLayoutMode = 'standard' | 'fullWidth'; diff --git a/packages/frontend/core/src/components/doc-properties/widgets/radio-group.css.ts b/packages/frontend/core/src/components/properties/widgets/radio-group.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/widgets/radio-group.css.ts rename to packages/frontend/core/src/components/properties/widgets/radio-group.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/widgets/radio-group.tsx b/packages/frontend/core/src/components/properties/widgets/radio-group.tsx similarity index 96% rename from packages/frontend/core/src/components/doc-properties/widgets/radio-group.tsx rename to packages/frontend/core/src/components/properties/widgets/radio-group.tsx index 7cf23c2fb1..6b9385a54c 100644 --- a/packages/frontend/core/src/components/doc-properties/widgets/radio-group.tsx +++ b/packages/frontend/core/src/components/properties/widgets/radio-group.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import * as styles from './radio-group.css'; -export const DocPropertyRadioGroup = ({ +export const PropertyRadioGroup = ({ width = 194, items, value, diff --git a/packages/frontend/core/src/components/system-property-types/index.ts b/packages/frontend/core/src/components/system-property-types/index.ts new file mode 100644 index 0000000000..75a717ba9c --- /dev/null +++ b/packages/frontend/core/src/components/system-property-types/index.ts @@ -0,0 +1,39 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import type { I18nString } from '@affine/i18n'; +import { TagIcon } from '@blocksuite/icons/rc'; + +import { TagsFilterValue } from './tags'; + +export const SystemPropertyTypes = { + tags: { + icon: TagIcon, + name: 'Tags', + filterMethod: { + include: 'com.affine.filter.contains all', + 'is-not-empty': 'com.affine.filter.is not empty', + 'is-empty': 'com.affine.filter.is empty', + }, + filterValue: TagsFilterValue, + }, +} satisfies { + [type: string]: { + icon: React.FC>; + name: I18nString; + + allowInOrderBy?: boolean; + allowInGroupBy?: boolean; + filterMethod: { [key: string]: I18nString }; + filterValue: React.FC<{ + filter: FilterParams; + onChange: (filter: FilterParams) => void; + }>; + }; +}; + +export type SystemPropertyType = keyof typeof SystemPropertyTypes; + +export const isSupportedSystemPropertyType = ( + type?: string +): type is SystemPropertyType => { + return type ? type in SystemPropertyTypes : false; +}; diff --git a/packages/frontend/core/src/components/system-property-types/tags.tsx b/packages/frontend/core/src/components/system-property-types/tags.tsx new file mode 100644 index 0000000000..498836edff --- /dev/null +++ b/packages/frontend/core/src/components/system-property-types/tags.tsx @@ -0,0 +1,62 @@ +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useCallback, useMemo } from 'react'; + +import { WorkspaceTagsInlineEditor } from '../tags'; + +export const TagsFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + const tagService = useService(TagService); + const allTagMetas = useLiveData(tagService.tagList.tagMetas$); + + const selectedTags = useMemo( + () => + filter.value + ?.split(',') + .filter(id => allTagMetas.some(tag => tag.id === id)) ?? [], + [filter, allTagMetas] + ); + + const handleSelectTag = useCallback( + (tagId: string) => { + onChange({ + ...filter, + value: [...selectedTags, tagId].join(','), + }); + }, + [filter, onChange, selectedTags] + ); + + const handleDeselectTag = useCallback( + (tagId: string) => { + onChange({ + ...filter, + value: selectedTags.filter(id => id !== tagId).join(','), + }); + }, + [filter, onChange, selectedTags] + ); + + return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? ( + + {t['com.affine.filter.empty']()} + + } + selectedTags={selectedTags} + onSelectTag={handleSelectTag} + onDeselectTag={handleDeselectTag} + tagMode="inline-tag" + /> + ) : undefined; +}; diff --git a/packages/frontend/core/src/components/tags/styles.css.ts b/packages/frontend/core/src/components/tags/styles.css.ts index ee9f2188b8..db280270b4 100644 --- a/packages/frontend/core/src/components/tags/styles.css.ts +++ b/packages/frontend/core/src/components/tags/styles.css.ts @@ -34,6 +34,7 @@ export const tagsMenu = style({ left: '-3.5px', width: 'calc(var(--radix-popper-anchor-width) + 16px)', overflow: 'hidden', + minWidth: 400, }); export const tagsEditorSelectedTags = style({ diff --git a/packages/frontend/core/src/components/tags/tag-edit-menu.tsx b/packages/frontend/core/src/components/tags/tag-edit-menu.tsx index 691dd6aee7..8c4e43336e 100644 --- a/packages/frontend/core/src/components/tags/tag-edit-menu.tsx +++ b/packages/frontend/core/src/components/tags/tag-edit-menu.tsx @@ -39,7 +39,7 @@ const DesktopTagEditMenu = ({ if (name.trim() === '') { return; } - onTagChange('value', name); + onTagChange('name', name); }; return { @@ -51,7 +51,7 @@ const DesktopTagEditMenu = ({ items: ( <> { updateTagName(e.currentTarget.value); }} @@ -131,10 +131,10 @@ const MobileTagEditMenu = ({ const [localTag, setLocalTag] = useState({ ...tag }); useEffect(() => { - if (localTag.value !== tag.value) { + if (localTag.name !== tag.name) { setLocalTag({ ...tag }); } - }, [tag, localTag.value]); + }, [tag, localTag.name]); const handleTriggerClick: MouseEventHandler = useCallback( e => { @@ -145,8 +145,8 @@ const MobileTagEditMenu = ({ ); const handleOnDone = () => { setOpen(false); - if (localTag.value.trim() !== tag.value) { - onTagChange('value', localTag.value); + if (localTag.name.trim() !== tag.name) { + onTagChange('name', localTag.name); } if (localTag.color !== tag.color) { onTagChange('color', localTag.color); @@ -167,9 +167,9 @@ const MobileTagEditMenu = ({ }} autoSelect={false} className={styles.mobileTagEditInput} - value={localTag.value} + value={localTag.name} onChange={e => { - setLocalTag({ ...localTag, value: e }); + setLocalTag({ ...localTag, name: e }); }} placeholder={t['Untitled']()} /> diff --git a/packages/frontend/core/src/components/tags/tag.tsx b/packages/frontend/core/src/components/tags/tag.tsx index 6b0de484b9..218f34e933 100644 --- a/packages/frontend/core/src/components/tags/tag.tsx +++ b/packages/frontend/core/src/components/tags/tag.tsx @@ -26,7 +26,7 @@ export const TagItem = ({ style, maxWidth, }: TagItemProps) => { - const { value, color, id } = tag; + const { name, color, id } = tag; const handleRemove: MouseEventHandler = useCallback( e => { e.stopPropagation(); @@ -39,8 +39,8 @@ export const TagItem = ({ className={styles.tag} data-idx={idx} data-tag-id={id} - data-tag-value={value} - title={value} + data-tag-value={name} + title={name} style={{ ...style, ...assignInlineVars({ @@ -58,7 +58,7 @@ export const TagItem = ({ })} > {mode !== 'db-label' ?
: null} -
{value}
+
{name}
{onRemoved ? (
void; // a candidate to be deleted jumpToTag?: (id: string) => void; tagMode: 'inline-tag' | 'db-label'; + style?: React.CSSProperties; } export interface TagsInlineEditorProps extends TagsEditorProps { @@ -39,6 +43,7 @@ export interface TagsInlineEditorProps extends TagsEditorProps { title?: ReactNode; // only used for mobile modalMenu?: boolean; menuClassName?: string; + style?: React.CSSProperties; } type TagOption = TagLike | { readonly create: true; readonly value: string }; @@ -56,10 +61,11 @@ export const TagsEditor = ({ onDeselectTag, onCreateTag, tagColors, - onDeleteTag: onTagDelete, + onDeleteTag, onTagChange, jumpToTag, tagMode, + style, }: TagsEditorProps) => { const t = useI18n(); const [inputValue, setInputValue] = useState(''); @@ -67,11 +73,11 @@ export const TagsEditor = ({ const trimmedInputValue = inputValue.trim(); const filteredTags = tags.filter(tag => - tag.value.toLowerCase().includes(trimmedInputValue.toLowerCase()) + tag.name.toLowerCase().includes(trimmedInputValue.toLowerCase()) ); const inputRef = useRef(null); - const exactMatch = filteredTags.find(tag => tag.value === trimmedInputValue); + const exactMatch = filteredTags.find(tag => tag.name === trimmedInputValue); const showCreateTag = !exactMatch && trimmedInputValue; // tag option candidates to show in the tag dropdown @@ -145,6 +151,13 @@ export const TagsEditor = ({ [onCreateTag, nextColor] ); + const handleDeleteTag = useCallback( + (tagId: string) => { + onDeleteTag(tagId); + }, + [onDeleteTag] + ); + const onSelectTagOption = useCallback( (tagOption: TagOption) => { const id = isCreateNewTag(tagOption) @@ -230,6 +243,7 @@ export const TagsEditor = ({ return (
@@ -301,13 +315,13 @@ export const TagsEditor = ({ key={tag.id} {...commonProps} data-tag-id={tag.id} - data-tag-value={tag.value} + data-tag-value={tag.name} >
{ onTagChange(tag.id, property, value); }} @@ -335,6 +349,7 @@ const MobileInlineEditor = ({ placeholder, className, title, + style, ...props }: TagsInlineEditorProps) => { const [editing, setEditing] = useState(false); @@ -360,6 +375,7 @@ const MobileInlineEditor = ({ data-empty={empty} data-readonly={readonly} onClick={() => setEditing(true)} + style={style} > {empty ? ( placeholder @@ -377,6 +393,7 @@ const DesktopTagsInlineEditor = ({ className, modalMenu, menuClassName, + style, ...props }: TagsInlineEditorProps) => { const empty = !props.selectedTags || props.selectedTags.length === 0; @@ -406,6 +423,7 @@ const DesktopTagsInlineEditor = ({ className={clsx(styles.tagsInlineEditor, className)} data-empty={empty} data-readonly={readonly} + style={style} > {empty ? ( placeholder @@ -425,3 +443,69 @@ const DesktopTagsInlineEditor = ({ export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition ? MobileInlineEditor : DesktopTagsInlineEditor; + +export const WorkspaceTagsInlineEditor = ({ + selectedTags, + onDeselectTag, + ...otherProps +}: Omit< + TagsInlineEditorProps, + 'tags' | 'onCreateTag' | 'onDeleteTag' | 'tagColors' | 'onTagChange' +>) => { + const tagService = useService(TagService); + const tags = useLiveData(tagService.tagList.tagMetas$); + const openDeleteTagConfirmModal = useDeleteTagConfirmModal(); + const tagColors = tagService.tagColors; + const adaptedTagColors = useMemo(() => { + return tagColors.map(color => ({ + id: color[0], + value: color[1], + name: color[0], + })); + }, [tagColors]); + + const onDeleteTag = useAsyncCallback( + async (tagId: string) => { + if (await openDeleteTagConfirmModal([tagId])) { + tagService.tagList.deleteTag(tagId); + if (selectedTags.includes(tagId)) { + onDeselectTag(tagId); + } + } + }, + [tagService.tagList, openDeleteTagConfirmModal, selectedTags, onDeselectTag] + ); + const onCreateTag = useCallback( + (name: string, color: string) => { + const newTag = tagService.tagList.createTag(name, color); + return { + id: newTag.id, + name: newTag.value$.value, + color: newTag.color$.value, + }; + }, + [tagService.tagList] + ); + const onTagChange = useCallback( + (id: string, property: keyof TagLike, value: string) => { + if (property === 'name') { + tagService.tagList.tagByTagId$(id).value?.rename(value); + } else if (property === 'color') { + tagService.tagList.tagByTagId$(id).value?.changeColor(value); + } + }, + [tagService.tagList] + ); + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/tags/types.ts b/packages/frontend/core/src/components/tags/types.ts index e25a05b318..c63a6a7620 100644 --- a/packages/frontend/core/src/components/tags/types.ts +++ b/packages/frontend/core/src/components/tags/types.ts @@ -1,6 +1,6 @@ export interface TagLike { id: string; - value: string; // value is the tag name + name: string; // display name color: string; // css color value } diff --git a/packages/frontend/core/src/components/doc-properties/types/checkbox.css.ts b/packages/frontend/core/src/components/workspace-property-types/checkbox.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/checkbox.css.ts rename to packages/frontend/core/src/components/workspace-property-types/checkbox.css.ts diff --git a/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx new file mode 100644 index 0000000000..31965ca32c --- /dev/null +++ b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx @@ -0,0 +1,75 @@ +import { Checkbox, Menu, MenuItem, PropertyValue } from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; +import { useCallback } from 'react'; + +import type { PropertyValueProps } from '../properties/types'; +import * as styles from './checkbox.css'; + +export const CheckboxValue = ({ + value, + onChange, + readonly, +}: PropertyValueProps) => { + const parsedValue = value === 'true' ? true : false; + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (readonly) { + return; + } + onChange(parsedValue ? 'false' : 'true'); + }, + [onChange, parsedValue, readonly] + ); + return ( + + {}} + disabled={readonly} + /> + + ); +}; + +export const CheckboxFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + return ( + + { + onChange({ + ...filter, + value: 'true', + }); + }} + selected={filter.value === 'true'} + > + {'True'} + + { + onChange({ + ...filter, + value: 'false', + }); + }} + selected={filter.value !== 'true'} + > + {'False'} + + + } + > + {filter.value === 'true' ? 'True' : 'False'} + + ); +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/created-updated-by.css.ts b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/created-updated-by.css.ts rename to packages/frontend/core/src/components/workspace-property-types/created-updated-by.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/created-updated-by.tsx b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx similarity index 67% rename from packages/frontend/core/src/components/doc-properties/types/created-updated-by.tsx rename to packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx index 9f7833babf..852a08bb46 100644 --- a/packages/frontend/core/src/components/doc-properties/types/created-updated-by.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx @@ -1,10 +1,14 @@ import { PropertyValue } from '@affine/component'; import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useCallback, useMemo } from 'react'; +import { MemberSelectorInline } from '../member-selector'; import { userWrapper } from './created-updated-by.css'; const CreatedByUpdatedByAvatar = (props: { @@ -78,3 +82,40 @@ export const UpdatedByValue = () => { ); }; + +export const CreatedByUpdatedByFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + + const selected = useMemo( + () => filter.value?.split(',').filter(Boolean) ?? [], + [filter] + ); + + const handleChange = useCallback( + (selected: string[]) => { + onChange({ + ...filter, + value: selected.join(','), + }); + }, + [filter, onChange] + ); + + return ( + + {t['com.affine.filter.empty']()} + + } + selected={selected} + onChange={handleChange} + /> + ); +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/date.css.ts b/packages/frontend/core/src/components/workspace-property-types/date.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/date.css.ts rename to packages/frontend/core/src/components/workspace-property-types/date.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/date.tsx b/packages/frontend/core/src/components/workspace-property-types/date.tsx similarity index 53% rename from packages/frontend/core/src/components/doc-properties/types/date.tsx rename to packages/frontend/core/src/components/workspace-property-types/date.tsx index 96acb22232..66e2d9655b 100644 --- a/packages/frontend/core/src/components/doc-properties/types/date.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/date.tsx @@ -1,10 +1,13 @@ import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { i18nTime, useI18n } from '@affine/i18n'; import { useLiveData, useServices } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useCallback } from 'react'; +import type { PropertyValueProps } from '../properties/types'; import * as styles from './date.css'; -import type { PropertyValueProps } from './types'; const useParsedDate = (value: string) => { const parsedValue = @@ -105,3 +108,79 @@ export const CreateDateValue = MetaDateValueFactory({ export const UpdatedDateValue = MetaDateValueFactory({ type: 'updatedDate', }); + +export const DateFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + const value = filter.value; + const values = value?.split(',') ?? []; + const displayDates = + values.map(t => i18nTime(t, { absolute: { accuracy: 'day' } })) ?? []; + + const handleChange = useCallback( + (date: string) => { + onChange({ + ...filter, + value: date, + }); + }, + [onChange, filter] + ); + + return filter.method === 'after' || filter.method === 'before' ? ( + + } + > + {displayDates[0] ? ( + {displayDates[0]} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + + ) : filter.method === 'between' ? ( + <> + handleChange(`${value},${values[1] || ''}`)} + /> + } + > + {displayDates[0] ? ( + {displayDates[0]} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + +  -  + handleChange(`${values[0] || ''},${value}`)} + /> + } + > + {displayDates[1] ? ( + {displayDates[1]} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + + + ) : undefined; +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.css.ts b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.css.ts rename to packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx similarity index 56% rename from packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx rename to packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx index 0880425b80..4d782d53e7 100644 --- a/packages/frontend/core/src/components/doc-properties/types/doc-primary-mode.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx @@ -1,13 +1,20 @@ -import { notify, PropertyValue, type RadioItem } from '@affine/component'; +import { + Menu, + MenuItem, + notify, + PropertyValue, + type RadioItem, +} from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { useI18n } from '@affine/i18n'; import type { DocMode } from '@blocksuite/affine/model'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; -import { DocPropertyRadioGroup } from '../widgets/radio-group'; +import type { PropertyValueProps } from '../properties/types'; +import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import * as styles from './doc-primary-mode.css'; -import type { PropertyValueProps } from './types'; export const DocPrimaryModeValue = ({ onChange, @@ -55,7 +62,7 @@ export const DocPrimaryModeValue = ({ hoverable={false} readonly={readonly} > - ); }; + +export const DocPrimaryModeFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + + return ( + + { + onChange({ + ...filter, + value: 'page', + }); + }} + selected={filter.value !== 'edgeless'} + > + {t['Page']()} + + { + onChange({ + ...filter, + value: 'edgeless', + }); + }} + selected={filter.value === 'edgeless'} + > + {t['Edgeless']()} + + + } + > + {filter.value === 'edgeless' ? t['Edgeless']() : t['Page']()} + + ); +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/edgeless-theme.css.ts b/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/edgeless-theme.css.ts rename to packages/frontend/core/src/components/workspace-property-types/edgeless-theme.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/edgeless-theme.tsx b/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx similarity index 89% rename from packages/frontend/core/src/components/doc-properties/types/edgeless-theme.tsx rename to packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx index 72beb73136..1fdfb14ef5 100644 --- a/packages/frontend/core/src/components/doc-properties/types/edgeless-theme.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/edgeless-theme.tsx @@ -4,9 +4,9 @@ import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; -import { DocPropertyRadioGroup } from '../widgets/radio-group'; +import type { PropertyValueProps } from '../properties/types'; +import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import * as styles from './edgeless-theme.css'; -import type { PropertyValueProps } from './types'; const getThemeOptions = (t: ReturnType) => [ @@ -47,7 +47,7 @@ export const EdgelessThemeValue = ({ hoverable={false} readonly={readonly} > - >; + value?: React.FC; + + allowInOrderBy?: boolean; + allowInGroupBy?: boolean; + filterMethod?: { [key in WorkspacePropertyFilter]: I18nString }; + filterValue?: React.FC<{ + filter: FilterParams; + onChange: (filter: FilterParams) => void; + }>; + defaultFilter?: Omit; + /** + * set a unique id for property type, make the property type can only be created once. + */ + uniqueId?: string; + name: I18nString; + renameable?: boolean; + description?: I18nString; + }; +}; + +export const isSupportedWorkspacePropertyType = ( + type?: string +): type is WorkspacePropertyType => { + return type && type !== 'unknown' ? type in WorkspacePropertyTypes : false; +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/journal.css.ts b/packages/frontend/core/src/components/workspace-property-types/journal.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/journal.css.ts rename to packages/frontend/core/src/components/workspace-property-types/journal.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/journal.tsx b/packages/frontend/core/src/components/workspace-property-types/journal.tsx similarity index 82% rename from packages/frontend/core/src/components/doc-properties/types/journal.tsx rename to packages/frontend/core/src/components/workspace-property-types/journal.tsx index 49307e78b8..53bf453980 100644 --- a/packages/frontend/core/src/components/doc-properties/types/journal.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/journal.tsx @@ -1,5 +1,12 @@ -import { Checkbox, DatePicker, Menu, PropertyValue } from '@affine/component'; +import { + Checkbox, + DatePicker, + Menu, + MenuItem, + PropertyValue, +} from '@affine/component'; import { MobileJournalConflictList } from '@affine/core/mobile/pages/workspace/detail/menu/journal-conflicts'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; import { DocService } from '@affine/core/modules/doc'; import { JournalService } from '@affine/core/modules/journal'; import { WorkbenchService } from '@affine/core/modules/workbench'; @@ -13,8 +20,8 @@ import { import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PropertyValueProps } from '../properties/types'; import * as styles from './journal.css'; -import type { PropertyValueProps } from './types'; const stopPropagation = (e: React.MouseEvent) => e.stopPropagation(); export const JournalValue = ({ readonly }: PropertyValueProps) => { @@ -168,3 +175,44 @@ export const JournalValue = ({ readonly }: PropertyValueProps) => { ); }; + +export const JournalFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + return ( + + { + onChange({ + ...filter, + value: 'true', + }); + }} + selected={filter.value === 'true'} + > + {'True'} + + { + onChange({ + ...filter, + value: 'false', + }); + }} + selected={filter.value !== 'true'} + > + {'False'} + + + } + > + {filter.value === 'true' ? 'True' : 'False'} + + ); +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/number.css.ts b/packages/frontend/core/src/components/workspace-property-types/number.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/number.css.ts rename to packages/frontend/core/src/components/workspace-property-types/number.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/number.tsx b/packages/frontend/core/src/components/workspace-property-types/number.tsx similarity index 95% rename from packages/frontend/core/src/components/doc-properties/types/number.tsx rename to packages/frontend/core/src/components/workspace-property-types/number.tsx index e29f7e905c..8dc4367482 100644 --- a/packages/frontend/core/src/components/doc-properties/types/number.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/number.tsx @@ -7,8 +7,8 @@ import { useState, } from 'react'; +import type { PropertyValueProps } from '../properties/types'; import * as styles from './number.css'; -import type { PropertyValueProps } from './types'; export const NumberValue = ({ value, diff --git a/packages/frontend/core/src/components/doc-properties/types/page-width.css.ts b/packages/frontend/core/src/components/workspace-property-types/page-width.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/page-width.css.ts rename to packages/frontend/core/src/components/workspace-property-types/page-width.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/page-width.tsx b/packages/frontend/core/src/components/workspace-property-types/page-width.tsx similarity index 79% rename from packages/frontend/core/src/components/doc-properties/types/page-width.tsx rename to packages/frontend/core/src/components/workspace-property-types/page-width.tsx index 6fa3f4503e..7f688d05a1 100644 --- a/packages/frontend/core/src/components/doc-properties/types/page-width.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/page-width.tsx @@ -5,9 +5,9 @@ import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; -import { DocPropertyRadioGroup } from '../widgets/radio-group'; +import type { PropertyValueProps } from '../properties/types'; +import { PropertyRadioGroup } from '../properties/widgets/radio-group'; import { container } from './page-width.css'; -import type { PageLayoutMode, PropertyValueProps } from './types'; export const PageWidthValue = ({ readonly }: PropertyValueProps) => { const t = useI18n(); @@ -17,14 +17,12 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => { const doc = useService(DocService).doc; const pageWidth = useLiveData(doc.properties$.selector(p => p.pageWidth)); - const radioValue = - pageWidth ?? - ((defaultPageWidth ? 'fullWidth' : 'standard') as PageLayoutMode); + const radioValue = pageWidth ?? (defaultPageWidth ? 'fullWidth' : 'standard'); const radioItems = useMemo( () => [ { - value: 'standard' as PageLayoutMode, + value: 'standard', label: t[ 'com.affine.settings.editorSettings.page.default-page-width.standard' @@ -32,7 +30,7 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => { testId: 'standard-width-trigger', }, { - value: 'fullWidth' as PageLayoutMode, + value: 'fullWidth', label: t[ 'com.affine.settings.editorSettings.page.default-page-width.full-width' @@ -44,14 +42,14 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => { ); const handleChange = useCallback( - (value: PageLayoutMode) => { + (value: string) => { doc.record.setProperty('pageWidth', value); }, [doc] ); return ( - { + const t = useI18n(); + + const doc = useService(DocService).doc; + + const tagList = useService(TagService).tagList; + const tagIds = useLiveData(tagList.tagIdsByPageId$(doc.id)); + const empty = !tagIds || tagIds.length === 0; + + return ( + + {}} + readonly={readonly} + /> + + ); +}; + +export const TagsFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const t = useI18n(); + const tagService = useService(TagService); + const allTagMetas = useLiveData(tagService.tagList.tagMetas$); + + const selectedTags = useMemo( + () => + filter.value + ?.split(',') + .filter(id => allTagMetas.some(tag => tag.id === id)) ?? [], + [filter, allTagMetas] + ); + + const handleSelectTag = useCallback( + (tagId: string) => { + onChange({ + ...filter, + value: [...selectedTags, tagId].join(','), + }); + }, + [filter, onChange, selectedTags] + ); + + const handleDeselectTag = useCallback( + (tagId: string) => { + onChange({ + ...filter, + value: selectedTags.filter(id => id !== tagId).join(','), + }); + }, + [filter, onChange, selectedTags] + ); + return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? ( + + {t['com.affine.filter.empty']()} + + } + selectedTags={selectedTags} + onSelectTag={handleSelectTag} + onDeselectTag={handleDeselectTag} + tagMode="inline-tag" + /> + ) : undefined; +}; + +const TagsInlineEditor = ({ + pageId, + readonly, + placeholder, + className, + onChange, +}: { + placeholder?: string; + className?: string; + onChange?: (value: unknown) => void; + pageId: string; + readonly?: boolean; + focusedIndex?: number; +}) => { + const workspace = useService(WorkspaceService); + const tagService = useService(TagService); + const tagIds$ = tagService.tagList.tagIdsByPageId$(pageId); + const tagIds = useLiveData(tagIds$); + + const onSelectTag = useCallback( + (tagId: string) => { + tagService.tagList.tagByTagId$(tagId).value?.tag(pageId); + onChange?.(tagIds$.value); + }, + [onChange, pageId, tagIds$, tagService.tagList] + ); + + const onDeselectTag = useCallback( + (tagId: string) => { + tagService.tagList.tagByTagId$(tagId).value?.untag(pageId); + onChange?.(tagIds$.value); + }, + [onChange, pageId, tagIds$, tagService.tagList] + ); + + const navigator = useNavigateHelper(); + + const jumpToTag = useCallback( + (id: string) => { + navigator.jumpToTag(workspace.workspace.id, id); + }, + [navigator, workspace.workspace.id] + ); + + const t = useI18n(); + + return ( + + + {t['Tags']()} + + } + /> + ); +}; diff --git a/packages/frontend/core/src/components/doc-properties/types/template.css.ts b/packages/frontend/core/src/components/workspace-property-types/template.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/template.css.ts rename to packages/frontend/core/src/components/workspace-property-types/template.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/template.tsx b/packages/frontend/core/src/components/workspace-property-types/template.tsx similarity index 95% rename from packages/frontend/core/src/components/doc-properties/types/template.tsx rename to packages/frontend/core/src/components/workspace-property-types/template.tsx index da639db87c..3d9eb64f43 100644 --- a/packages/frontend/core/src/components/doc-properties/types/template.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/template.tsx @@ -3,8 +3,8 @@ import { DocService } from '@affine/core/modules/doc'; import { useLiveData, useService } from '@toeverything/infra'; import { type ChangeEvent, useCallback } from 'react'; +import type { PropertyValueProps } from '../properties/types'; import * as styles from './template.css'; -import type { PropertyValueProps } from './types'; export const TemplateValue = ({ readonly }: PropertyValueProps) => { const docService = useService(DocService); diff --git a/packages/frontend/core/src/components/doc-properties/types/text.css.ts b/packages/frontend/core/src/components/workspace-property-types/text.css.ts similarity index 100% rename from packages/frontend/core/src/components/doc-properties/types/text.css.ts rename to packages/frontend/core/src/components/workspace-property-types/text.css.ts diff --git a/packages/frontend/core/src/components/doc-properties/types/text.tsx b/packages/frontend/core/src/components/workspace-property-types/text.tsx similarity index 67% rename from packages/frontend/core/src/components/doc-properties/types/text.tsx rename to packages/frontend/core/src/components/workspace-property-types/text.tsx index f0def01e21..0693fcd22d 100644 --- a/packages/frontend/core/src/components/doc-properties/types/text.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/text.tsx @@ -1,6 +1,9 @@ -import { PropertyValue } from '@affine/component'; +import { Input, Menu, PropertyValue } from '@affine/component'; +import type { FilterParams } from '@affine/core/modules/collection-rules'; import { useI18n } from '@affine/i18n'; import { TextIcon } from '@blocksuite/icons/rc'; +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { type ChangeEventHandler, useCallback, @@ -9,9 +12,9 @@ import { useState, } from 'react'; -import { ConfigModal } from '../../mobile'; +import { ConfigModal } from '../mobile'; +import type { PropertyValueProps } from '../properties/types'; import * as styles from './text.css'; -import type { PropertyValueProps } from './types'; const DesktopTextValue = ({ value, @@ -168,3 +171,80 @@ const MobileTextValue = ({ export const TextValue = BUILD_CONFIG.isMobileWeb ? MobileTextValue : DesktopTextValue; + +export const TextFilterValue = ({ + filter, + onChange, +}: { + filter: FilterParams; + onChange: (filter: FilterParams) => void; +}) => { + const [tempValue, setTempValue] = useState(filter.value || ''); + const [valueMenuOpen, setValueMenuOpen] = useState(false); + const t = useI18n(); + + useEffect(() => { + // update temp value with new filter value + setTempValue(filter.value || ''); + }, [filter.value]); + + const submitTempValue = useCallback(() => { + if (tempValue !== (filter.value || '')) { + onChange({ + ...filter, + value: tempValue, + }); + } + }, [filter, onChange, tempValue]); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== 'Escape') return; + submitTempValue(); + setValueMenuOpen(false); + }, + [submitTempValue] + ); + + const handleInputEnter = useCallback(() => { + submitTempValue(); + setValueMenuOpen(false); + }, [submitTempValue]); + + return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? ( + { + setTempValue(value); + }} + onEnter={handleInputEnter} + onKeyDown={handleInputKeyDown} + style={{ height: 34, borderRadius: 4 }} + /> + } + > + {filter.value ? ( + {filter.value} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + + ) : null; +}; diff --git a/packages/frontend/core/src/desktop/dialogs/doc-info/info-modal.tsx b/packages/frontend/core/src/desktop/dialogs/doc-info/info-modal.tsx index d0af4c5a4d..cf53aab880 100644 --- a/packages/frontend/core/src/desktop/dialogs/doc-info/info-modal.tsx +++ b/packages/frontend/core/src/desktop/dialogs/doc-info/info-modal.tsx @@ -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(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 => ( - { return tagMetas.filter(tag => { const reg = new RegExp(keyword, 'i'); - return reg.test(tag.title); + return reg.test(tag.name); }); }, [keyword, tagMetas]); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/page.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/page.tsx index 57e65e3051..afc3eb3b9e 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/page.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/editor/page.tsx @@ -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( () => [ { - 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); }, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/setting-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/setting-panel.tsx index b38791794f..255a68efb9 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/setting-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/setting-panel.tsx @@ -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 (
  • {t['com.affine.integration.readwise.setting.tags-label']()}
    - {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} /> diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/properties/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/properties/index.tsx index c1601fe3b2..a0f76c1423 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/properties/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/properties/index.tsx @@ -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 = () => {
  • - +
    ); }; diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts new file mode 100644 index 0000000000..c9d274c76a --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.css.ts @@ -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%', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx new file mode 100644 index 0000000000..1ce3915d96 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/all-page-new/all-page.tsx @@ -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({}); + + const [groups, setGroups] = useState([]); + + 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 ( + <> + + + + +
    +
    + + +
    +
    {JSON.stringify(explorerPreference, null, 2)}
    +
    {JSON.stringify(groups, null, 2)}
    +
    +
    + + + ); +}; + +export const Component = () => { + return ; +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx index 2f3fa8bc39..509555a2c5 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx @@ -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() { }> - + diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index f4f962374f..f81c259182 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -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'), diff --git a/packages/frontend/core/src/mobile/components/doc-info/doc-info.tsx b/packages/frontend/core/src/mobile/components/doc-info/doc-info.tsx index 89b468d093..e580636914 100644 --- a/packages/frontend/core/src/mobile/components/doc-info/doc-info.tsx +++ b/packages/frontend/core/src/mobile/components/doc-info/doc-info.tsx @@ -6,18 +6,18 @@ import { PropertyCollapsibleSection, Scrollable, } from '@affine/component'; +import { useGuard } from '@affine/core/components/guard'; import { type DefaultOpenProperty, - DocPropertyRow, -} from '@affine/core/components/doc-properties'; -import { CreatePropertyMenuItems } from '@affine/core/components/doc-properties/menu/create-doc-property'; -import { useGuard } from '@affine/core/components/guard'; + WorkspacePropertyRow, +} from '@affine/core/components/properties'; +import { CreatePropertyMenuItems } from '@affine/core/components/properties/menu/create-doc-property'; import { LinksRow } from '@affine/core/desktop/dialogs/doc-info/links-row'; import { TimeRow } from '@affine/core/desktop/dialogs/doc-info/time-row'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; -import { DocsService } from '@affine/core/modules/doc'; import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info'; import { DocsSearchService } from '@affine/core/modules/docs-search'; +import { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; import { useI18n } from '@affine/i18n'; import { PlusIcon } from '@blocksuite/icons/rc'; import { LiveData, useLiveData, useServices } from '@toeverything/infra'; @@ -31,9 +31,9 @@ export const DocInfoSheet = ({ docId: string; defaultOpenProperty?: DefaultOpenProperty; }) => { - const { docsSearchService, docsService } = useServices({ + const { docsSearchService, workspacePropertyService } = useServices({ DocsSearchService, - DocsService, + WorkspacePropertyService, }); const t = useI18n(); @@ -69,7 +69,7 @@ export const DocInfoSheet = ({ setNewPropertyId(property.id); }, []); - const properties = useLiveData(docsService.propertyList.sortedProperties$); + const properties = useLiveData(workspacePropertyService.sortedProperties$); return ( @@ -101,7 +101,7 @@ export const DocInfoSheet = ({ } > {properties.map(property => ( - > { + const method = params.method as WorkspacePropertyFilter<'checkbox'>; + if (method === 'is') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if ((value === 'true' ? 'true' : 'false') === params.value) { + match.add(id); + } + } + return match; + }) + ); + } + if (method === 'is-not') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if ((value === 'true' ? 'true' : 'false') !== params.value) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/created-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/created-at.ts new file mode 100644 index 0000000000..a121648376 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/created-at.ts @@ -0,0 +1,19 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; +import { basicDateFilter } from './date'; + +export class CreatedAtFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + filter$(params: FilterParams): Observable> { + return this.docsService.allDocsCreatedDate$().pipe( + map(docs => new Map(docs.map(doc => [doc.id, doc.createDate]))), + basicDateFilter(params) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/created-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/created-by.ts new file mode 100644 index 0000000000..4cf6142cc4 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/created-by.ts @@ -0,0 +1,33 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class CreatedByFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method as WorkspacePropertyFilter<'createdBy'>; + if (method === 'include') { + const userIds = params.value?.split(',') ?? []; + + return this.docsService.propertyValues$('createdBy').pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (value && userIds.includes(value)) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/date.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/date.ts new file mode 100644 index 0000000000..889130e11e --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/date.ts @@ -0,0 +1,160 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import dayjs, { type Dayjs, isDayjs } from 'dayjs'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class DatePropertyFilterProvider + extends Service + implements FilterProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + filter$(params: FilterParams): Observable> { + return this.docsService + .propertyValues$('custom:' + params.key) + .pipe(basicDateFilter(params)); + } +} + +export function basicDateFilter( + params: FilterParams +): ( + upstream$: Observable> +) => Observable> { + return upstream$ => { + // value can be like "2025-01-01,2025-01-02" + // or "2025-01-01" + const filterValues = (params.value + ?.split(',') + .map(t => parseDate(t)) + .filter(Boolean) ?? []) as [number, number, number][]; + + const now = dayjs(); + const method = params.method as WorkspacePropertyFilter<'date'>; + + const relativeRanges: Record = { + 'last-3-days': now.subtract(3, 'day'), + 'last-7-days': now.subtract(7, 'day'), + 'last-15-days': now.subtract(15, 'day'), + 'last-30-days': now.subtract(30, 'day'), + 'this-week': now.startOf('week'), + 'this-month': now.startOf('month'), + // @ts-expect-error 'quarter' is not in type, but it's supported by dayjs + 'this-quarter': now.startOf('quarter'), + 'this-year': now.startOf('year'), + }; + + return upstream$.pipe( + map(o => { + if (method === 'is-empty' || method === 'is-not-empty') { + const match = new Set(); + for (const [id, value] of o) { + if (method === 'is-empty' ? !value : !!value) { + match.add(id); + } + } + return match; + } + + if (method === 'between' && filterValues.length >= 2) { + return handleDateRangeFilter( + o, + parsed => + isAfter(parsed, filterValues[0]) && + isBefore(parsed, filterValues[1]) + ); + } + + if (method === 'after' && filterValues.length >= 1) { + return handleDateRangeFilter(o, parsed => + isAfter(parsed, filterValues[0]) + ); + } + + if (method === 'before' && filterValues.length >= 1) { + return handleDateRangeFilter(o, parsed => + isBefore(parsed, filterValues[0]) + ); + } + + if (method in relativeRanges) { + return handleDateRangeFilter(o, parsed => + isAfter(parsed, relativeRanges[method]) + ); + } + + throw new Error(`Unsupported method: ${method}`); + }) + ); + }; +} + +function parseDate(value: string | number): [number, number, number] | null { + if (typeof value === 'string') { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const [_, year, month, day] = match; + return [parseInt(year), parseInt(month), parseInt(day)]; + } else if (typeof value === 'number') { + const date = new Date(value); + return [date.getFullYear(), date.getMonth() + 1, date.getDate()]; + } + return null; +} + +function handleDateRangeFilter( + propertyValues: Map, + predicate: (parsed: [number, number, number]) => boolean +): Set { + const match = new Set(); + for (const [id, value] of propertyValues) { + if (!value) { + continue; + } + const parsed = parseDate(value); + if (parsed && predicate(parsed)) { + match.add(id); + } + } + return match; +} + +function isAfter( + targetDate: readonly [number, number, number] | Dayjs, + referenceDate: readonly [number, number, number] | Dayjs +): boolean { + const [targetYear, targetMonth, targetDay] = isDayjs(targetDate) + ? [targetDate.year(), targetDate.month(), targetDate.date()] + : targetDate; + const [refYear, refMonth, refDay] = isDayjs(referenceDate) + ? [referenceDate.year(), referenceDate.month(), referenceDate.date()] + : referenceDate; + + return ( + targetYear >= refYear || + (targetYear === refYear && targetMonth >= refMonth) || + (targetYear === refYear && targetMonth === refMonth && targetDay >= refDay) + ); +} + +function isBefore( + targetDate: [number, number, number], + referenceDate: [number, number, number] +): boolean { + const [targetYear, targetMonth, targetDay] = targetDate; + const [refYear, refMonth, refDay] = referenceDate; + + return ( + targetYear <= refYear || + (targetYear === refYear && targetMonth <= refMonth) || + (targetYear === refYear && targetMonth === refMonth && targetDay <= refDay) + ); +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/doc-primary-mode.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/doc-primary-mode.ts new file mode 100644 index 0000000000..b5a9d9cf44 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/doc-primary-mode.ts @@ -0,0 +1,46 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class DocPrimaryModeFilterProvider + extends Service + implements FilterProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method as WorkspacePropertyFilter<'docPrimaryMode'>; + if (method === 'is') { + return this.docsService.propertyValues$('primaryMode').pipe( + map(values => { + const match = new Set(); + for (const [id, value] of values) { + if ((value ?? 'page') === params.value) { + match.add(id); + } + } + return match; + }) + ); + } else if (method === 'is-not') { + return this.docsService.propertyValues$('primaryMode').pipe( + map(values => { + const match = new Set(); + for (const [id, value] of values) { + if ((value ?? 'page') !== params.value) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/journal.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/journal.ts new file mode 100644 index 0000000000..1fe49d0b21 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/journal.ts @@ -0,0 +1,43 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class JournalFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method as WorkspacePropertyFilter<'journal'>; + if (method === 'is') { + return this.docsService.propertyValues$('journal').pipe( + map(values => { + const match = new Set(); + for (const [id, value] of values) { + if (!!value === (params.value === 'true' ? true : false)) { + match.add(id); + } + } + return match; + }) + ); + } else if (method === 'is-not') { + return this.docsService.propertyValues$('journal').pipe( + map(values => { + const match = new Set(); + for (const [id, value] of values) { + if (!value === (params.value === 'true' ? true : false)) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts new file mode 100644 index 0000000000..412ac19b47 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/property.ts @@ -0,0 +1,34 @@ +import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { type Observable, switchMap } from 'rxjs'; + +import { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class PropertyFilterProvider extends Service implements FilterProvider { + constructor( + private readonly workspacePropertyService: WorkspacePropertyService + ) { + super(); + } + + filter$(params: FilterParams): Observable> { + const property$ = this.workspacePropertyService.propertyInfo$(params.key); + + return property$.pipe( + switchMap(property => { + if (!property) { + throw new Error('Unknown property'); + } + const type = property.type; + const provider = this.framework.getOptional( + FilterProvider('property:' + type) + ); + if (!provider) { + throw new Error('Unsupported property type'); + } + return provider.filter$(params); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/system.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/system.ts new file mode 100644 index 0000000000..91ef868eab --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/system.ts @@ -0,0 +1,17 @@ +import { Service } from '@toeverything/infra'; +import { type Observable } from 'rxjs'; + +import { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class SystemFilterProvider extends Service implements FilterProvider { + filter$(params: FilterParams): Observable> { + const provider = this.framework.getOptional( + FilterProvider('system:' + params.key) + ); + if (!provider) { + throw new Error('Unsupported system filter: ' + params.key); + } + return provider.filter$(params); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts new file mode 100644 index 0000000000..7dc936ef01 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/tags.ts @@ -0,0 +1,75 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { TagService } from '@affine/core/modules/tag'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable, of, switchMap } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class TagsFilterProvider extends Service implements FilterProvider { + constructor( + private readonly tagService: TagService, + private readonly docsService: DocsService + ) { + super(); + } + + filter$(params: FilterParams): Observable> { + if (params.method === 'include') { + const tagIds = params.value?.split(',') ?? []; + + const tags = tagIds.map(id => this.tagService.tagList.tagByTagId$(id)); + + if (tags.length === 0) { + return of(new Set()); + } + + return combineLatest(tags).pipe( + switchMap(tags => + combineLatest( + tags.filter(tag => tag !== undefined).map(tag => tag.pageIds$) + ).pipe(map(pageIds => new Set(pageIds.flat()))) + ) + ); + } else if (params.method === 'is-not-empty') { + return combineLatest([ + this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))), + this.docsService.allDocsTagIds$(), + ]).pipe( + map( + ([tags, docs]) => + new Set( + docs + .filter( + // filter deleted tags + // oxlint-disable-next-line prefer-array-some + doc => doc.tags.filter(tag => tags.has(tag)).length > 0 + ) + .map(doc => doc.id) + ) + ) + ); + } else if (params.method === 'is-empty') { + return this.tagService.tagList.tags$ + .map(tags => new Set(tags.map(t => t.id))) + .pipe( + switchMap(tags => + this.docsService.allDocsTagIds$().pipe( + map(docs => { + return new Set( + docs + .filter( + // filter deleted tags + // oxlint-disable-next-lint prefer-array-some + doc => doc.tags.filter(tag => tags.has(tag)).length === 0 + ) + .map(doc => doc.id) + ); + }) + ) + ) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/text.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/text.ts new file mode 100644 index 0000000000..4bf8c5c3a3 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/text.ts @@ -0,0 +1,70 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class TextPropertyFilterProvider + extends Service + implements FilterProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + filter$(params: FilterParams): Observable> { + const method = params.method as WorkspacePropertyFilter<'text'>; + if (method === 'is') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (value === params.value) { + match.add(id); + } + } + return match; + }) + ); + } else if (method === 'is-not') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (value !== params.value) { + match.add(id); + } + } + return match; + }) + ); + } else if (method === 'is-not-empty') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (value) { + match.add(id); + } + } + return match; + }) + ); + } else if (method === 'is-empty') { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (!value) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-at.ts new file mode 100644 index 0000000000..1fd99ad037 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-at.ts @@ -0,0 +1,19 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; +import { basicDateFilter } from './date'; + +export class UpdatedAtFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + filter$(params: FilterParams): Observable> { + return this.docsService.allDocsUpdatedDate$().pipe( + map(docs => new Map(docs.map(doc => [doc.id, doc.updatedDate]))), + basicDateFilter(params) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts new file mode 100644 index 0000000000..502f9685ca --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/filters/updated-by.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { FilterProvider } from '../../provider'; +import type { FilterParams } from '../../types'; + +export class UpdatedByFilterProvider extends Service implements FilterProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + filter$(params: FilterParams): Observable> { + const method = params.method as WorkspacePropertyFilter<'updatedBy'>; + if (method === 'include') { + const userIds = params.value?.split(',') ?? []; + + return this.docsService.propertyValues$('updatedBy').pipe( + map(o => { + const match = new Set(); + for (const [id, value] of o) { + if (value && userIds.includes(value)) { + match.add(id); + } + } + return match; + }) + ); + } + throw new Error(`Unsupported method: ${params.method}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/checkbox.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/checkbox.ts new file mode 100644 index 0000000000..0cbbf85158 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/checkbox.ts @@ -0,0 +1,34 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class CheckboxPropertyGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + groupBy$( + _items$: Observable>, + params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(values => { + const result = new Map>(); + for (const [id, value] of values) { + // Treat undefined or any non-true value as false for checkbox grouping + const v = value === 'true' ? 'true' : 'false'; + const set = result.get(v) ?? new Set(); + set.add(id); + result.set(v, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-at.ts new file mode 100644 index 0000000000..3a80422cba --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-at.ts @@ -0,0 +1,39 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class CreatedAtGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.allDocsCreatedDate$().pipe( + map(docs => { + const result = new Map>(); + docs.forEach(doc => { + if (!doc.createDate) { + return; + } + const date = new Date(doc.createDate); + const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + + if (!result.has(formattedDate)) { + result.set(formattedDate, new Set([doc.id])); + } else { + result.get(formattedDate)?.add(doc.id); + } + }); + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-by.ts new file mode 100644 index 0000000000..06c42241ca --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/created-by.ts @@ -0,0 +1,34 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class CreatedByGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('createdBy').pipe( + map(o => { + const result = new Map>(); + for (const [id, value] of o) { + if (value === undefined) { + continue; + } + const set = result.get(value) ?? new Set(); + set.add(id); + result.set(value, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/date.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/date.ts new file mode 100644 index 0000000000..d5a1e87de7 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/date.ts @@ -0,0 +1,35 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class DatePropertyGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + groupBy$( + _items$: Observable>, + params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const result = new Map>(); + for (const [id, value] of o) { + if (value === undefined) { + continue; + } + const set = result.get(value) ?? new Set(); + set.add(id); + result.set(value, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/doc-primary-mode.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/doc-primary-mode.ts new file mode 100644 index 0000000000..c307062fff --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/doc-primary-mode.ts @@ -0,0 +1,35 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class DocPrimaryModeGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('primaryMode').pipe( + map(values => { + const result = new Map>(); + for (const [id, value] of values) { + const mode = value ?? 'page'; + if (!result.has(mode)) { + result.set(mode, new Set([id])); + } else { + result.get(mode)?.add(id); + } + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/journal.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/journal.ts new file mode 100644 index 0000000000..f46b177685 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/journal.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class JournalGroupByProvider extends Service implements GroupByProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('journal').pipe( + map(values => { + const result = new Map>(); + for (const [id, value] of values) { + const isJournal = value ? 'true' : 'false'; + if (!result.has(isJournal)) { + result.set(isJournal, new Set([id])); + } else { + result.get(isJournal)?.add(id); + } + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/property.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/property.ts new file mode 100644 index 0000000000..e3f7096908 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/property.ts @@ -0,0 +1,41 @@ +import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; +import { switchMap } from 'rxjs'; + +import { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class PropertyGroupByProvider + extends Service + implements GroupByProvider +{ + constructor( + private readonly workspacePropertyService: WorkspacePropertyService + ) { + super(); + } + + groupBy$( + items$: Observable>, + params: GroupByParams + ): Observable>> { + const property$ = this.workspacePropertyService.propertyInfo$(params.key); + + return property$.pipe( + switchMap(property => { + if (!property) { + throw new Error('Unknown property'); + } + const type = property.type; + const provider = this.framework.getOptional( + GroupByProvider('property:' + type) + ); + if (!provider) { + throw new Error('Unsupported property type'); + } + return provider.groupBy$(items$, params); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/system.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/system.ts new file mode 100644 index 0000000000..21ab264c5c --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/system.ts @@ -0,0 +1,20 @@ +import { Service } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +import { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class SystemGroupByProvider extends Service implements GroupByProvider { + groupBy$( + items$: Observable>, + params: GroupByParams + ): Observable>> { + const provider = this.framework.getOptional( + GroupByProvider('system:' + params.key) + ); + if (!provider) { + throw new Error('Unsupported system group by: ' + params.key); + } + return provider.groupBy$(items$, params); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/tags.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/tags.ts new file mode 100644 index 0000000000..0311629607 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/tags.ts @@ -0,0 +1,43 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { TagService } from '@affine/core/modules/tag'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class TagsGroupByProvider extends Service implements GroupByProvider { + constructor( + private readonly docsService: DocsService, + private readonly tagService: TagService + ) { + super(); + } + + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return combineLatest([ + this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))), + this.docsService.allDocsTagIds$(), + ]).pipe( + map(([existsTags, docs]) => { + const map = new Map>(); + + for (const { id, tags } of docs) { + for (const tag of tags) { + if (!existsTags.has(tag)) { + continue; + } + const set = map.get(tag) ?? new Set(); + set.add(id); + map.set(tag, set); + } + } + + return map; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/text.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/text.ts new file mode 100644 index 0000000000..410b718419 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/text.ts @@ -0,0 +1,35 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class TextPropertyGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + groupBy$( + _items$: Observable>, + params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const result = new Map>(); + for (const [id, value] of o) { + if (value === undefined) { + continue; + } + const set = result.get(value) ?? new Set(); + set.add(id); + result.set(value, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-at.ts new file mode 100644 index 0000000000..3c20457d61 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-at.ts @@ -0,0 +1,40 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class UpdatedAtGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + groupBy$( + // not used in this implementation + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.allDocsUpdatedDate$().pipe( + map(docs => { + const result = new Map>(); + docs.forEach(doc => { + if (!doc.updatedDate) { + return; + } + const date = new Date(doc.updatedDate); + const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + + if (!result.has(formattedDate)) { + result.set(formattedDate, new Set([doc.id])); + } else { + result.get(formattedDate)?.add(doc.id); + } + }); + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-by.ts new file mode 100644 index 0000000000..3e1e58e01e --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/updated-by.ts @@ -0,0 +1,34 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { GroupByProvider } from '../../provider'; +import type { GroupByParams } from '../../types'; + +export class UpdatedByGroupByProvider + extends Service + implements GroupByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + groupBy$( + _items$: Observable>, + _params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('updatedBy').pipe( + map(o => { + const result = new Map>(); + for (const [id, value] of o) { + if (value === undefined) { + continue; + } + const set = result.get(value) ?? new Set(); + set.add(id); + result.set(value, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/checkbox.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/checkbox.ts new file mode 100644 index 0000000000..22afc60aeb --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/checkbox.ts @@ -0,0 +1,38 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class CheckboxPropertyOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return combineLatest([ + this.docsService.list.docs$, // We need the complete doc list as docs without property values should default to false + this.docsService.propertyValues$('custom:' + params.key), + ]).pipe( + map(([docs, values]) => { + const result: [string, boolean][] = []; + for (const doc of docs) { + const value = values.get(doc.id) === 'true' ? true : false; + result.push([doc.id, value]); + } + return result + .sort( + (a, b) => (a[1] === b[1] ? 0 : a[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-at.ts new file mode 100644 index 0000000000..24956f9e75 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-at.ts @@ -0,0 +1,33 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class CreatedAtOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + return this.docsService.allDocsCreatedDate$().pipe( + map(docs => { + if (params.desc) { + return docs + .sort((a, b) => (b.createDate ?? 0) - (a.createDate ?? 0)) + .map(doc => doc.id); + } else { + return docs + .sort((a, b) => (a.createDate ?? 0) - (b.createDate ?? 0)) + .map(doc => doc.id); + } + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-by.ts new file mode 100644 index 0000000000..85fd2a9f9c --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/created-by.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class CreatedByOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return this.docsService.propertyValues$('createdBy').pipe( + map(o => { + return Array.from(o) + .filter((i): i is [string, string] => !!i[1]) // filter empty value + .sort( + (a, b) => + (a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/date.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/date.ts new file mode 100644 index 0000000000..65856b7bb7 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/date.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class DatePropertyOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + return Array.from(o) + .filter((i): i is [string, string] => !!i[1]) // filter empty value + .sort( + (a, b) => + (a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/doc-primary-mode.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/doc-primary-mode.ts new file mode 100644 index 0000000000..1a54c59740 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/doc-primary-mode.ts @@ -0,0 +1,39 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class DocPrimaryModeOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + return this.docsService.propertyValues$('primaryMode').pipe( + map(values => { + const docs = Array.from(values).map(([id, value]) => ({ + id, + mode: value ?? 'page', + })); + + if (params.desc) { + return docs + .sort((a, b) => b.mode.localeCompare(a.mode)) + .map(doc => doc.id); + } else { + return docs + .sort((a, b) => a.mode.localeCompare(b.mode)) + .map(doc => doc.id); + } + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/journal.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/journal.ts new file mode 100644 index 0000000000..25b73dee9a --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/journal.ts @@ -0,0 +1,35 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class JournalOrderByProvider extends Service implements OrderByProvider { + constructor(private readonly docsService: DocsService) { + super(); + } + + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return this.docsService.propertyValues$('journal').pipe( + map(values => { + return Array.from(values) + .map(([id, value]) => ({ + id, + isJournal: !!value, + })) + .sort((a, b) => { + if (a.isJournal === b.isJournal) { + return 0; + } + return (a.isJournal ? 1 : -1) * (isDesc ? -1 : 1); + }) + .map(doc => doc.id); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/property.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/property.ts new file mode 100644 index 0000000000..877ccb3764 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/property.ts @@ -0,0 +1,40 @@ +import type { WorkspacePropertyService } from '@affine/core/modules/workspace-property'; +import { Service } from '@toeverything/infra'; +import { type Observable, switchMap } from 'rxjs'; + +import { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class PropertyOrderByProvider + extends Service + implements OrderByProvider +{ + constructor( + private readonly workspacePropertyService: WorkspacePropertyService + ) { + super(); + } + + orderBy$( + items$: Observable>, + params: OrderByParams + ): Observable { + const property$ = this.workspacePropertyService.propertyInfo$(params.key); + + return property$.pipe( + switchMap(property => { + if (!property) { + throw new Error('Unknown property'); + } + const type = property.type; + const provider = this.framework.getOptional( + OrderByProvider('property:' + type) + ); + if (!provider) { + throw new Error('Unsupported property type'); + } + return provider.orderBy$(items$, params); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/system.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/system.ts new file mode 100644 index 0000000000..4cdc577c57 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/system.ts @@ -0,0 +1,20 @@ +import { Service } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +import { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class SystemOrderByProvider extends Service implements OrderByProvider { + orderBy$( + items$: Observable>, + params: OrderByParams + ): Observable { + const provider = this.framework.getOptional( + OrderByProvider('system:' + params.key) + ); + if (!provider) { + throw new Error('Unsupported system order by: ' + params.key); + } + return provider.orderBy$(items$, params); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/tags.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/tags.ts new file mode 100644 index 0000000000..09ac113ea6 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/tags.ts @@ -0,0 +1,43 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import type { TagService } from '@affine/core/modules/tag'; +import { Service } from '@toeverything/infra'; +import { combineLatest, map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class TagsOrderByProvider extends Service implements OrderByProvider { + constructor( + private readonly docsService: DocsService, + private readonly tagService: TagService + ) { + super(); + } + + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return combineLatest([ + this.tagService.tagList.tags$.map(tags => new Set(tags.map(t => t.id))), + this.docsService.allDocsTagIds$(), + ]).pipe( + map(([existsTags, docs]) => + docs + .map(doc => { + const filteredTags = doc.tags + .filter(tag => existsTags.has(tag)) // filter out tags that don't exist + .sort() // sort tags by ids + .join(','); // convert to string + return [doc.id, filteredTags]; + }) + .sort( + (a, b) => + (a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]) + ) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/text.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/text.ts new file mode 100644 index 0000000000..269adf5d51 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/text.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class TextPropertyOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + return Array.from(o) + .filter((i): i is [string, string] => !!i[1]) // filter empty value + .sort( + (a, b) => + (a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-at.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-at.ts new file mode 100644 index 0000000000..45a86fdf56 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-at.ts @@ -0,0 +1,35 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class UpdatedAtOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + return this.docsService.allDocsUpdatedDate$().pipe( + map(docs => { + if (params.desc) { + return docs + .filter(doc => doc.updatedDate) + .sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0)) + .map(doc => doc.id); + } else { + return docs + .filter(doc => doc.updatedDate) + .sort((a, b) => (a.updatedDate ?? 0) - (b.updatedDate ?? 0)) + .map(doc => doc.id); + } + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-by.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-by.ts new file mode 100644 index 0000000000..7e36199c40 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/updated-by.ts @@ -0,0 +1,32 @@ +import type { DocsService } from '@affine/core/modules/doc'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { OrderByProvider } from '../../provider'; +import type { OrderByParams } from '../../types'; + +export class UpdatedByOrderByProvider + extends Service + implements OrderByProvider +{ + constructor(private readonly docsService: DocsService) { + super(); + } + orderBy$( + _items$: Observable>, + params: OrderByParams + ): Observable { + const isDesc = params.desc; + return this.docsService.propertyValues$('updatedBy').pipe( + map(o => { + return Array.from(o) + .filter((i): i is [string, string] => !!i[1]) // filter empty value + .sort( + (a, b) => + (a[1] === b[1] ? 0 : a[1] > b[1] ? 1 : -1) * (isDesc ? -1 : 1) + ) + .map(i => i[0]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/index.ts b/packages/frontend/core/src/modules/collection-rules/index.ts new file mode 100644 index 0000000000..08b651ede9 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/index.ts @@ -0,0 +1,243 @@ +import type { Framework } from '@toeverything/infra'; + +import { DocsService } from '../doc'; +import { TagService } from '../tag'; +import { WorkspaceScope } from '../workspace'; +import { WorkspacePropertyService } from '../workspace-property'; +import { CheckboxPropertyFilterProvider } from './impls/filters/checkbox'; +import { CreatedAtFilterProvider } from './impls/filters/created-at'; +import { CreatedByFilterProvider } from './impls/filters/created-by'; +import { DatePropertyFilterProvider } from './impls/filters/date'; +import { DocPrimaryModeFilterProvider } from './impls/filters/doc-primary-mode'; +import { JournalFilterProvider } from './impls/filters/journal'; +import { PropertyFilterProvider } from './impls/filters/property'; +import { SystemFilterProvider } from './impls/filters/system'; +import { TagsFilterProvider } from './impls/filters/tags'; +import { TextPropertyFilterProvider } from './impls/filters/text'; +import { UpdatedAtFilterProvider } from './impls/filters/updated-at'; +import { UpdatedByFilterProvider } from './impls/filters/updated-by'; +import { CheckboxPropertyGroupByProvider } from './impls/group-by/checkbox'; +import { CreatedAtGroupByProvider } from './impls/group-by/created-at'; +import { CreatedByGroupByProvider } from './impls/group-by/created-by'; +import { DatePropertyGroupByProvider } from './impls/group-by/date'; +import { DocPrimaryModeGroupByProvider } from './impls/group-by/doc-primary-mode'; +import { JournalGroupByProvider } from './impls/group-by/journal'; +import { PropertyGroupByProvider } from './impls/group-by/property'; +import { SystemGroupByProvider } from './impls/group-by/system'; +import { TagsGroupByProvider } from './impls/group-by/tags'; +import { TextPropertyGroupByProvider } from './impls/group-by/text'; +import { UpdatedAtGroupByProvider } from './impls/group-by/updated-at'; +import { UpdatedByGroupByProvider } from './impls/group-by/updated-by'; +import { CheckboxPropertyOrderByProvider } from './impls/order-by/checkbox'; +import { CreatedAtOrderByProvider } from './impls/order-by/created-at'; +import { CreatedByOrderByProvider } from './impls/order-by/created-by'; +import { DatePropertyOrderByProvider } from './impls/order-by/date'; +import { DocPrimaryModeOrderByProvider } from './impls/order-by/doc-primary-mode'; +import { JournalOrderByProvider } from './impls/order-by/journal'; +import { PropertyOrderByProvider } from './impls/order-by/property'; +import { SystemOrderByProvider } from './impls/order-by/system'; +import { TagsOrderByProvider } from './impls/order-by/tags'; +import { TextPropertyOrderByProvider } from './impls/order-by/text'; +import { UpdatedAtOrderByProvider } from './impls/order-by/updated-at'; +import { UpdatedByOrderByProvider } from './impls/order-by/updated-by'; +import { FilterProvider, GroupByProvider, OrderByProvider } from './provider'; +import { CollectionRulesService } from './services/collection-rules'; + +export { CollectionRulesService } from './services/collection-rules'; +export type { FilterParams } from './types'; + +export function configureCollectionRulesModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(CollectionRulesService) + // --------------- Filter --------------- + .impl(FilterProvider('system'), SystemFilterProvider) + .impl(FilterProvider('property'), PropertyFilterProvider, [ + WorkspacePropertyService, + ]) + .impl(FilterProvider('property:checkbox'), CheckboxPropertyFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:text'), TextPropertyFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:tags'), TagsFilterProvider, [ + TagService, + DocsService, + ]) + .impl(FilterProvider('system:tags'), TagsFilterProvider, [ + TagService, + DocsService, + ]) + .impl( + FilterProvider('property:docPrimaryMode'), + DocPrimaryModeFilterProvider, + [DocsService] + ) + .impl( + FilterProvider('system:docPrimaryMode'), + DocPrimaryModeFilterProvider, + [DocsService] + ) + .impl(FilterProvider('property:date'), DatePropertyFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:createdAt'), CreatedAtFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('system:createdAt'), CreatedAtFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:updatedAt'), UpdatedAtFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('system:updatedAt'), UpdatedAtFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:journal'), JournalFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('system:journal'), JournalFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:createdBy'), CreatedByFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('system:createdBy'), CreatedByFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('property:updatedBy'), UpdatedByFilterProvider, [ + DocsService, + ]) + .impl(FilterProvider('system:updatedBy'), UpdatedByFilterProvider, [ + DocsService, + ]) + // --------------- Group By --------------- + .impl(GroupByProvider('system'), SystemGroupByProvider) + .impl(GroupByProvider('property'), PropertyGroupByProvider, [ + WorkspacePropertyService, + ]) + .impl(GroupByProvider('property:date'), DatePropertyGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('property:tags'), TagsGroupByProvider, [ + DocsService, + TagService, + ]) + .impl(GroupByProvider('system:tags'), TagsGroupByProvider, [ + DocsService, + TagService, + ]) + .impl( + GroupByProvider('property:checkbox'), + CheckboxPropertyGroupByProvider, + [DocsService] + ) + .impl(GroupByProvider('property:text'), TextPropertyGroupByProvider, [ + DocsService, + ]) + .impl( + GroupByProvider('property:docPrimaryMode'), + DocPrimaryModeGroupByProvider, + [DocsService] + ) + .impl( + GroupByProvider('system:docPrimaryMode'), + DocPrimaryModeGroupByProvider, + [DocsService] + ) + .impl(GroupByProvider('property:createdAt'), CreatedAtGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('system:createdAt'), CreatedAtGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('property:updatedAt'), UpdatedAtGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('system:updatedAt'), UpdatedAtGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('property:journal'), JournalGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('system:journal'), JournalGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('property:createdBy'), CreatedByGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('system:createdBy'), CreatedByGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('property:updatedBy'), UpdatedByGroupByProvider, [ + DocsService, + ]) + .impl(GroupByProvider('system:updatedBy'), UpdatedByGroupByProvider, [ + DocsService, + ]) + // --------------- Order By --------------- + .impl(OrderByProvider('system'), SystemOrderByProvider) + .impl(OrderByProvider('property'), PropertyOrderByProvider, [ + WorkspacePropertyService, + ]) + .impl( + OrderByProvider('property:docPrimaryMode'), + DocPrimaryModeOrderByProvider, + [DocsService] + ) + .impl( + OrderByProvider('system:docPrimaryMode'), + DocPrimaryModeOrderByProvider, + [DocsService] + ) + .impl(OrderByProvider('property:updatedAt'), UpdatedAtOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('system:updatedAt'), UpdatedAtOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('property:createdAt'), CreatedAtOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('system:createdAt'), CreatedAtOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('property:text'), TextPropertyOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('property:date'), DatePropertyOrderByProvider, [ + DocsService, + ]) + .impl( + OrderByProvider('property:checkbox'), + CheckboxPropertyOrderByProvider, + [DocsService] + ) + .impl(OrderByProvider('property:tags'), TagsOrderByProvider, [ + DocsService, + TagService, + ]) + .impl(OrderByProvider('system:tags'), TagsOrderByProvider, [ + DocsService, + TagService, + ]) + .impl(OrderByProvider('property:journal'), JournalOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('system:journal'), JournalOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('property:createdBy'), CreatedByOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('system:createdBy'), CreatedByOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('property:updatedBy'), UpdatedByOrderByProvider, [ + DocsService, + ]) + .impl(OrderByProvider('system:updatedBy'), UpdatedByOrderByProvider, [ + DocsService, + ]); +} diff --git a/packages/frontend/core/src/modules/collection-rules/provider/index.ts b/packages/frontend/core/src/modules/collection-rules/provider/index.ts new file mode 100644 index 0000000000..3931b7fc1e --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/provider/index.ts @@ -0,0 +1,31 @@ +import { createIdentifier } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +import type { FilterParams, GroupByParams, OrderByParams } from '../types'; + +export interface FilterProvider { + filter$(params: FilterParams): Observable>; +} + +export const FilterProvider = + createIdentifier('FilterProvider'); + +export interface GroupByProvider { + groupBy$( + items$: Observable>, + params: GroupByParams + ): Observable>>; +} + +export const GroupByProvider = + createIdentifier('GroupByProvider'); + +export interface OrderByProvider { + orderBy$( + items$: Observable>, + params: OrderByParams + ): Observable; +} + +export const OrderByProvider = + createIdentifier('OrderByProvider'); diff --git a/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts b/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts new file mode 100644 index 0000000000..a5665928fd --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/services/collection-rules.ts @@ -0,0 +1,207 @@ +import { Service } from '@toeverything/infra'; +import { + catchError, + combineLatest, + distinctUntilChanged, + map, + type Observable, + of, + share, + throttleTime, +} from 'rxjs'; + +import { FilterProvider, GroupByProvider, OrderByProvider } from '../provider'; +import type { FilterParams, GroupByParams, OrderByParams } from '../types'; + +export class CollectionRulesService extends Service { + constructor() { + super(); + } + + watch( + filters: FilterParams[], + groupBy?: GroupByParams, + orderBy?: OrderByParams + ): Observable<{ + groups: { + key: string; + items: string[]; + }[]; + filterErrors: any[]; + }> { + // STEP 1: FILTER + const filterProviders = this.framework.getAll(FilterProvider); + const filtered$: Observable<{ + filtered: Set; + filterErrors: any[]; // errors from the filter providers + }> = + filters.length === 0 + ? of({ filtered: new Set(), filterErrors: [] }) + : combineLatest( + filters.map(filter => { + const provider = filterProviders.get(filter.type); + if (!provider) { + return of({ + error: new Error(`Unsupported filter type: ${filter.type}`), + }); + } + return provider.filter$(filter).pipe( + distinctUntilChanged((prev, curr) => { + return prev.isSubsetOf(curr) && curr.isSubsetOf(prev); + }), + catchError(error => { + console.log(error); + return of({ error }); + }) + ); + }) + ).pipe( + map(results => { + const finalSet = results.reduce((acc, result) => { + if ('error' in acc) { + return acc; + } + if ('error' in result) { + return acc; + } + return acc.intersection(result); + }); + + return { + filtered: 'error' in finalSet ? new Set() : finalSet, + filterErrors: results.map(i => ('error' in i ? i.error : null)), + }; + }) + ); + + // STEP 2: ORDER BY + const orderByProvider = orderBy + ? this.framework.getOptional(OrderByProvider(orderBy.type)) + : null; + const ordered$: Observable<{ + ordered: string[]; + filtered: Set; + filterErrors: any[]; + }> = filtered$.pipe(last$ => { + if (orderBy && orderByProvider) { + const shared$ = last$.pipe(share()); + const items$ = shared$.pipe( + map(i => i.filtered), + // avoid re-ordering the same items + distinctUntilChanged((prev, curr) => { + return prev.isSubsetOf(curr) && curr.isSubsetOf(prev); + }) + ); + return combineLatest([ + orderByProvider.orderBy$(items$, orderBy), + shared$, + ]).pipe( + map(([ordered, last]) => { + return { + ordered: Array.from(ordered), + ...last, + }; + }) + ); + } + return last$.pipe( + map(last => ({ + ordered: Array.from(last.filtered), + ...last, + })) + ); + }); + + // STEP 3: GROUP BY + const groupByProvider = groupBy + ? this.framework.getOptional(GroupByProvider(groupBy.type)) + : null; + const grouped$: Observable<{ + grouped: Map>; + ordered: string[]; + filtered: Set; + filterErrors: any[]; + }> = ordered$.pipe(last$ => { + if (groupBy && groupByProvider) { + const shared$ = last$.pipe(share()); + const items$ = shared$.pipe( + map(i => i.filtered), + // avoid re-grouping the same items + distinctUntilChanged((prev, curr) => { + return prev.isSubsetOf(curr) && curr.isSubsetOf(prev); + }) + ); + return combineLatest([ + groupByProvider.groupBy$(items$, groupBy), + shared$, + ]).pipe( + map(([grouped, last]) => { + return { + grouped: grouped, + ...last, + }; + }) + ); + } + return last$.pipe( + map(last => ({ + grouped: new Map>([['', last.filtered]]), + ...last, + })) + ); + }); + + // STEP 4: Merge the results + const final$: Observable<{ + groups: { + key: string; + items: string[]; + }[]; + filterErrors: any[]; + }> = grouped$.pipe( + throttleTime(500), // throttle the results to avoid too many re-renders + map(({ grouped, ordered, filtered, filterErrors }) => { + const result: { key: string; items: string[] }[] = []; + + function addToResult(key: string, item: string) { + const existing = result.find(i => i.key === key); + if (existing) { + existing.items.push(item); + } else { + result.push({ key: key, items: [item] }); + } + } + + // this step ensures that all filtered items are present in ordered + const finalOrdered = new Set(ordered.concat(Array.from(filtered))); + + for (const item of finalOrdered) { + const included = filtered.has(item); + if (!included) { + continue; + } + + const groups: string[] = []; + for (const [group, items] of grouped) { + if (items.has(item)) { + groups.push(group); + } + } + + if (groups.length === 0) { + // ungrouped items + addToResult('', item); + } else { + for (const group of groups) { + addToResult(group, item); + } + } + } + + return { groups: result, filterErrors }; + }) + ); + + return final$; + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/types.ts b/packages/frontend/core/src/modules/collection-rules/types.ts new file mode 100644 index 0000000000..af3ab37d0c --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/types.ts @@ -0,0 +1,17 @@ +export interface FilterParams { + type: string; + key: string; + method: string; + value?: string; +} + +export interface GroupByParams { + type: string; + key: string; +} + +export interface OrderByParams { + type: string; + key: string; + desc?: boolean; +} diff --git a/packages/frontend/core/src/modules/db/entities/table.ts b/packages/frontend/core/src/modules/db/entities/table.ts index 3957582a38..37621a7ceb 100644 --- a/packages/frontend/core/src/modules/db/entities/table.ts +++ b/packages/frontend/core/src/modules/db/entities/table.ts @@ -41,6 +41,9 @@ export class WorkspaceDBTable< find = this.table.find.bind(this.table) as typeof this.table.find; // eslint-disable-next-line rxjs/finnish find$ = this.table.find$.bind(this.table) as typeof this.table.find$; + select = this.table.select.bind(this.table) as typeof this.table.select; + // eslint-disable-next-line rxjs/finnish + select$ = this.table.select$.bind(this.table) as typeof this.table.select$; keys = this.table.keys.bind(this.table) as typeof this.table.keys; delete = this.table.delete.bind(this.table) as typeof this.table.delete; } diff --git a/packages/frontend/core/src/modules/db/schema/schema.ts b/packages/frontend/core/src/modules/db/schema/schema.ts index 8159c22223..a284feb621 100644 --- a/packages/frontend/core/src/modules/db/schema/schema.ts +++ b/packages/frontend/core/src/modules/db/schema/schema.ts @@ -1,11 +1,14 @@ import { type DBSchemaBuilder, f, + type FieldSchemaBuilder, type ORMEntity, t, } from '@toeverything/infra'; import { nanoid } from 'nanoid'; +import type { WorkspacePropertyType } from '../../workspace-property'; + const integrationType = f.enum('readwise', 'zotero'); export const AFFiNE_WORKSPACE_DB_SCHEMA = { @@ -31,7 +34,7 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = { docCustomPropertyInfo: { id: f.string().primaryKey().optional().default(nanoid), name: f.string().optional(), - type: f.string(), + type: f.string() as FieldSchemaBuilder, show: f.enum('always-show', 'always-hide', 'hide-when-empty').optional(), index: f.string().optional(), icon: f.string().optional(), diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx index c3473e3762..229b392cd4 100644 --- a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/checkbox.tsx @@ -1,4 +1,4 @@ -import { CheckboxValue } from '@affine/core/components/doc-properties/types/checkbox'; +import { CheckboxValue } from '@affine/core/components/workspace-property-types/checkbox'; import type { LiveData } from '@toeverything/infra'; import { useLiveData } from '@toeverything/infra'; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx index 5a79cb9f39..76d0390b10 100644 --- a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/date.tsx @@ -1,4 +1,4 @@ -import { DateValue } from '@affine/core/components/doc-properties/types/date'; +import { DateValue } from '@affine/core/components/workspace-property-types/date'; import type { LiveData } from '@toeverything/infra'; import { useLiveData } from '@toeverything/infra'; import dayjs from 'dayjs'; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx index 8b88e60c4d..d12ece46b8 100644 --- a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/number.tsx @@ -1,4 +1,4 @@ -import { NumberValue } from '@affine/core/components/doc-properties/types/number'; +import { NumberValue } from '@affine/core/components/workspace-property-types/number'; import { useLiveData } from '@toeverything/infra'; import type { DatabaseCellRendererProps } from '../../../types'; diff --git a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx index edbbcd8221..21125d0002 100644 --- a/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx +++ b/packages/frontend/core/src/modules/doc-info/views/database-properties/cells/select.tsx @@ -115,7 +115,7 @@ const adapter = { ...options, { id: newTag.id, - value: newTag.value, + value: newTag.name, color: newTag.color, }, ]); @@ -156,9 +156,10 @@ const BlocksuiteDatabaseSelector = ({ let tagOptions = useLiveData(adapter.getTagOptions$(selectCell)); // adapt bs database old tag color to new tag color - tagOptions = useMemo(() => { + let adaptedTagOptions = useMemo(() => { return tagOptions.map(tag => ({ - ...tag, + id: tag.id, + name: tag.value, color: databaseTagColorToV2(tag.color), })); }, [tagOptions]); @@ -168,7 +169,7 @@ const BlocksuiteDatabaseSelector = ({ // bs database uses --affine-tag-xxx colors const newTag = { id: nanoid(), - value: name, + name: name, color: color, }; adapter.createTag(selectCell, dataSource, newTag); @@ -228,7 +229,7 @@ const BlocksuiteDatabaseSelector = ({ { + const result = new Map(); + for (const docId of docIds) { + result.set(docId, propertyValues.get(docId)); + } + return result; + }) + ); + } /** - * used for search doc by properties, for convenience of search, all non-exist doc or trash doc have been filtered + * used for search */ - allDocProperties$: LiveData> = LiveData.from( - combineLatest([ - this.docPropertiesStore.watchAllDocProperties(), - this.store.watchNonTrashDocIds(), - ]).pipe( - map(([properties, docIds]) => { - const allIds = new Set(docIds); - return omitBy( - properties as Record, - (_, id) => !allIds.has(id) - ); - }) - ), - {} - ); + allDocsCreatedDate$() { + return this.store.watchAllDocCreateDate(); + } + + /** + * used for search + */ + allDocsUpdatedDate$() { + return this.store.watchAllDocUpdatedDate(); + } + + allDocsTagIds$() { + return this.store.watchAllDocTagIds(); + } constructor( private readonly store: DocsStore, diff --git a/packages/frontend/core/src/modules/doc/stores/doc-properties.ts b/packages/frontend/core/src/modules/doc/stores/doc-properties.ts index 9237f36e50..b49700ae67 100644 --- a/packages/frontend/core/src/modules/doc/stores/doc-properties.ts +++ b/packages/frontend/core/src/modules/doc/stores/doc-properties.ts @@ -1,33 +1,22 @@ -import { Store, yjsObserveByPath, yjsObserveDeep } from '@toeverything/infra'; -import { differenceBy, isNil, omitBy } from 'lodash-es'; +import { + LiveData, + Store, + yjsGetPath, + yjsObserveDeep, +} from '@toeverything/infra'; +import { isNil, omitBy } from 'lodash-es'; import { combineLatest, map, switchMap } from 'rxjs'; import { AbstractType as YAbstractType } from 'yjs'; import type { WorkspaceDBService } from '../../db'; -import type { - DocCustomPropertyInfo, - DocProperties, -} from '../../db/schema/schema'; +import type { DocProperties } from '../../db/schema/schema'; import type { WorkspaceService } from '../../workspace'; -import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants'; interface LegacyDocProperties { custom?: Record; system?: Record; } -type LegacyDocPropertyInfo = { - id?: string; - name?: string; - type?: string; - icon?: string; -}; - -type LegacyDocPropertyInfoList = Record< - string, - LegacyDocPropertyInfo | undefined ->; - export class DocPropertiesStore extends Store { constructor( private readonly workspaceService: WorkspaceService, @@ -43,92 +32,6 @@ export class DocPropertiesStore extends Store { }); } - getDocPropertyInfoList() { - const db = this.dbService.db.docCustomPropertyInfo.find(); - const legacy = this.upgradeLegacyDocPropertyInfoList( - this.getLegacyDocPropertyInfoList() - ); - const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; - const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; - const all = [ - ...withLegacy, - ...differenceBy(builtIn, withLegacy, i => i.id), - ]; - return all.filter(i => !i.isDeleted); - } - - createDocPropertyInfo( - config: Omit & { id?: string } - ) { - return this.dbService.db.docCustomPropertyInfo.create(config); - } - - removeDocPropertyInfo(id: string) { - this.updateDocPropertyInfo(id, { - additionalData: {}, // also remove additional data to reduce size - isDeleted: true, - }); - } - - updateDocPropertyInfo(id: string, config: Partial) { - const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id); - const isBuiltIn = - needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id); - if (isBuiltIn) { - this.createPropertyFromBuiltIn(id, config); - } else if (needMigration) { - // if this property is not in db, we need to migration it from legacy to db, only type and name is needed - this.migrateLegacyDocPropertyInfo(id, config); - } else { - this.dbService.db.docCustomPropertyInfo.update(id, config); - } - } - - migrateLegacyDocPropertyInfo( - id: string, - override: Partial - ) { - const legacy = this.getLegacyDocPropertyInfo(id); - this.dbService.db.docCustomPropertyInfo.create({ - id, - type: - legacy?.type ?? - 'unknown' /* should never reach here, just for safety, we need handle unknown property type */, - name: legacy?.name, - ...override, - }); - } - - createPropertyFromBuiltIn( - id: string, - override: Partial - ) { - const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id); - if (!builtIn) { - return; - } - this.createDocPropertyInfo({ ...builtIn, ...override }); - } - - watchDocPropertyInfoList() { - return combineLatest([ - this.watchLegacyDocPropertyInfoList().pipe( - map(this.upgradeLegacyDocPropertyInfoList) - ), - this.dbService.db.docCustomPropertyInfo.find$(), - ]).pipe( - map(([legacy, db]) => { - const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; - const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; - const all = [ - ...withLegacy, - ...differenceBy(builtIn, withLegacy, i => i.id), - ]; - return all.filter(i => !i.isDeleted); - }) - ); - } - getDocProperties(id: string) { return { ...this.upgradeLegacyDocProperties(this.getLegacyDocProperties(id)), @@ -137,28 +40,6 @@ export class DocPropertiesStore extends Store { }; } - watchAllDocProperties() { - const allDocProperties$ = this.dbService.db.docProperties.find$(); - const allLegacyDocProperties$ = this.watchAllLegacyDocProperties(); - - return combineLatest([allDocProperties$, allLegacyDocProperties$]).pipe( - map(([db, legacy]) => { - const map = new Map(db.map(i => [i.id, i])); - const allIds = new Set([...map.keys(), ...Object.keys(legacy ?? {})]); - - const result = {} as Record>; - - for (const id of allIds) { - result[id] = { - ...this.upgradeLegacyDocProperties(legacy?.[id]), - ...omitBy(map.get(id), isNil), - }; - } - return result; - }) - ); - } - watchDocProperties(id: string) { return combineLatest([ this.watchLegacyDocProperties(id).pipe( @@ -176,6 +57,20 @@ export class DocPropertiesStore extends Store { ); } + /** + * find doc ids by property key and value + * + * this apis will not include legacy properties + */ + watchPropertyAllValues(propertyKey: string) { + return LiveData.from>( + this.dbService.db.docProperties + .select$(propertyKey) + .pipe(map(o => new Map(o.map(i => [i.id, i[propertyKey]])))), + new Map() + ); + } + private upgradeLegacyDocProperties(properties?: LegacyDocProperties) { if (!properties) { return {}; @@ -194,29 +89,6 @@ export class DocPropertiesStore extends Store { return newProperties; } - private upgradeLegacyDocPropertyInfoList( - infoList?: LegacyDocPropertyInfoList - ) { - if (!infoList) { - return []; - } - - const newInfoList: DocCustomPropertyInfo[] = []; - - for (const [id, info] of Object.entries(infoList ?? {})) { - if (info?.type) { - newInfoList.push({ - id, - name: info.name, - type: info.type, - icon: info.icon, - }); - } - } - - return newInfoList; - } - private getLegacyDocProperties(id: string) { return this.workspaceService.workspace.rootYDoc .getMap('affine:workspace-properties') @@ -225,25 +97,8 @@ export class DocPropertiesStore extends Store { ?.toJSON() as LegacyDocProperties | undefined; } - private watchAllLegacyDocProperties() { - return yjsObserveByPath( - this.workspaceService.workspace.rootYDoc.getMap( - 'affine:workspace-properties' - ), - `pageProperties` - ).pipe( - switchMap(yjsObserveDeep), - map( - p => - (p instanceof YAbstractType ? p.toJSON() : p) as - | { [docId: string]: LegacyDocProperties } - | undefined - ) - ); - } - private watchLegacyDocProperties(id: string) { - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap( 'affine:workspace-properties' ), @@ -258,40 +113,4 @@ export class DocPropertiesStore extends Store { ) ); } - - private getLegacyDocPropertyInfoList() { - return this.workspaceService.workspace.rootYDoc - .getMap('affine:workspace-properties') - .get('schema') - ?.get('pageProperties') - ?.get('custom') - ?.toJSON() as LegacyDocPropertyInfoList | undefined; - } - - private watchLegacyDocPropertyInfoList() { - return yjsObserveByPath( - this.workspaceService.workspace.rootYDoc.getMap( - 'affine:workspace-properties' - ), - 'schema.pageProperties.custom' - ).pipe( - switchMap(yjsObserveDeep), - map( - p => - (p instanceof YAbstractType ? p.toJSON() : p) as - | LegacyDocPropertyInfoList - | undefined - ) - ); - } - - private getLegacyDocPropertyInfo(id: string) { - return this.workspaceService.workspace.rootYDoc - .getMap('affine:workspace-properties') - .get('schema') - ?.get('pageProperties') - ?.get('custom') - ?.get(id) - ?.toJSON() as LegacyDocPropertyInfo | undefined; - } } diff --git a/packages/frontend/core/src/modules/doc/stores/docs.ts b/packages/frontend/core/src/modules/doc/stores/docs.ts index 0e3b0e48a2..933a1c1215 100644 --- a/packages/frontend/core/src/modules/doc/stores/docs.ts +++ b/packages/frontend/core/src/modules/doc/stores/docs.ts @@ -2,9 +2,10 @@ import type { DocMode } from '@blocksuite/affine/model'; import type { DocMeta } from '@blocksuite/affine/store'; import { Store, + yjsGetPath, yjsObserve, - yjsObserveByPath, yjsObserveDeep, + yjsObservePath, } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { distinctUntilChanged, map, switchMap } from 'rxjs'; @@ -63,7 +64,7 @@ export class DocsStore extends Store { } watchDocIds() { - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap('meta'), 'pages' ).pipe( @@ -78,8 +79,65 @@ export class DocsStore extends Store { ); } + watchAllDocUpdatedDate() { + return yjsGetPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(pages => yjsObservePath(pages, '*.updatedDate')), + map(pages => { + if (pages instanceof YArray) { + return pages.map(v => ({ + id: v.get('id') as string, + updatedDate: v.get('updatedDate') as number | undefined, + })); + } else { + return []; + } + }) + ); + } + + watchAllDocTagIds() { + return yjsGetPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(pages => yjsObservePath(pages, '*.tags')), + map(pages => { + if (pages instanceof YArray) { + return pages.map(v => ({ + id: v.get('id') as string, + tags: (v.get('tags')?.toJSON() ?? []) as string[], + })); + } else { + return []; + } + }) + ); + } + + watchAllDocCreateDate() { + return yjsGetPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(pages => yjsObservePath(pages, '*.createDate')), + map(pages => { + if (pages instanceof YArray) { + return pages.map(v => ({ + id: v.get('id') as string, + createDate: (v.get('createDate') ?? 0) as number, + })); + } else { + return []; + } + }) + ); + } + watchNonTrashDocIds() { - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap('meta'), 'pages' ).pipe( @@ -97,7 +155,7 @@ export class DocsStore extends Store { } watchTrashDocIds() { - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap('meta'), 'pages' ).pipe( @@ -116,7 +174,7 @@ export class DocsStore extends Store { watchDocMeta(id: string) { let docMetaIndexCache = -1; - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap('meta'), 'pages' ).pipe( diff --git a/packages/frontend/core/src/modules/editor/entities/editor.ts b/packages/frontend/core/src/modules/editor/entities/editor.ts index d44e4c9ca5..fbcdb91c24 100644 --- a/packages/frontend/core/src/modules/editor/entities/editor.ts +++ b/packages/frontend/core/src/modules/editor/entities/editor.ts @@ -1,5 +1,5 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor'; -import type { DefaultOpenProperty } from '@affine/core/components/doc-properties'; +import type { DefaultOpenProperty } from '@affine/core/components/properties'; import { PresentTool } from '@blocksuite/affine/blocks/frame'; import { DefaultTool } from '@blocksuite/affine/blocks/surface'; import type { DocTitle } from '@blocksuite/affine/fragments/doc-title'; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index dac1223389..21be3365be 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -11,6 +11,7 @@ import { configAtMenuConfigModule } from './at-menu-config'; import { configureBlobManagementModule } from './blob-management'; import { configureCloudModule } from './cloud'; import { configureCollectionModule } from './collection'; +import { configureCollectionRulesModule } from './collection-rules'; import { configureWorkspaceDBModule } from './db'; import { configureDialogModule } from './dialogs'; import { configureDndModule } from './dnd'; @@ -56,6 +57,7 @@ import { configureThemeEditorModule } from './theme-editor'; import { configureUrlModule } from './url'; import { configureUserspaceModule } from './userspace'; import { configureWorkspaceModule } from './workspace'; +import { configureWorkspacePropertyModule } from './workspace-property'; export function configureCommonModules(framework: Framework) { configureI18nModule(framework); @@ -110,4 +112,6 @@ export function configureCommonModules(framework: Framework) { configureImportClipperModule(framework); configureNotificationModule(framework); configureIntegrationModule(framework); + configureWorkspacePropertyModule(framework); + configureCollectionRulesModule(framework); } diff --git a/packages/frontend/core/src/modules/peek-view/view/utils.ts b/packages/frontend/core/src/modules/peek-view/view/utils.ts index c8be85b750..0f8f60c217 100644 --- a/packages/frontend/core/src/modules/peek-view/view/utils.ts +++ b/packages/frontend/core/src/modules/peek-view/view/utils.ts @@ -1,4 +1,4 @@ -import type { DefaultOpenProperty } from '@affine/core/components/doc-properties'; +import type { DefaultOpenProperty } from '@affine/core/components/properties'; import type { DocMode } from '@blocksuite/affine/model'; import { useLiveData, useService } from '@toeverything/infra'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; diff --git a/packages/frontend/core/src/modules/permissions/services/member-search.ts b/packages/frontend/core/src/modules/permissions/services/member-search.ts index b5a2f7c760..e2bab0db46 100644 --- a/packages/frontend/core/src/modules/permissions/services/member-search.ts +++ b/packages/frontend/core/src/modules/permissions/services/member-search.ts @@ -66,6 +66,7 @@ export class MemberSearchService extends Service { } search(searchText?: string) { + console.log('search', searchText); this.reset(); this.searchText$.setValue(searchText ?? ''); this.loadMore(); diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts index 4f315a0d83..4ead75a03b 100644 --- a/packages/frontend/core/src/modules/search-menu/services/index.ts +++ b/packages/frontend/core/src/modules/search-menu/services/index.ts @@ -262,12 +262,12 @@ export class SearchMenuService extends Service { query, }), items: result.map(item => { - const title = this.highlightFuseTitle( + const name = this.highlightFuseTitle( item.matches, - item.item.title, - 'title' + item.item.name, + 'name' ); - return this.toTagMenuItem({ ...item.item, title }, action); + return this.toTagMenuItem({ ...item.item, name }, action); }), }; } @@ -285,7 +285,7 @@ export class SearchMenuService extends Service { `; return { key: tag.id, - name: html`${unsafeHTML(tag.title)}`, + name: html`${unsafeHTML(tag.name)}`, icon: tagIcon, action: async () => { await action(tag); diff --git a/packages/frontend/core/src/modules/tag/entities/tag-list.ts b/packages/frontend/core/src/modules/tag/entities/tag-list.ts index 441d6bb417..ed14638d82 100644 --- a/packages/frontend/core/src/modules/tag/entities/tag-list.ts +++ b/packages/frontend/core/src/modules/tag/entities/tag-list.ts @@ -58,7 +58,7 @@ export class TagList extends Entity { return get(this.tags$).map(tag => { return { id: tag.id, - title: get(tag.value$), + name: get(tag.value$), color: get(tag.color$), createDate: get(tag.createDate$), updatedDate: get(tag.updateDate$), diff --git a/packages/frontend/core/src/modules/tag/stores/tag.ts b/packages/frontend/core/src/modules/tag/stores/tag.ts index 556760b3e2..e6518c50b2 100644 --- a/packages/frontend/core/src/modules/tag/stores/tag.ts +++ b/packages/frontend/core/src/modules/tag/stores/tag.ts @@ -1,10 +1,9 @@ -import type { Tag, Tag as TagSchema } from '@affine/env/filter'; import type { DocsPropertiesMeta } from '@blocksuite/affine/store'; import { LiveData, Store, - yjsObserveByPath, - yjsObserveDeep, + yjsGetPath, + yjsObservePath, } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { map, Observable, switchMap } from 'rxjs'; @@ -12,6 +11,15 @@ import { Array as YArray } from 'yjs'; import type { WorkspaceService } from '../../workspace'; +export type Tag = { + value: string; + id: string; + color: string; + createDate?: number | Date | undefined; + updateDate?: number | Date | undefined; + parentId?: string | undefined; +}; + export class TagStore extends Store { get properties() { return this.workspaceService.workspace.docCollection.meta.properties; @@ -105,13 +113,13 @@ export class TagStore extends Store { watchTagInfo(id: string) { return this.tagOptions$.map( - tags => tags.find(tag => tag.id === id) as TagSchema | undefined + tags => tags.find(tag => tag.id === id) as Tag | undefined ); } - updateTagInfo(id: string, tagInfo: Partial) { + updateTagInfo(id: string, tagInfo: Partial) { const tag = this.tagOptions$.value.find(tag => tag.id === id) as - | TagSchema + | Tag | undefined; if (!tag) { return; @@ -127,11 +135,13 @@ export class TagStore extends Store { } watchTagPageIds(id: string) { - return yjsObserveByPath( + return yjsGetPath( this.workspaceService.workspace.rootYDoc.getMap('meta'), 'pages' ).pipe( - switchMap(yjsObserveDeep), + switchMap(pages => { + return yjsObservePath(pages, '*.tags'); + }), map(meta => { if (meta instanceof YArray) { return meta diff --git a/packages/frontend/core/src/modules/doc/constants.ts b/packages/frontend/core/src/modules/workspace-property/constants.ts similarity index 100% rename from packages/frontend/core/src/modules/doc/constants.ts rename to packages/frontend/core/src/modules/workspace-property/constants.ts diff --git a/packages/frontend/core/src/modules/workspace-property/index.ts b/packages/frontend/core/src/modules/workspace-property/index.ts new file mode 100644 index 0000000000..8828135979 --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-property/index.ts @@ -0,0 +1,17 @@ +import type { Framework } from '@toeverything/infra'; + +import { WorkspaceDBService } from '../db'; +import { WorkspaceService } from '../workspace'; +import { WorkspaceScope } from '../workspace/scopes/workspace'; +import { WorkspacePropertyService } from './services/workspace-property'; +import { WorkspacePropertyStore } from './stores/workspace-property'; + +export { WorkspacePropertyService } from './services/workspace-property'; +export type * from './types'; + +export function configureWorkspacePropertyModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(WorkspacePropertyService, [WorkspacePropertyStore]) + .store(WorkspacePropertyStore, [WorkspaceService, WorkspaceDBService]); +} diff --git a/packages/frontend/core/src/modules/doc/entities/property-list.ts b/packages/frontend/core/src/modules/workspace-property/services/workspace-property.ts similarity index 79% rename from packages/frontend/core/src/modules/doc/entities/property-list.ts rename to packages/frontend/core/src/modules/workspace-property/services/workspace-property.ts index b7a146bc5d..98108d0c9a 100644 --- a/packages/frontend/core/src/modules/doc/entities/property-list.ts +++ b/packages/frontend/core/src/modules/workspace-property/services/workspace-property.ts @@ -1,19 +1,21 @@ import { - Entity, generateFractionalIndexingKeyBetween, LiveData, + Service, } from '@toeverything/infra'; import type { DocCustomPropertyInfo } from '../../db/schema/schema'; -import type { DocPropertiesStore } from '../stores/doc-properties'; +import type { WorkspacePropertyStore } from '../stores/workspace-property'; -export class DocPropertyList extends Entity { - constructor(private readonly docPropertiesStore: DocPropertiesStore) { +export class WorkspacePropertyService extends Service { + constructor( + private readonly workspacePropertiesStore: WorkspacePropertyStore + ) { super(); } properties$ = LiveData.from( - this.docPropertiesStore.watchDocPropertyInfoList(), + this.workspacePropertiesStore.watchWorkspaceProperties(), [] ); @@ -27,17 +29,17 @@ export class DocPropertyList extends Entity { } updatePropertyInfo(id: string, properties: Partial) { - this.docPropertiesStore.updateDocPropertyInfo(id, properties); + this.workspacePropertiesStore.updateWorkspaceProperty(id, properties); } createProperty( properties: Omit & { id?: string } ) { - return this.docPropertiesStore.createDocPropertyInfo(properties); + return this.workspacePropertiesStore.createWorkspaceProperty(properties); } removeProperty(id: string) { - this.docPropertiesStore.removeDocPropertyInfo(id); + this.workspacePropertiesStore.removeWorkspaceProperty(id); } indexAt(at: 'before' | 'after', targetId?: string) { diff --git a/packages/frontend/core/src/modules/workspace-property/stores/workspace-property.ts b/packages/frontend/core/src/modules/workspace-property/stores/workspace-property.ts new file mode 100644 index 0000000000..5e594059eb --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-property/stores/workspace-property.ts @@ -0,0 +1,175 @@ +import { Store, yjsGetPath, yjsObserveDeep } from '@toeverything/infra'; +import { differenceBy } from 'lodash-es'; +import { combineLatest, map, switchMap } from 'rxjs'; +import { AbstractType as YAbstractType } from 'yjs'; + +import type { WorkspaceDBService } from '../../db'; +import type { DocCustomPropertyInfo } from '../../db/schema/schema'; +import type { WorkspaceService } from '../../workspace'; +import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants'; +import type { WorkspacePropertyType } from '../types'; + +type LegacyWorkspacePropertyInfo = { + id?: string; + name?: string; + type?: string; + icon?: string; +}; + +type LegacyWorkspacePropertyInfoList = Record< + string, + LegacyWorkspacePropertyInfo | undefined +>; + +export class WorkspacePropertyStore extends Store { + constructor( + private readonly workspaceService: WorkspaceService, + private readonly dbService: WorkspaceDBService + ) { + super(); + } + + getWorkspaceProperties() { + const db = this.dbService.db.docCustomPropertyInfo.find(); + const legacy = this.upgradeLegacyWorkspacePropertyInfoList( + this.getLegacyWorkspacePropertyInfoList() + ); + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; + const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; + const all = [ + ...withLegacy, + ...differenceBy(builtIn, withLegacy, i => i.id), + ]; + return all.filter(i => !i.isDeleted); + } + + createWorkspaceProperty( + config: Omit & { id?: string } + ) { + return this.dbService.db.docCustomPropertyInfo.create(config); + } + + removeWorkspaceProperty(id: string) { + this.updateWorkspaceProperty(id, { + additionalData: {}, // also remove additional data to reduce size + isDeleted: true, + }); + } + + updateWorkspaceProperty(id: string, config: Partial) { + const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id); + const isBuiltIn = + needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id); + if (isBuiltIn) { + this.createWorkspacePropertyFromBuiltIn(id, config); + } else if (needMigration) { + // if this property is not in db, we need to migration it from legacy to db, only type and name is needed + this.migrateLegacyWorkspaceProperty(id, config); + } else { + this.dbService.db.docCustomPropertyInfo.update(id, config); + } + } + + migrateLegacyWorkspaceProperty( + id: string, + override: Partial + ) { + const legacy = this.getLegacyWorkspacePropertyInfo(id); + this.dbService.db.docCustomPropertyInfo.create({ + id, + type: (legacy?.type ?? + 'unknown') /* should never reach here, just for safety, we need handle unknown property type */ as WorkspacePropertyType, + name: legacy?.name, + ...override, + }); + } + + createWorkspacePropertyFromBuiltIn( + id: string, + override: Partial + ) { + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id); + if (!builtIn) { + return; + } + this.createWorkspaceProperty({ ...builtIn, ...override }); + } + + watchWorkspaceProperties() { + return combineLatest([ + this.watchLegacyWorkspacePropertyInfoList().pipe( + map(this.upgradeLegacyWorkspacePropertyInfoList) + ), + this.dbService.db.docCustomPropertyInfo.find$(), + ]).pipe( + map(([legacy, db]) => { + const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE; + const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)]; + const all = [ + ...withLegacy, + ...differenceBy(builtIn, withLegacy, i => i.id), + ]; + return all.filter(i => !i.isDeleted); + }) + ); + } + + private upgradeLegacyWorkspacePropertyInfoList( + infoList?: LegacyWorkspacePropertyInfoList + ) { + if (!infoList) { + return []; + } + + const newInfoList: DocCustomPropertyInfo[] = []; + + for (const [id, info] of Object.entries(infoList ?? {})) { + if (info?.type) { + newInfoList.push({ + id, + name: info.name, + type: info.type as WorkspacePropertyType, + icon: info.icon, + }); + } + } + + return newInfoList; + } + + private getLegacyWorkspacePropertyInfoList() { + return this.workspaceService.workspace.rootYDoc + .getMap('affine:workspace-properties') + .get('schema') + ?.get('pageProperties') + ?.get('custom') + ?.toJSON() as LegacyWorkspacePropertyInfoList | undefined; + } + + private watchLegacyWorkspacePropertyInfoList() { + return yjsGetPath( + this.workspaceService.workspace.rootYDoc.getMap( + 'affine:workspace-properties' + ), + 'schema.pageProperties.custom' + ).pipe( + switchMap(yjsObserveDeep), + map( + p => + (p instanceof YAbstractType ? p.toJSON() : p) as + | LegacyWorkspacePropertyInfoList + | undefined + ) + ); + } + + private getLegacyWorkspacePropertyInfo(id: string) { + return this.workspaceService.workspace.rootYDoc + .getMap('affine:workspace-properties') + .get('schema') + ?.get('pageProperties') + ?.get('custom') + ?.get(id) + ?.toJSON() as LegacyWorkspacePropertyInfo | undefined; + } +} diff --git a/packages/frontend/core/src/modules/workspace-property/types.ts b/packages/frontend/core/src/modules/workspace-property/types.ts new file mode 100644 index 0000000000..5f0fca80e5 --- /dev/null +++ b/packages/frontend/core/src/modules/workspace-property/types.ts @@ -0,0 +1,44 @@ +type DateFilters = + | 'after' + | 'before' + | 'between' + | 'last-3-days' + | 'last-7-days' + | 'last-15-days' + | 'last-30-days' + | 'this-month' + | 'this-week' + | 'this-quarter' + | 'this-year'; + +export type WorkspacePropertyTypes = { + tags: { + filter: 'include' | 'is-not-empty' | 'is-empty'; + }; + text: { + filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty'; + }; + number: { + filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty'; + }; + checkbox: { + filter: 'is' | 'is-not'; + }; + date: { + filter: DateFilters | 'is-not-empty' | 'is-empty'; + }; + createdBy: { filter: 'include' }; + updatedBy: { filter: 'include' }; + updatedAt: { filter: DateFilters }; + createdAt: { filter: DateFilters }; + docPrimaryMode: { filter: 'is' | 'is-not' }; + journal: { filter: 'is' | 'is-not' }; + edgelessTheme: { filter: never }; + pageWidth: { filter: never }; + template: { filter: never }; + unknown: { filter: never }; +}; +export type WorkspacePropertyType = keyof WorkspacePropertyTypes; + +export type WorkspacePropertyFilter = + WorkspacePropertyTypes[T]['filter']; diff --git a/packages/frontend/core/src/modules/workspace/entities/workspace.ts b/packages/frontend/core/src/modules/workspace/entities/workspace.ts index 805f84fd36..809859eccf 100644 --- a/packages/frontend/core/src/modules/workspace/entities/workspace.ts +++ b/packages/frontend/core/src/modules/workspace/entities/workspace.ts @@ -1,5 +1,5 @@ import type { Workspace as WorkspaceInterface } from '@blocksuite/affine/store'; -import { Entity, LiveData, yjsObserveByPath } from '@toeverything/infra'; +import { Entity, LiveData, yjsGetPath } from '@toeverything/infra'; import type { Observable } from 'rxjs'; import { Doc as YDoc, transact } from 'yjs'; @@ -80,14 +80,14 @@ export class Workspace extends Entity { } name$ = LiveData.from( - yjsObserveByPath(this.rootYDoc.getMap('meta'), 'name') as Observable< + yjsGetPath(this.rootYDoc.getMap('meta'), 'name') as Observable< string | undefined >, undefined ); avatar$ = LiveData.from( - yjsObserveByPath(this.rootYDoc.getMap('meta'), 'avatar') as Observable< + yjsGetPath(this.rootYDoc.getMap('meta'), 'avatar') as Observable< string | undefined >, undefined diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index e0a59b1b6c..4ad4315d86 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,26 +1,26 @@ { - "ar": 98, + "ar": 97, "ca": 4, "da": 4, - "de": 98, - "el-GR": 98, + "de": 97, + "el-GR": 97, "en": 100, - "es-AR": 98, - "es-CL": 99, - "es": 98, - "fa": 98, - "fr": 98, + "es-AR": 97, + "es-CL": 98, + "es": 97, + "fa": 97, + "fr": 97, "hi": 2, - "it-IT": 98, + "it-IT": 97, "it": 1, - "ja": 98, + "ja": 97, "ko": 56, - "pl": 98, - "pt-BR": 98, - "ru": 98, - "sv-SE": 98, - "uk": 98, + "pl": 97, + "pt-BR": 97, + "ru": 97, + "sv-SE": 97, + "uk": 97, "ur": 2, - "zh-Hans": 98, - "zh-Hant": 98 + "zh-Hans": 97, + "zh-Hant": 97 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index af4b2c2273..59f43fddfb 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -1843,6 +1843,10 @@ export function useAFFiNEI18N(): { * `is` */ ["com.affine.editCollection.rules.include.is"](): string; + /** + * `is-not` + */ + ["com.affine.editCollection.rules.include.is-not"](): string; /** * `Doc` */ @@ -2085,6 +2089,10 @@ export function useAFFiNEI18N(): { * `Empty` */ ["com.affine.filter.empty-tag"](): string; + /** + * `Empty` + */ + ["com.affine.filter.empty"](): string; /** * `false` */ @@ -2109,6 +2117,42 @@ export function useAFFiNEI18N(): { * `Shared` */ ["com.affine.filter.is-public"](): string; + /** + * `between` + */ + ["com.affine.filter.between"](): string; + /** + * `last 3 days` + */ + ["com.affine.filter.last 3 days"](): string; + /** + * `last 7 days` + */ + ["com.affine.filter.last 7 days"](): string; + /** + * `last 15 days` + */ + ["com.affine.filter.last 15 days"](): string; + /** + * `last 30 days` + */ + ["com.affine.filter.last 30 days"](): string; + /** + * `this week` + */ + ["com.affine.filter.this week"](): string; + /** + * `this month` + */ + ["com.affine.filter.this month"](): string; + /** + * `this quarter` + */ + ["com.affine.filter.this quarter"](): string; + /** + * `this year` + */ + ["com.affine.filter.this year"](): string; /** * `last` */ @@ -2125,6 +2169,18 @@ export function useAFFiNEI18N(): { * `Add filter` */ ["com.affine.filterList.button.add"](): string; + /** + * `Display` + */ + ["com.affine.explorer.display-menu.button"](): string; + /** + * `Grouping` + */ + ["com.affine.explorer.display-menu.grouping"](): string; + /** + * `Ordering` + */ + ["com.affine.explorer.display-menu.ordering"](): string; /** * `View in Page mode` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index d8d0b84e7f..1b567d46f1 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -459,6 +459,7 @@ "com.affine.editCollection.rules.empty.noRules.tips": "Please <1>add rules to save this collection or switch to <3>Docs, use manual selection mode", "com.affine.editCollection.rules.include.add": "Add selected doc", "com.affine.editCollection.rules.include.is": "is", + "com.affine.editCollection.rules.include.is-not": "is-not", "com.affine.editCollection.rules.include.page": "Doc", "com.affine.editCollection.rules.include.tips": "“Selected docs” refers to manually adding docs rather than automatically adding them through rule matching. You can manually add docs through the “Add selected docs” option or by dragging and dropping.", "com.affine.editCollection.rules.include.tipsTitle": "What is \"Selected docs\"?", @@ -521,16 +522,29 @@ "com.affine.filter.does not contains all": "does not contains all", "com.affine.filter.does not contains one of": "does not contains one of", "com.affine.filter.empty-tag": "Empty", + "com.affine.filter.empty": "Empty", "com.affine.filter.false": "false", "com.affine.filter.is": "is", "com.affine.filter.is empty": "is empty", "com.affine.filter.is not empty": "is not empty", "com.affine.filter.is-favourited": "Favourited", "com.affine.filter.is-public": "Shared", + "com.affine.filter.between": "between", + "com.affine.filter.last 3 days": "last 3 days", + "com.affine.filter.last 7 days": "last 7 days", + "com.affine.filter.last 15 days": "last 15 days", + "com.affine.filter.last 30 days": "last 30 days", + "com.affine.filter.this week": "this week", + "com.affine.filter.this month": "this month", + "com.affine.filter.this quarter": "this quarter", + "com.affine.filter.this year": "this year", "com.affine.filter.last": "last", "com.affine.filter.save-view": "Save view", "com.affine.filter.true": "true", "com.affine.filterList.button.add": "Add filter", + "com.affine.explorer.display-menu.button": "Display", + "com.affine.explorer.display-menu.grouping": "Grouping", + "com.affine.explorer.display-menu.ordering": "Ordering", "com.affine.header.mode-switch.page": "View in Page mode", "com.affine.header.mode-switch.edgeless": "View in Edgeless Canvas", "com.affine.header.option.add-tag": "Add tag",