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

whats changed:

### orm

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

### yjs-observable

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

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

### standard property naming

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

### `WorkspacePropertyService`

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

### new `<Filters />` component

in `core/component/filter`

### new `<ExplorerDisplayMenuButton />` component

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

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

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

New route for test components and functions

### new collection role service

Implemented some filter group order rules

see `collection-rules/index.ts`

### standard property type definition

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

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

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

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

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

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

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

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

- **Chores**
  - Added and refined test cases for ORM, observables, and filtering logic.
  - Cleaned up legacy document property code and improved type safety.
  - Modularized and restructured components for better maintainability.
  - Introduced new CSS styles for workspace pages and display menus.
  - Added framework module configurations for collection rules and workspace property features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-08 08:38:56 +00:00
parent f9e003d220
commit 8399d99e79
174 changed files with 5644 additions and 980 deletions

View File

@@ -74,14 +74,4 @@ export type DeleteCollectionInfo = {
} | null; } | null;
export type DeletedCollection = z.input<typeof deletedCollectionSchema>; export type DeletedCollection = z.input<typeof deletedCollectionSchema>;
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<typeof tagSchema>;
export type PropertiesMeta = DocsPropertiesMeta; export type PropertiesMeta = DocsPropertiesMeta;

View File

@@ -102,6 +102,102 @@ describe('ORM entity CRUD', () => {
expect(user2).toEqual(user); 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 => { test('should be able to filter with nullable condition', t => {
const { client } = t; const { client } = t;

View File

@@ -6,6 +6,7 @@ import {
type Transaction, type Transaction,
} from 'yjs'; } from 'yjs';
import { shallowEqual } from '../../../../utils/shallow-equal';
import { validators } from '../../validators'; import { validators } from '../../validators';
import { HookAdapter } from '../mixins'; import { HookAdapter } from '../mixins';
import type { import type {
@@ -133,7 +134,16 @@ export class YjsTableAdapter implements TableAdapter {
if (isMatch && isPrevMatched) { if (isMatch && isPrevMatched) {
const newValue = this.value(record, select); 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); results.set(key, newValue);
hasChanged = true; hasChanged = true;
} }

View File

@@ -273,6 +273,62 @@ export class Table<T extends TableSchemaBuilder> {
}); });
} }
select<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Pick<Entity<T>, Key | PrimaryKeyField<T>>[] {
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<Entity<T>, Key | PrimaryKeyField<T>>;
});
}
select$<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Observable<Pick<Entity<T>, Key | PrimaryKeyField<T>>[]> {
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<Entity<T>, Key | PrimaryKeyField<T>>;
})
);
},
});
return unsubscribe;
});
}
keys(): PrimaryKeyFieldType<T>[] { keys(): PrimaryKeyFieldType<T>[] {
return this.adapter.find({ return this.adapter.find({
select: 'key', select: 'key',

View File

@@ -1,13 +1,13 @@
import { describe, expect, test } from 'vitest'; 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', () => { describe('yjs observable', () => {
test('basic', async () => { test('basic', async () => {
const ydoc = new YDoc(); const ydoc = new YDoc();
let currentValue: any = false; let currentValue: any = false;
yjsObserveByPath(ydoc.getMap('foo'), 'key.subkey').subscribe( yjsGetPath(ydoc.getMap('foo'), 'key.subkey').subscribe(
v => (currentValue = v) v => (currentValue = v)
); );
expect(currentValue).toBe(undefined); expect(currentValue).toBe(undefined);
@@ -28,4 +28,84 @@ describe('yjs observable', () => {
ydoc.getMap('foo').set('key', 'text'); ydoc.getMap('foo').set('key', 'text');
expect(currentValue).toBe(undefined); 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<any>());
expect(callbackCount).toBe(2);
expect(currentValue).toStrictEqual([]);
const pages = ydoc.getMap('metas').get('pages') as YArray<any>;
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: [] }]);
});
}); });

View File

@@ -3,6 +3,9 @@ import {
AbstractType as YAbstractType, AbstractType as YAbstractType,
Array as YArray, Array as YArray,
Map as YMap, Map as YMap,
YArrayEvent,
type YEvent,
YMapEvent,
} from 'yjs'; } from 'yjs';
/** /**
@@ -13,7 +16,11 @@ function parsePath(path: string): (string | number)[] {
const parts = path.split('.'); const parts = path.split('.');
return parts.map(part => { return parts.map(part => {
if (part.startsWith('[') && part.endsWith(']')) { 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)) { if (isNaN(index)) {
throw new Error(`index: ${part} is not a number`); throw new Error(`index: ${part} is not a number`);
} }
@@ -65,11 +72,11 @@ function _yjsDeepWatch(
* this function is optimized for deep watch performance. * this function is optimized for deep watch performance.
* *
* @example * @example
* yjsObserveByPath(yjs, 'pages.[0].id') -> only emit when pages[0].id changed * yjsGetPath(yjs, 'pages.[0].id') -> get pages[0].id and emit when changed
* yjsObserveByPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> emit when any of pages[0] or its children changed * yjsGetPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> get pages[0] and 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]').switchMap(yjsObserveDeep) -> get pages[0] and emit when pages[0] or any of its deep children changed
*/ */
export function yjsObserveByPath(yjs: YAbstractType<any>, path: string) { export function yjsGetPath(yjs: YAbstractType<any>, path: string) {
const parsedPath = parsePath(path); const parsedPath = parsePath(path);
return _yjsDeepWatch(yjs, parsedPath); 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<any>[]) => {
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. * convert yjs type to observable.
* observable will automatically update when yjs data changed. * observable will automatically update when yjs data changed.

View File

@@ -24,7 +24,7 @@ export type InputProps = {
endFix?: ReactNode; endFix?: ReactNode;
type?: HTMLInputElement['type']; type?: HTMLInputElement['type'];
inputStyle?: CSSProperties; inputStyle?: CSSProperties;
onEnter?: () => void; onEnter?: (value: string) => void;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>; } & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(

View File

@@ -19,7 +19,7 @@ export type RowInputProps = {
autoSelect?: boolean; autoSelect?: boolean;
type?: HTMLInputElement['type']; type?: HTMLInputElement['type'];
style?: CSSProperties; style?: CSSProperties;
onEnter?: () => void; onEnter?: (value: string) => void;
[key: `data-${string}`]: string; [key: `data-${string}`]: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>; } & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
@@ -84,7 +84,7 @@ export const RowInput = forwardRef<HTMLInputElement, RowInputProps>(
if (e.key !== 'Enter' || composing) { if (e.key !== 'Enter' || composing) {
return; return;
} }
onEnter?.(); onEnter?.(e.currentTarget.value);
}, },
[onKeyDown, composing, onEnter] [onKeyDown, composing, onEnter]
); );

View File

@@ -292,7 +292,7 @@ export const PropertyName = ({
name?: ReactNode; name?: ReactNode;
menuItems?: ReactNode; menuItems?: ReactNode;
defaultOpenMenu?: boolean; defaultOpenMenu?: boolean;
} & HTMLProps<HTMLDivElement>) => { } & Omit<HTMLProps<HTMLDivElement>, 'name'>) => {
const [menuOpen, setMenuOpen] = useState(defaultOpenMenu); const [menuOpen, setMenuOpen] = useState(defaultOpenMenu);
const hasMenu = !!menuItems; const hasMenu = !!menuItems;

View File

@@ -37,9 +37,9 @@ export class ChatPanelTagChip extends SignalWatcher(
override render() { override render() {
const { state } = this.chip; const { state } = this.chip;
const { title, color } = this.tag; const { name, color } = this.tag;
const isLoading = state === 'processing'; const isLoading = state === 'processing';
const tooltip = getChipTooltip(state, title, this.chip.tooltip); const tooltip = getChipTooltip(state, name, this.chip.tooltip);
const tagIcon = html` const tagIcon = html`
<div class="tag-icon-container"> <div class="tag-icon-container">
<div class="tag-icon" style="background-color: ${color};"></div> <div class="tag-icon" style="background-color: ${color};"></div>
@@ -49,7 +49,7 @@ export class ChatPanelTagChip extends SignalWatcher(
return html`<chat-panel-chip return html`<chat-panel-chip
.state=${state} .state=${state}
.name=${title} .name=${name}
.tooltip=${tooltip} .tooltip=${tooltip}
.icon=${icon} .icon=${icon}
.closeable=${!isLoading} .closeable=${!isLoading}

View File

@@ -30,7 +30,7 @@ import clsx from 'clsx';
import type { CSSProperties, HTMLAttributes } from 'react'; import type { CSSProperties, HTMLAttributes } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DefaultOpenProperty } from '../../components/doc-properties'; import type { DefaultOpenProperty } from '../../components/properties';
import { BlocksuiteDocEditor, BlocksuiteEdgelessEditor } from './lit-adaper'; import { BlocksuiteDocEditor, BlocksuiteEdgelessEditor } from './lit-adaper';
import * as styles from './styles.css'; import * as styles from './styles.css';

View File

@@ -42,8 +42,8 @@ import {
import { import {
type DefaultOpenProperty, type DefaultOpenProperty,
DocPropertiesTable, WorkspacePropertiesTable,
} from '../../components/doc-properties'; } from '../../components/properties';
import { BiDirectionalLinkPanel } from './bi-directional-link-panel'; import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title'; import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import { StarterBar } from './starter-bar'; import { StarterBar } from './starter-bar';
@@ -229,7 +229,7 @@ export const BlocksuiteDocEditor = forwardRef<
)} )}
{!shared && displayDocInfo ? ( {!shared && displayDocInfo ? (
<div className={styles.docPropertiesTableContainer}> <div className={styles.docPropertiesTableContainer}>
<DocPropertiesTable <WorkspacePropertiesTable
className={styles.docPropertiesTable} className={styles.docPropertiesTable}
onDatabasePropertyChange={onDatabasePropertyChange} onDatabasePropertyChange={onDatabasePropertyChange}
onPropertyChange={onPropertyChange} onPropertyChange={onPropertyChange}

View File

@@ -1,2 +1,4 @@
import 'core-js/es/set/union.js'; import 'core-js/es/set/union.js';
import 'core-js/es/set/difference.js'; import 'core-js/es/set/difference.js';
import 'core-js/es/set/intersection.js';
import 'core-js/es/set/is-subset-of.js';

View File

@@ -1 +0,0 @@
export * from './table';

View File

@@ -1,145 +0,0 @@
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import {
type TagLike,
TagsInlineEditor as TagsInlineEditorComponent,
} from '../tags';
interface TagsEditorProps {
pageId: string;
readonly?: boolean;
focusedIndex?: number;
}
interface TagsInlineEditorProps extends TagsEditorProps {
placeholder?: string;
className?: string;
onChange?: (value: unknown) => 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 (
<TagsInlineEditorComponent
tagMode="inline-tag"
jumpToTag={jumpToTag}
readonly={readonly}
placeholder={placeholder}
className={className}
tags={adaptedTags}
selectedTags={tagIds}
onCreateTag={onCreateTag}
onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onTagDelete}
title={
<>
<TagsIcon />
{t['Tags']()}
</>
}
/>
);
};

View File

@@ -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 (
<PropertyValue onClick={handleClick} className={styles.container}>
<Checkbox
className={styles.checkboxProperty}
checked={parsedValue}
onChange={() => {}}
disabled={readonly}
/>
</PropertyValue>
);
};

View File

@@ -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<React.SVGProps<SVGSVGElement>>;
value?: React.FC<PropertyValueProps>;
/**
* 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;
};

View File

@@ -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 (
<PropertyValue
className={styles.container}
isEmpty={empty}
data-testid="property-tags-value"
readonly={readonly}
>
<TagsInlineEditor
className={styles.tagInlineEditor}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
pageId={doc.id}
onChange={() => {}}
readonly={readonly}
/>
</PropertyValue>
);
};

View File

@@ -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 ? (
<WorkspacePropertyName propertyInfo={propertyInfo} />
) : null;
};
export const GroupByName = ({ groupBy }: { groupBy: GroupByParams }) => {
const t = useI18n();
if (groupBy.type === 'property') {
return <PropertyGroupByName groupBy={groupBy} />;
}
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 (
<MenuItem
key={v.id}
onClick={e => {
e.preventDefault();
onChange?.({
type: 'property',
key: v.id,
});
}}
suffixIcon={
groupBy?.type === 'property' && groupBy?.key === v.id ? (
<DoneIcon style={{ color: cssVarV2('icon/activated') }} />
) : null
}
>
<WorkspacePropertyName propertyInfo={v} />
</MenuItem>
);
})}
</>
);
};

View File

@@ -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 (
<div className={styles.displayMenuContainer}>
<MenuSub
items={
<GroupByList
groupBy={preference.groupBy}
onChange={handleGroupByChange}
/>
}
>
<div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.grouping']()}</span>
<span className={styles.subMenuSelectorSelected}>
{preference.groupBy ? (
<GroupByName groupBy={preference.groupBy} />
) : null}
</span>
</div>
</MenuSub>
<MenuSub
items={
<OrderByList
orderBy={preference.orderBy}
onChange={handleOrderByChange}
/>
}
>
<div className={styles.subMenuSelectorContainer}>
<span>{t['com.affine.explorer.display-menu.ordering']()}</span>
<span className={styles.subMenuSelectorSelected}>
{preference.orderBy ? (
<OrderByName orderBy={preference.orderBy} />
) : null}
</span>
</div>
</MenuSub>
</div>
);
};
export const ExplorerDisplayMenuButton = ({
style,
className,
preference,
onChange,
}: {
style?: React.CSSProperties;
className?: string;
preference: ExplorerPreference;
onChange?: (preference: ExplorerPreference) => void;
}) => {
const t = useI18n();
return (
<Menu
items={
<ExplorerDisplayMenu preference={preference} onChange={onChange} />
}
>
<Button
className={className}
style={style}
suffix={<ArrowDownSmallIcon />}
>
{t['com.affine.explorer.display-menu.button']()}
</Button>
</Menu>
);
};

View File

@@ -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 ? (
<WorkspacePropertyName propertyInfo={propertyInfo} />
) : null;
};
export const OrderByName = ({ orderBy }: { orderBy: OrderByParams }) => {
const t = useI18n();
if (orderBy.type === 'property') {
return <PropertyOrderByName orderBy={orderBy} />;
}
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 (
<MenuItem
key={v.id}
onClick={e => {
e.preventDefault();
onChange?.({
type: 'property',
key: v.id,
desc: !active ? false : !orderBy.desc,
});
}}
suffixIcon={
active ? (
!orderBy.desc ? (
<SortUpIcon style={{ color: cssVarV2('icon/activated') }} />
) : (
<SortDownIcon style={{ color: cssVarV2('icon/activated') }} />
)
) : null
}
>
<WorkspacePropertyName propertyInfo={v} />
</MenuItem>
);
})}
</>
);
};

View File

@@ -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'),
});

View File

@@ -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;
}

View File

@@ -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 (
<>
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
</div>
<MenuSeparator />
{workspaceProperties.map(property => {
const type = WorkspacePropertyTypes[property.type];
const defaultFilter = type?.defaultFilter;
if (!defaultFilter) {
return null;
}
return (
<MenuItem
prefixIcon={
<WorkspacePropertyIcon
propertyInfo={property}
className={styles.filterTypeItemIcon}
/>
}
key={property.id}
onClick={() => {
onAdd({
type: 'property',
key: property.id,
...defaultFilter,
});
}}
>
<span className={styles.filterTypeItemName}>
<WorkspacePropertyName propertyInfo={property} />
</span>
</MenuItem>
);
})}
</>
);
};
export const AddFilter = ({
onAdd,
}: {
onAdd: (params: FilterParams) => void;
}) => {
return (
<Menu
items={<AddFilterMenu onAdd={onAdd} />}
contentOptions={{
className: styles.addFilterMenuContent,
}}
>
<IconButton size="16">
<PlusIcon />
</IconButton>
</Menu>
);
};

View File

@@ -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 (
<>
<div className={clsx(styles.filterTypeStyle, styles.ellipsisTextStyle)}>
{icon && <div className={styles.filterTypeIconStyle}>{icon}</div>}
{name}
</div>
{methods && (
<Menu
items={methods.map(([method, name]) => (
<MenuItem
onClick={() => {
onChange?.({
...filter,
method,
});
}}
selected={filter.method === method}
key={method}
>
{name}
</MenuItem>
))}
>
<div
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
data-testid="filter-method"
>
{methods.find(([method]) => method === filter.method)?.[1] ??
'unknown'}
</div>
</Menu>
)}
{value && (
<div
className={clsx(styles.filterValueStyle, styles.ellipsisTextStyle)}
data-testid="filter-method"
>
{value}
</div>
)}
</>
);
};

View File

@@ -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 <UnknownFilterCondition filter={filter} />;
}
return (
<Condition
filter={filter}
icon={<WorkspacePropertyIcon propertyInfo={propertyInfo} />}
name={<WorkspacePropertyName propertyInfo={propertyInfo} />}
methods={Object.entries(methods).map(([key, i18nKey]) => [
key,
t.t(i18nKey as string),
])}
value={Value && <Value filter={filter} onChange={onChange} />}
onChange={onChange}
/>
);
};

View File

@@ -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',
},
});

View File

@@ -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 <UnknownFilterCondition filter={filter} />;
}
const methods = type.filterMethod;
const Value = type.filterValue;
return (
<Condition
filter={filter}
icon={<type.icon />}
name={t.t(type.name)}
methods={Object.entries(methods).map(([key, i18nKey]) => [
key,
t.t(i18nKey as string),
])}
value={Value && <Value filter={filter} onChange={onChange} />}
onChange={onChange}
/>
);
};

View File

@@ -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 (
<Condition
filter={filter}
icon={<WarningIcon className={styles.filterTypeIconUnknownStyle} />}
name={<span className={styles.filterTypeUnknownNameStyle}>Unknown</span>}
/>
);
};

View File

@@ -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 (
<div className={styles.filterItemStyle}>
{type === 'property' ? (
<PropertyFilterCondition filter={filter} onChange={onChange} />
) : type === 'system' ? (
<SystemFilterCondition filter={filter} onChange={onChange} />
) : null}
<div className={styles.filterItemCloseStyle} onClick={onDelete}>
<CloseIcon />
</div>
</div>
);
};

View File

@@ -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 (
<div className={styles.container}>
{filters.map((filter, index) => {
return (
<Filter
// oxlint-disable-next-line no-array-index-key
key={index}
filter={filter}
onDelete={() => {
handleDelete(index);
}}
onChange={filter => {
handleChange(index, filter);
}}
/>
);
})}
<AddFilter
onAdd={filter => {
onChange?.(filters.concat(filter));
}}
/>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from './filter';
export * from './filters';

View File

@@ -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',
});

View File

@@ -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 (
<div className={styles.memberItemListMode} style={style}>
<Avatar
url={avatarUrl}
name={name ?? ''}
size={20}
className={styles.memberItemAvatar}
/>
<div className={styles.memberItemLabel}>{name}</div>
</div>
);
};
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<HTMLInputElement>(null);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const [focusedInlineIndex, setFocusedInlineIndex] = useState<number>(-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<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div
style={style}
data-testid="tags-editor-popup"
className={clsx(
className,
BUILD_CONFIG.isMobileEdition
? styles.memberSelectorRootMobile
: styles.memberSelectorRoot
)}
>
<div className={styles.memberSelectorSelectedTags}>
<InlineMemberList
members={selected}
onRemove={handleUnselectMember}
focusedIndex={safeInlineFocusedIndex}
>
<RowInput
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
onEnter={onEnter}
autoFocus
className={styles.searchInput}
placeholder="Type here ..."
/>
</InlineMemberList>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.memberDivider} />
)}
</div>
<div className={styles.memberSelectorBody}>
<Scrollable.Root>
<Scrollable.Viewport
ref={scrollContainerRef}
className={styles.memberSelectorScrollContainer}
>
{searchedMembers.length === 0 && (
<div className={styles.memberSelectorEmpty}>Nothing here yet</div>
)}
{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 (
<div
key={member.id}
{...commonProps}
data-member-id={member.id}
data-member-name={member.name}
>
<MemberSelectItem member={member} />
</div>
);
})}
</Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root>
</div>
</div>
);
};
const MobileMemberSelectorInline = ({
readonly,
placeholder,
className,
title,
style,
...props
}: MemberSelectorInlineProps) => {
const [editing, setEditing] = useState(false);
const empty = !props.selected || props.selected.length === 0;
return (
<>
<ConfigModal
title={title}
open={editing}
onOpenChange={setEditing}
onBack={() => setEditing(false)}
>
<MemberSelector {...props} />
</ConfigModal>
<div
className={clsx(styles.membersSelectorInline, className)}
data-empty={empty}
data-readonly={readonly}
onClick={() => setEditing(true)}
style={style}
>
{empty ? placeholder : <InlineMemberList members={props.selected} />}
</div>
</>
);
};
const DesktopMemberSelectorInline = ({
readonly,
placeholder,
className,
modalMenu,
menuClassName,
style,
selected,
...props
}: MemberSelectorInlineProps) => {
const empty = !selected || selected.length === 0;
return (
<Menu
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 0,
avoidCollisions: false,
className: clsx(styles.memberSelectorMenu, menuClassName),
onClick(e) {
e.stopPropagation();
},
}}
rootOptions={{
open: readonly ? false : undefined,
modal: modalMenu,
}}
items={<MemberSelector selected={selected} {...props} />}
>
<div
className={clsx(styles.membersSelectorInline, className)}
data-empty={empty}
data-readonly={readonly}
style={style}
>
{empty ? placeholder : <InlineMemberList members={selected} />}
</div>
</Menu>
);
};
export const MemberSelectorInline = BUILD_CONFIG.isMobileEdition
? MobileMemberSelectorInline
: DesktopMemberSelectorInline;

View File

@@ -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<HTMLAttributes<HTMLDivElement>, 'onChange'> {
members: string[];
focusedIndex?: number;
onRemove?: (id: string) => void;
}
export const InlineMemberList = ({
className,
children,
members,
focusedIndex,
onRemove,
...props
}: InlineMemberListProps) => {
return (
<div className={clsx(styles.inlineMemberList, className)} {...props}>
{members.map((member, idx) => (
<MemberItem
key={member}
userId={member}
focused={focusedIndex === idx}
onRemove={onRemove ? () => onRemove(member) : undefined}
/>
))}
{children}
</div>
);
};

View File

@@ -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<HTMLDivElement> = 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 (
<div className={styles.memberItem} data-idx={idx} style={style}>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={styles.memberItemInlineMode}
>
<div className={styles.memberItemLabel}>
{!isLoading ? (
<span>
<Skeleton width="12px" height="12px" variant="circular" />
<Skeleton width="3em" />
</span>
) : (
t['Unknown User']()
)}
</div>
{onRemove ? (
<div
data-testid="remove-tag-button"
className={styles.memberItemRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
}
const { name, avatarUrl } = member;
return (
<div
className={styles.memberItem}
data-idx={idx}
title={name ?? undefined}
style={style}
>
<div
style={{ maxWidth: maxWidth }}
data-focused={focused}
className={styles.memberItemInlineMode}
>
<Avatar
url={avatarUrl}
name={name ?? ''}
size={16}
className={styles.memberItemAvatar}
/>
<div className={styles.memberItemLabel}>{name}</div>
{onRemove ? (
<div
data-testid="remove-tag-button"
className={styles.memberItemRemove}
onClick={handleRemove}
>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
};

View File

@@ -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',
});

View File

@@ -41,7 +41,7 @@ export const TagItem = ({ tag, ...props }: TagItemProps) => {
mode={props.mode === 'inline' ? 'inline-tag' : 'list-tag'} mode={props.mode === 'inline' ? 'inline-tag' : 'list-tag'}
tag={{ tag={{
id: tag?.id, id: tag?.id,
value: value, name: value,
color: color, color: color,
}} }}
/> />

View File

@@ -1,7 +1,8 @@
import { Input, Menu, MenuItem } from '@affine/component'; 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 { ReactNode } from 'react';
import type { TagMeta } from '../types';
import { DateSelect } from './date-select'; import { DateSelect } from './date-select';
import { FilterTag } from './filter-tag-translation'; import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css'; import { inputStyle } from './index.css';
@@ -70,7 +71,7 @@ literalMatcher.register(tDate.create(), {
<DateSelect value={value as number} onChange={onChange} /> <DateSelect value={value as number} onChange={onChange} />
), ),
}); });
const getTagsOfArrayTag = (type: TType): Tag[] => { const getTagsOfArrayTag = (type: TType): TagMeta[] => {
if (type.type === 'array') { if (type.type === 'array') {
if (tTag.is(type.ele)) { if (tTag.is(type.ele)) {
return type.ele.data?.tags ?? []; return type.ele.data?.tags ?? [];
@@ -86,8 +87,8 @@ literalMatcher.register(tArray(tTag.create()), {
<MultiSelect <MultiSelect
value={(value ?? []) as string[]} value={(value ?? []) as string[]}
onChange={value => onChange(value)} onChange={value => onChange(value)}
options={getTagsOfArrayTag(type).map(v => ({ options={getTagsOfArrayTag(type).map((v: any) => ({
label: v.value, label: v.name,
value: v.id, value: v.id,
}))} }))}
></MultiSelect> ></MultiSelect>

View File

@@ -1,5 +1,4 @@
import type { Tag } from '@affine/env/filter'; import type { TagMeta } from '../../types';
import { DataHelper, typesystem } from './typesystem'; import { DataHelper, typesystem } from './typesystem';
export const tNumber = typesystem.defineData( export const tNumber = typesystem.defineData(
@@ -15,7 +14,7 @@ export const tDate = typesystem.defineData(
DataHelper.create<{ value: number }>('Date') DataHelper.create<{ value: number }>('Date')
); );
export const tTag = typesystem.defineData<{ tags: Tag[] }>({ export const tTag = typesystem.defineData<{ tags: TagMeta[] }>({
name: 'Tag', name: 'Tag',
supers: [], supers: [],
}); });

View File

@@ -42,7 +42,17 @@ export const variableDefineMap = {
icon: <FavoriteIcon />, icon: <FavoriteIcon />,
}, },
Tags: { 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: <TagsIcon />, icon: <TagsIcon />,
}, },
'Is Public': { 'Is Public': {

View File

@@ -1,8 +1,7 @@
import { shallowEqual } from '@affine/component'; import { shallowEqual } from '@affine/component';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import type { Tag } from '@affine/env/filter';
import { useI18n } from '@affine/i18n'; 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 { ToggleRightIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra'; 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 PageTitle = ({ id }: { id: string }) => {
const i18n = useI18n(); const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
@@ -326,10 +316,6 @@ function pageMetaToListItemProp(
to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined, to: props.rowAsLink && !props.selectable ? `/${item.id}` : undefined,
onClick: toggleSelection, onClick: toggleSelection,
icon: <UnifiedPageIcon id={item.id} />, icon: <UnifiedPageIcon id={item.id} />,
tags:
item.tags
?.map(id => tagIdToTagOption(id, props.docCollection))
.filter((v): v is Tag => v != null) ?? [],
operations: props.operationsRenderer?.(item), operations: props.operationsRenderer?.(item),
selectable: props.selectable, selectable: props.selectable,
selected: props.selectedIds?.includes(item.id), selected: props.selectedIds?.includes(item.id),
@@ -403,7 +389,7 @@ function tagMetaToListItemProp(
: undefined; : undefined;
const itemProps: TagListItemProps = { const itemProps: TagListItemProps = {
tagId: item.id, tagId: item.id,
title: item.title, title: item.name,
to: props.rowAsLink && !props.selectable ? `/tag/${item.id}` : undefined, to: props.rowAsLink && !props.selectable ? `/tag/${item.id}` : undefined,
onClick: toggleSelection, onClick: toggleSelection,
color: item.color, color: item.color,

View File

@@ -136,12 +136,7 @@ const defaultSortingFn: SorterConfig<MetaRecord<ListItem>>['sortingFn'] = (
return 0; return 0;
}; };
const validKeys: Set<keyof MetaRecord<ListItem>> = new Set([ const validKeys = new Set(['id', 'title', 'name', 'createDate', 'updatedDate']);
'id',
'title',
'createDate',
'updatedDate',
]);
const sorterStateAtom = atom<SorterConfig<MetaRecord<ListItem>>>({ const sorterStateAtom = atom<SorterConfig<MetaRecord<ListItem>>>({
key: DEFAULT_SORT_KEY, key: DEFAULT_SORT_KEY,

View File

@@ -34,7 +34,7 @@ export const CreateOrEditTag = ({
const t = useI18n(); const t = useI18n();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [tagName, setTagName] = useState(tagMeta?.title || ''); const [tagName, setTagName] = useState(tagMeta?.name || '');
const handleChangeName = useCallback((value: string) => { const handleChangeName = useCallback((value: string) => {
setTagName(value); setTagName(value);
}, []); }, []);
@@ -89,7 +89,7 @@ export const CreateOrEditTag = ({
if (!tagName?.trim()) return; if (!tagName?.trim()) return;
if ( if (
tagOptions.some( 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']()); return toast(t['com.affine.tags.create-tag.toast.exist']());
@@ -131,9 +131,9 @@ export const CreateOrEditTag = ({
}, [open, onOpenChange, menuOpen, onClose]); }, [open, onOpenChange, menuOpen, onClose]);
useEffect(() => { useEffect(() => {
setTagName(tagMeta?.title || ''); setTagName(tagMeta?.name || '');
setTagIcon(tagMeta?.color || tagService.randomTagColor()); setTagIcon(tagMeta?.color || tagService.randomTagColor());
}, [tagMeta?.color, tagMeta?.title, tagService]); }, [tagMeta?.color, tagMeta?.name, tagService]);
if (!open) { if (!open) {
return null; return null;

View File

@@ -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 { DocMeta, Workspace } from '@blocksuite/affine/store';
import type { JSX, PropsWithChildren, ReactNode } from 'react'; import type { JSX, PropsWithChildren, ReactNode } from 'react';
import type { To } from 'react-router-dom'; import type { To } from 'react-router-dom';
@@ -13,7 +13,7 @@ export interface CollectionMeta extends Collection {
export type TagMeta = { export type TagMeta = {
id: string; id: string;
title: string; name: string;
color: string; color: string;
pageCount?: number; pageCount?: number;
createDate?: Date | number; createDate?: Date | number;
@@ -27,7 +27,6 @@ export type PageListItemProps = {
icon: JSX.Element; icon: JSX.Element;
title: ReactNode; // using ReactNode to allow for rich content rendering title: ReactNode; // using ReactNode to allow for rich content rendering
preview?: ReactNode; // using ReactNode to allow for rich content rendering preview?: ReactNode; // using ReactNode to allow for rich content rendering
tags: Tag[];
createDate: Date; createDate: Date;
updatedDate?: Date; updatedDate?: Date;
isPublicPage?: boolean; isPublicPage?: boolean;

View File

@@ -7,7 +7,7 @@ type fromLibIconName<T extends string> = T extends `${infer N}Icon`
? Uncapitalize<N> ? Uncapitalize<N>
: never; : never;
export const DocPropertyIconNames = [ export const WorkspacePropertyIconNames = [
'ai', 'ai',
'email', 'email',
'text', 'text',
@@ -88,4 +88,5 @@ export const DocPropertyIconNames = [
'member', 'member',
] as const satisfies fromLibIconName<LibIconComponentName>[]; ] as const satisfies fromLibIconName<LibIconComponentName>[];
export type DocPropertyIconName = (typeof DocPropertyIconNames)[number]; export type WorkspacePropertyIconName =
(typeof WorkspacePropertyIconNames)[number];

View File

@@ -3,20 +3,26 @@ import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { chunk } from 'lodash-es'; import { chunk } from 'lodash-es';
import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; import {
import { DocPropertyIcon, iconNameToComponent } from './doc-property-icon'; type WorkspacePropertyIconName,
WorkspacePropertyIconNames,
} from './constant';
import * as styles from './icons-selector.css'; import * as styles from './icons-selector.css';
import {
iconNameToComponent,
WorkspacePropertyIcon,
} from './workspace-property-icon';
const iconsPerRow = 6; const iconsPerRow = 6;
const iconRows = chunk(DocPropertyIconNames, iconsPerRow); const iconRows = chunk(WorkspacePropertyIconNames, iconsPerRow);
const IconsSelectorPanel = ({ const IconsSelectorPanel = ({
selectedIcon, selectedIcon,
onSelectedChange, onSelectedChange,
}: { }: {
selectedIcon?: string | null; selectedIcon?: string | null;
onSelectedChange: (icon: DocPropertyIconName) => void; onSelectedChange: (icon: WorkspacePropertyIconName) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -53,19 +59,19 @@ const IconsSelectorPanel = ({
); );
}; };
export const DocPropertyIconSelector = ({ export const WorkspacePropertyIconSelector = ({
propertyInfo, propertyInfo,
readonly, readonly,
onSelectedChange, onSelectedChange,
}: { }: {
propertyInfo: DocCustomPropertyInfo; propertyInfo: DocCustomPropertyInfo;
readonly?: boolean; readonly?: boolean;
onSelectedChange: (icon: DocPropertyIconName) => void; onSelectedChange: (icon: WorkspacePropertyIconName) => void;
}) => { }) => {
if (readonly) { if (readonly) {
return ( return (
<div className={styles.iconSelectorButton} data-readonly={readonly}> <div className={styles.iconSelectorButton} data-readonly={readonly}>
<DocPropertyIcon propertyInfo={propertyInfo} /> <WorkspacePropertyIcon propertyInfo={propertyInfo} />
</div> </div>
); );
} }
@@ -85,7 +91,7 @@ export const DocPropertyIconSelector = ({
} }
> >
<div className={styles.iconSelectorButton}> <div className={styles.iconSelectorButton}>
<DocPropertyIcon propertyInfo={propertyInfo} /> <WorkspacePropertyIcon propertyInfo={propertyInfo} />
</div> </div>
</Menu> </Menu>
); );

View File

@@ -3,15 +3,18 @@ import * as icons from '@blocksuite/icons/rc';
import type { SVGProps } from 'react'; import type { SVGProps } from 'react';
import { import {
DocPropertyTypes, isSupportedWorkspacePropertyType,
isSupportedDocPropertyType, WorkspacePropertyTypes,
} from '../types/constant'; } from '../../workspace-property-types';
import { type DocPropertyIconName, DocPropertyIconNames } from './constant'; import {
type WorkspacePropertyIconName,
WorkspacePropertyIconNames,
} from './constant';
// assume all exports in icons are icon Components // assume all exports in icons are icon Components
type LibIconComponentName = keyof typeof icons; 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 capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const IconComponent = const IconComponent =
icons[`${capitalize(name)}Icon` as LibIconComponentName]; icons[`${capitalize(name)}Icon` as LibIconComponentName];
@@ -21,7 +24,7 @@ export const iconNameToComponent = (name: DocPropertyIconName) => {
return IconComponent; return IconComponent;
}; };
export const DocPropertyIcon = ({ export const WorkspacePropertyIcon = ({
propertyInfo, propertyInfo,
...props ...props
}: { }: {
@@ -29,11 +32,13 @@ export const DocPropertyIcon = ({
} & SVGProps<SVGSVGElement>) => { } & SVGProps<SVGSVGElement>) => {
const Icon = const Icon =
propertyInfo.icon && propertyInfo.icon &&
DocPropertyIconNames.includes(propertyInfo.icon as DocPropertyIconName) WorkspacePropertyIconNames.includes(
? iconNameToComponent(propertyInfo.icon as DocPropertyIconName) propertyInfo.icon as WorkspacePropertyIconName
: isSupportedDocPropertyType(propertyInfo.type) )
? DocPropertyTypes[propertyInfo.type].icon ? iconNameToComponent(propertyInfo.icon as WorkspacePropertyIconName)
: DocPropertyTypes.text.icon; : isSupportedWorkspacePropertyType(propertyInfo.type)
? WorkspacePropertyTypes[propertyInfo.type].icon
: WorkspacePropertyTypes.text.icon;
return <Icon {...props} />; return <Icon {...props} />;
}; };

View File

@@ -0,0 +1,3 @@
export { WorkspacePropertyIcon } from './icons/workspace-property-icon';
export { WorkspacePropertyName } from './name';
export * from './table';

View File

@@ -7,8 +7,8 @@ import {
useDropTarget, useDropTarget,
} from '@affine/component'; } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
@@ -17,12 +17,12 @@ import clsx from 'clsx';
import { type HTMLProps, useCallback, useState } from 'react'; import { type HTMLProps, useCallback, useState } from 'react';
import { useGuard } from '../../guard'; import { useGuard } from '../../guard';
import { DocPropertyIcon } from '../icons/doc-property-icon';
import { EditDocPropertyMenuItems } from '../menu/edit-doc-property';
import { import {
DocPropertyTypes, isSupportedWorkspacePropertyType,
isSupportedDocPropertyType, WorkspacePropertyTypes,
} from '../types/constant'; } from '../../workspace-property-types';
import { WorkspacePropertyIcon } from '../icons/workspace-property-icon';
import { EditWorkspacePropertyMenuItems } from '../menu/edit-doc-property';
import * as styles from './styles.css'; import * as styles from './styles.css';
const PropertyItem = ({ const PropertyItem = ({
@@ -39,12 +39,12 @@ const PropertyItem = ({
}) => { }) => {
const t = useI18n(); const t = useI18n();
const workspaceService = useService(WorkspaceService); const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu); const [moreMenuOpen, setMoreMenuOpen] = useState(defaultOpenEditMenu);
const canEditPropertyInfo = useGuard('Workspace_Properties_Update'); const canEditPropertyInfo = useGuard('Workspace_Properties_Update');
const typeInfo = isSupportedDocPropertyType(propertyInfo.type) const typeInfo = isSupportedWorkspacePropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type] ? WorkspacePropertyTypes[propertyInfo.type]
: undefined; : undefined;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@@ -93,15 +93,20 @@ const PropertyItem = ({
if (edge !== 'bottom' && edge !== 'top') { if (edge !== 'bottom' && edge !== 'top') {
return; return;
} }
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
index: docsService.propertyList.indexAt( index: workspacePropertyService.indexAt(
edge === 'bottom' ? 'after' : 'before', edge === 'bottom' ? 'after' : 'before',
propertyInfo.id propertyInfo.id
), ),
}); });
}, },
}), }),
[docsService, propertyInfo, workspaceService, canEditPropertyInfo] [
workspacePropertyService,
propertyInfo,
workspaceService,
canEditPropertyInfo,
]
); );
return ( return (
@@ -118,7 +123,7 @@ const PropertyItem = ({
onClick={handleClick} onClick={handleClick}
data-testid="doc-property-manager-item" data-testid="doc-property-manager-item"
> >
<DocPropertyIcon <WorkspacePropertyIcon
className={styles.itemIcon} className={styles.itemIcon}
propertyInfo={propertyInfo} propertyInfo={propertyInfo}
/> />
@@ -140,7 +145,7 @@ const PropertyItem = ({
modal: true, modal: true,
}} }}
items={ items={
<EditDocPropertyMenuItems <EditWorkspacePropertyMenuItems
propertyId={propertyInfo.id} propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange} onPropertyInfoChange={onPropertyInfoChange}
readonly={!canEditPropertyInfo} readonly={!canEditPropertyInfo}
@@ -157,7 +162,7 @@ const PropertyItem = ({
); );
}; };
export const DocPropertyManager = ({ export const WorkspacePropertyManager = ({
className, className,
defaultOpenEditMenuPropertyId, defaultOpenEditMenuPropertyId,
onPropertyInfoChange, onPropertyInfoChange,
@@ -170,9 +175,9 @@ export const DocPropertyManager = ({
value: string value: string
) => void; ) => void;
}) => { }) => {
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const properties = useLiveData(docsService.propertyList.sortedProperties$); const properties = useLiveData(workspacePropertyService.sortedProperties$);
return ( return (
<div className={clsx(styles.container, className)} {...props}> <div className={clsx(styles.container, className)} {...props}>

View File

@@ -1,15 +1,18 @@
import { MenuItem, MenuSeparator } from '@affine/component'; import { MenuItem, MenuSeparator } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; 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 { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
DocPropertyTypes, isSupportedWorkspacePropertyType,
isSupportedDocPropertyType, WorkspacePropertyTypes,
} from '../types/constant'; } from '../../workspace-property-types';
import * as styles from './create-doc-property.css'; import * as styles from './create-doc-property.css';
export const CreatePropertyMenuItems = ({ export const CreatePropertyMenuItems = ({
@@ -20,16 +23,15 @@ export const CreatePropertyMenuItems = ({
onCreated?: (property: DocCustomPropertyInfo) => void; onCreated?: (property: DocCustomPropertyInfo) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const propertyList = docsService.propertyList; const properties = useLiveData(workspacePropertyService.properties$);
const properties = useLiveData(propertyList.properties$);
const onAddProperty = useCallback( const onAddProperty = useCallback(
(option: { type: string; name: string }) => { (option: { type: WorkspacePropertyType; name: string }) => {
if (!isSupportedDocPropertyType(option.type)) { if (!isSupportedWorkspacePropertyType(option.type)) {
return; return;
} }
const typeDefined = DocPropertyTypes[option.type]; const typeDefined = WorkspacePropertyTypes[option.type];
const nameExists = properties.some(meta => meta.name === option.name); const nameExists = properties.some(meta => meta.name === option.name);
const allNames = properties const allNames = properties
.map(meta => meta.name) .map(meta => meta.name)
@@ -38,16 +40,16 @@ export const CreatePropertyMenuItems = ({
? generateUniqueNameInSequence(option.name, allNames) ? generateUniqueNameInSequence(option.name, allNames)
: option.name; : option.name;
const uniqueId = typeDefined.uniqueId; const uniqueId = typeDefined.uniqueId;
const newProperty = propertyList.createProperty({ const newProperty = workspacePropertyService.createProperty({
id: uniqueId, id: uniqueId,
name, name,
type: option.type, type: option.type,
index: propertyList.indexAt(at), index: workspacePropertyService.indexAt(at),
isDeleted: false, isDeleted: false,
}); });
onCreated?.(newProperty); onCreated?.(newProperty);
}, },
[at, onCreated, propertyList, properties] [at, onCreated, workspacePropertyService, properties]
); );
return ( return (
@@ -56,7 +58,7 @@ export const CreatePropertyMenuItems = ({
{t['com.affine.page-properties.create-property.menu.header']()} {t['com.affine.page-properties.create-property.menu.header']()}
</div> </div>
<MenuSeparator /> <MenuSeparator />
{Object.entries(DocPropertyTypes).map(([type, info]) => { {Object.entries(WorkspacePropertyTypes).map(([type, info]) => {
const name = t.t(info.name); const name = t.t(info.name);
const uniqueId = info.uniqueId; const uniqueId = info.uniqueId;
const isUniqueExist = properties.some(meta => meta.id === uniqueId); const isUniqueExist = properties.some(meta => meta.id === uniqueId);
@@ -69,7 +71,7 @@ export const CreatePropertyMenuItems = ({
onClick={() => { onClick={() => {
onAddProperty({ onAddProperty({
name: name, name: name,
type: type, type: type as WorkspacePropertyType,
}); });
}} }}
data-testid="create-property-menu-item" data-testid="create-property-menu-item"

View File

@@ -5,7 +5,7 @@ import {
useConfirmModal, useConfirmModal,
} from '@affine/component'; } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; 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 { Trans, useI18n } from '@affine/i18n';
import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc'; import { DeleteIcon, InvisibleIcon, ViewIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -17,15 +17,15 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { DocPropertyIcon } from '../icons/doc-property-icon';
import { DocPropertyIconSelector } from '../icons/icons-selector';
import { import {
DocPropertyTypes, isSupportedWorkspacePropertyType,
isSupportedDocPropertyType, WorkspacePropertyTypes,
} from '../types/constant'; } 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'; import * as styles from './edit-doc-property.css';
export const EditDocPropertyMenuItems = ({ export const EditWorkspacePropertyMenuItems = ({
propertyId, propertyId,
onPropertyInfoChange, onPropertyInfoChange,
readonly, readonly,
@@ -38,14 +38,14 @@ export const EditDocPropertyMenuItems = ({
) => void; ) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const propertyInfo = useLiveData( const propertyInfo = useLiveData(
docsService.propertyList.propertyInfo$(propertyId) workspacePropertyService.propertyInfo$(propertyId)
); );
const propertyType = propertyInfo?.type; const propertyType = propertyInfo?.type;
const typeInfo = const typeInfo =
propertyType && isSupportedDocPropertyType(propertyType) propertyType && isSupportedWorkspacePropertyType(propertyType)
? DocPropertyTypes[propertyType] ? WorkspacePropertyTypes[propertyType]
: undefined; : undefined;
const propertyName = const propertyName =
propertyInfo?.name || propertyInfo?.name ||
@@ -64,31 +64,31 @@ export const EditDocPropertyMenuItems = ({
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
name: e.currentTarget.value, name: e.currentTarget.value,
}); });
} }
}, },
[docsService.propertyList, propertyId] [workspacePropertyService, propertyId]
); );
const handleBlur = useCallback( const handleBlur = useCallback(
(e: FocusEvent & { currentTarget: HTMLInputElement }) => { (e: FocusEvent & { currentTarget: HTMLInputElement }) => {
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
name: e.currentTarget.value, name: e.currentTarget.value,
}); });
onPropertyInfoChange?.('name', e.currentTarget.value); onPropertyInfoChange?.('name', e.currentTarget.value);
}, },
[docsService.propertyList, propertyId, onPropertyInfoChange] [workspacePropertyService, propertyId, onPropertyInfoChange]
); );
const handleIconChange = useCallback( const handleIconChange = useCallback(
(iconName: string) => { (iconName: string) => {
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
icon: iconName, icon: iconName,
}); });
onPropertyInfoChange?.('icon', iconName); onPropertyInfoChange?.('icon', iconName);
}, },
[docsService.propertyList, propertyId, onPropertyInfoChange] [workspacePropertyService, propertyId, onPropertyInfoChange]
); );
const handleNameChange = useCallback((e: string) => { const handleNameChange = useCallback((e: string) => {
@@ -98,37 +98,37 @@ export const EditDocPropertyMenuItems = ({
const handleClickAlwaysShow = useCallback( const handleClickAlwaysShow = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
show: 'always-show', show: 'always-show',
}); });
onPropertyInfoChange?.('show', 'always-show'); onPropertyInfoChange?.('show', 'always-show');
}, },
[docsService.propertyList, propertyId, onPropertyInfoChange] [workspacePropertyService, propertyId, onPropertyInfoChange]
); );
const handleClickHideWhenEmpty = useCallback( const handleClickHideWhenEmpty = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
show: 'hide-when-empty', show: 'hide-when-empty',
}); });
onPropertyInfoChange?.('show', 'hide-when-empty'); onPropertyInfoChange?.('show', 'hide-when-empty');
}, },
[docsService.propertyList, propertyId, onPropertyInfoChange] [workspacePropertyService, propertyId, onPropertyInfoChange]
); );
const handleClickAlwaysHide = useCallback( const handleClickAlwaysHide = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
e.preventDefault(); // avoid radix-ui close the menu e.preventDefault(); // avoid radix-ui close the menu
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
show: 'always-hide', show: 'always-hide',
}); });
onPropertyInfoChange?.('show', 'always-hide'); onPropertyInfoChange?.('show', 'always-hide');
}, },
[docsService.propertyList, propertyId, onPropertyInfoChange] [workspacePropertyService, propertyId, onPropertyInfoChange]
); );
if (!propertyInfo || !isSupportedDocPropertyType(propertyType)) { if (!propertyInfo || !isSupportedWorkspacePropertyType(propertyType)) {
return null; return null;
} }
@@ -142,7 +142,7 @@ export const EditDocPropertyMenuItems = ({
} }
data-testid="edit-property-menu-item" data-testid="edit-property-menu-item"
> >
<DocPropertyIconSelector <WorkspacePropertyIconSelector
propertyInfo={propertyInfo} propertyInfo={propertyInfo}
readonly={readonly} readonly={readonly}
onSelectedChange={handleIconChange} onSelectedChange={handleIconChange}
@@ -170,7 +170,7 @@ export const EditDocPropertyMenuItems = ({
> >
{t['com.affine.page-properties.create-property.menu.header']()} {t['com.affine.page-properties.create-property.menu.header']()}
<div className={styles.propertyTypeName}> <div className={styles.propertyTypeName}>
<DocPropertyIcon propertyInfo={propertyInfo} /> <WorkspacePropertyIcon propertyInfo={propertyInfo} />
{t[`com.affine.page-properties.property.${propertyType}`]()} {t[`com.affine.page-properties.property.${propertyType}`]()}
</div> </div>
</div> </div>
@@ -227,7 +227,7 @@ export const EditDocPropertyMenuItems = ({
), ),
confirmText: t['Confirm'](), confirmText: t['Confirm'](),
onConfirm: () => { onConfirm: () => {
docsService.propertyList.removeProperty(propertyId); workspacePropertyService.removeProperty(propertyId);
}, },
confirmButtonOptions: { confirmButtonOptions: {
variant: 'error', variant: 'error',

View File

@@ -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']());
};

View File

@@ -1,6 +1,9 @@
import { Divider, IconButton, Tooltip } from '@affine/component'; import { Divider, IconButton, Tooltip } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; 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 { generateUniqueNameInSequence } from '@affine/core/utils/unique-name';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
@@ -13,31 +16,30 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useGuard } from '../../guard'; import { useGuard } from '../../guard';
import { DocPropertyManager } from '../manager';
import { import {
DocPropertyTypes, isSupportedWorkspacePropertyType,
isSupportedDocPropertyType, WorkspacePropertyTypes,
} from '../types/constant'; } from '../../workspace-property-types';
import { WorkspacePropertyManager } from '../manager';
import { import {
AddDocPropertySidebarSection, AddWorkspacePropertySidebarSection,
DocPropertyListSidebarSection, WorkspacePropertyListSidebarSection,
} from './section'; } from './section';
import * as styles from './styles.css'; import * as styles from './styles.css';
export const DocPropertySidebar = () => { export const WorkspacePropertySidebar = () => {
const t = useI18n(); const t = useI18n();
const [newPropertyId, setNewPropertyId] = useState<string>(); const [newPropertyId, setNewPropertyId] = useState<string>();
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const propertyList = docsService.propertyList; const properties = useLiveData(workspacePropertyService.properties$);
const properties = useLiveData(propertyList.properties$);
const canEditPropertyInfo = useGuard('Workspace_Properties_Update'); const canEditPropertyInfo = useGuard('Workspace_Properties_Update');
const onAddProperty = useCallback( const onAddProperty = useCallback(
(option: { type: string; name: string }) => { (option: { type: WorkspacePropertyType; name: string }) => {
if (!isSupportedDocPropertyType(option.type)) { if (!isSupportedWorkspacePropertyType(option.type)) {
return; return;
} }
const typeDefined = DocPropertyTypes[option.type]; const typeDefined = WorkspacePropertyTypes[option.type];
const nameExists = properties.some(meta => meta.name === option.name); const nameExists = properties.some(meta => meta.name === option.name);
const allNames = properties const allNames = properties
.map(meta => meta.name) .map(meta => meta.name)
@@ -45,11 +47,11 @@ export const DocPropertySidebar = () => {
const name = nameExists const name = nameExists
? generateUniqueNameInSequence(option.name, allNames) ? generateUniqueNameInSequence(option.name, allNames)
: option.name; : option.name;
const newProperty = propertyList.createProperty({ const newProperty = workspacePropertyService.createProperty({
id: typeDefined.uniqueId, id: typeDefined.uniqueId,
name, name,
type: option.type, type: option.type,
index: propertyList.indexAt('after'), index: workspacePropertyService.indexAt('after'),
isDeleted: false, isDeleted: false,
}); });
setNewPropertyId(newProperty.id); setNewPropertyId(newProperty.id);
@@ -58,7 +60,7 @@ export const DocPropertySidebar = () => {
type: option.type, type: option.type,
}); });
}, },
[propertyList, properties] [workspacePropertyService, properties]
); );
const onPropertyInfoChange = useCallback( const onPropertyInfoChange = useCallback(
@@ -74,9 +76,9 @@ export const DocPropertySidebar = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<CollapsibleRoot defaultOpen> <CollapsibleRoot defaultOpen>
<DocPropertyListSidebarSection /> <WorkspacePropertyListSidebarSection />
<CollapsibleContent> <CollapsibleContent>
<DocPropertyManager <WorkspacePropertyManager
className={styles.manager} className={styles.manager}
defaultOpenEditMenuPropertyId={newPropertyId} defaultOpenEditMenuPropertyId={newPropertyId}
onPropertyInfoChange={onPropertyInfoChange} onPropertyInfoChange={onPropertyInfoChange}
@@ -87,10 +89,10 @@ export const DocPropertySidebar = () => {
<Divider /> <Divider />
</div> </div>
<CollapsibleRoot defaultOpen> <CollapsibleRoot defaultOpen>
<AddDocPropertySidebarSection /> <AddWorkspacePropertySidebarSection />
<CollapsibleContent> <CollapsibleContent>
<div className={styles.AddListContainer}> <div className={styles.AddListContainer}>
{Object.entries(DocPropertyTypes).map(([key, value]) => { {Object.entries(WorkspacePropertyTypes).map(([key, value]) => {
const Icon = value.icon; const Icon = value.icon;
const name = t.t(value.name); const name = t.t(value.name);
const isUniqueExist = properties.some( const isUniqueExist = properties.some(
@@ -109,7 +111,7 @@ export const DocPropertySidebar = () => {
return; return;
} }
onAddProperty({ onAddProperty({
type: key, type: key as WorkspacePropertyType,
name, name,
}); });
}} }}

View File

@@ -5,7 +5,7 @@ import { Trigger as CollapsibleTrigger } from '@radix-ui/react-collapsible';
import * as styles from './section.css'; import * as styles from './section.css';
export const DocPropertyListSidebarSection = () => { export const WorkspacePropertyListSidebarSection = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<div className={styles.headerRoot}> <div className={styles.headerRoot}>
@@ -21,7 +21,7 @@ export const DocPropertyListSidebarSection = () => {
); );
}; };
export const AddDocPropertySidebarSection = () => { export const AddWorkspacePropertySidebarSection = () => {
const t = useI18n(); const t = useI18n();
return ( return (
<div className={styles.headerRoot}> <div className={styles.headerRoot}>

View File

@@ -9,7 +9,7 @@ import {
useDropTarget, useDropTarget,
} from '@affine/component'; } from '@affine/component';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; 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 { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import type { import type {
DatabaseRow, DatabaseRow,
@@ -17,6 +17,7 @@ import type {
} from '@affine/core/modules/doc-info/types'; } from '@affine/core/modules/doc-info/types';
import { DocIntegrationPropertiesTable } from '@affine/core/modules/integration'; import { DocIntegrationPropertiesTable } from '@affine/core/modules/integration';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench'; 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 type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
@@ -32,11 +33,15 @@ import type React from 'react';
import { forwardRef, useCallback, useMemo, useState } from 'react'; import { forwardRef, useCallback, useMemo, useState } from 'react';
import { useGuard } from '../guard'; 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 { 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 * as styles from './table.css';
import { DocPropertyTypes, isSupportedDocPropertyType } from './types/constant';
export type DefaultOpenProperty = export type DefaultOpenProperty =
| { | {
@@ -49,7 +54,7 @@ export type DefaultOpenProperty =
databaseRowId: string; databaseRowId: string;
}; };
export interface DocPropertiesTableProps { export interface WorkspacePropertiesTableProps {
className?: string; className?: string;
defaultOpenProperty?: DefaultOpenProperty; defaultOpenProperty?: DefaultOpenProperty;
onPropertyAdded?: (property: DocCustomPropertyInfo) => void; onPropertyAdded?: (property: DocCustomPropertyInfo) => void;
@@ -66,7 +71,7 @@ export interface DocPropertiesTableProps {
) => void; ) => void;
} }
interface DocPropertiesTableHeaderProps { interface WorkspacePropertiesTableHeaderProps {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
open: boolean; open: boolean;
@@ -75,12 +80,12 @@ interface DocPropertiesTableHeaderProps {
// Info // Info
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
export const DocPropertiesTableHeader = ({ export const WorkspacePropertiesTableHeader = ({
className, className,
style, style,
open, open,
onOpenChange, onOpenChange,
}: DocPropertiesTableHeaderProps) => { }: WorkspacePropertiesTableHeaderProps) => {
const handleCollapse = useCallback(() => { const handleCollapse = useCallback(() => {
track.doc.inlineDocInfo.$.toggle(); track.doc.inlineDocInfo.$.toggle();
onOpenChange(!open); onOpenChange(!open);
@@ -108,7 +113,7 @@ export const DocPropertiesTableHeader = ({
); );
}; };
interface DocPropertyRowProps { interface WorkspacePropertyRowProps {
propertyInfo: DocCustomPropertyInfo; propertyInfo: DocCustomPropertyInfo;
showAll?: boolean; showAll?: boolean;
defaultOpenEditMenu?: boolean; defaultOpenEditMenu?: boolean;
@@ -121,23 +126,22 @@ interface DocPropertyRowProps {
) => void; ) => void;
} }
export const DocPropertyRow = ({ export const WorkspacePropertyRow = ({
propertyInfo, propertyInfo,
defaultOpenEditMenu, defaultOpenEditMenu,
onChange, onChange,
propertyInfoReadonly, propertyInfoReadonly,
readonly, readonly,
onPropertyInfoChange, onPropertyInfoChange,
}: DocPropertyRowProps) => { }: WorkspacePropertyRowProps) => {
const t = useI18n();
const docService = useService(DocService); const docService = useService(DocService);
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const customPropertyValue = useLiveData( const customPropertyValue = useLiveData(
docService.doc.customProperty$(propertyInfo.id) docService.doc.customProperty$(propertyInfo.id)
); );
const typeInfo = isSupportedDocPropertyType(propertyInfo.type) const typeInfo = isSupportedWorkspacePropertyType(propertyInfo.type)
? DocPropertyTypes[propertyInfo.type] ? WorkspacePropertyTypes[propertyInfo.type]
: undefined; : undefined;
const hide = propertyInfo.show === 'always-hide'; const hide = propertyInfo.show === 'always-hide';
@@ -200,15 +204,15 @@ export const DocPropertyRow = ({
if (edge !== 'bottom' && edge !== 'top') { if (edge !== 'bottom' && edge !== 'top') {
return; return;
} }
docsService.propertyList.updatePropertyInfo(propertyId, { workspacePropertyService.updatePropertyInfo(propertyId, {
index: docsService.propertyList.indexAt( index: workspacePropertyService.indexAt(
edge === 'bottom' ? 'after' : 'before', edge === 'bottom' ? 'after' : 'before',
propertyInfo.id propertyInfo.id
), ),
}); });
}, },
}), }),
[docId, docsService.propertyList, propertyInfo.id, propertyInfoReadonly] [docId, workspacePropertyService, propertyInfo.id, propertyInfoReadonly]
); );
if (!ValueRenderer || typeof ValueRenderer !== 'function') return null; if (!ValueRenderer || typeof ValueRenderer !== 'function') return null;
@@ -229,13 +233,10 @@ export const DocPropertyRow = ({
> >
<PropertyName <PropertyName
defaultOpenMenu={defaultOpenEditMenu} defaultOpenMenu={defaultOpenEditMenu}
icon={<DocPropertyIcon propertyInfo={propertyInfo} />} icon={<WorkspacePropertyIcon propertyInfo={propertyInfo} />}
name={ name={<WorkspacePropertyName propertyInfo={propertyInfo} />}
propertyInfo.name ||
(typeInfo?.name ? t.t(typeInfo.name) : t['unnamed']())
}
menuItems={ menuItems={
<EditDocPropertyMenuItems <EditWorkspacePropertyMenuItems
propertyId={propertyInfo.id} propertyId={propertyInfo.id}
onPropertyInfoChange={onPropertyInfoChange} onPropertyInfoChange={onPropertyInfoChange}
readonly={propertyInfoReadonly} readonly={propertyInfoReadonly}
@@ -253,7 +254,7 @@ export const DocPropertyRow = ({
); );
}; };
interface DocWorkspacePropertiesTableBodyProps { interface WorkspacePropertiesTableBodyProps {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
defaultOpen?: boolean; defaultOpen?: boolean;
@@ -269,9 +270,9 @@ interface DocWorkspacePropertiesTableBodyProps {
// 🏷️ Tags (⋅ xxx) (⋅ yyy) // 🏷️ Tags (⋅ xxx) (⋅ yyy)
// #️⃣ Number 123456 // #️⃣ Number 123456
// + Add a property // + Add a property
const DocWorkspacePropertiesTableBody = forwardRef< const WorkspaceWorkspacePropertiesTableBody = forwardRef<
HTMLDivElement, HTMLDivElement,
DocWorkspacePropertiesTableBodyProps WorkspacePropertiesTableBodyProps
>( >(
( (
{ {
@@ -286,11 +287,11 @@ const DocWorkspacePropertiesTableBody = forwardRef<
ref ref
) => { ) => {
const t = useI18n(); const t = useI18n();
const docsService = useService(DocsService); const workspacePropertyService = useService(WorkspacePropertyService);
const workbenchService = useService(WorkbenchService); const workbenchService = useService(WorkbenchService);
const viewService = useServiceOptional(ViewService); const viewService = useServiceOptional(ViewService);
const docService = useService(DocService); const docService = useService(DocService);
const properties = useLiveData(docsService.propertyList.sortedProperties$); const properties = useLiveData(workspacePropertyService.sortedProperties$);
const [addMoreCollapsed, setAddMoreCollapsed] = useState(true); const [addMoreCollapsed, setAddMoreCollapsed] = useState(true);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null); const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
@@ -344,7 +345,7 @@ const DocWorkspacePropertiesTableBody = forwardRef<
} }
> >
{properties.map(property => ( {properties.map(property => (
<DocPropertyRow <WorkspacePropertyRow
key={property.id} key={property.id}
propertyInfo={property} propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id} defaultOpenEditMenu={newPropertyId === property.id}
@@ -413,16 +414,16 @@ const DocWorkspacePropertiesTableBody = forwardRef<
); );
} }
); );
DocWorkspacePropertiesTableBody.displayName = 'PagePropertiesTableBody'; WorkspaceWorkspacePropertiesTableBody.displayName = 'PagePropertiesTableBody';
const DocPropertiesTableInner = ({ const WorkspacePropertiesTableInner = ({
defaultOpenProperty, defaultOpenProperty,
onPropertyAdded, onPropertyAdded,
onPropertyChange, onPropertyChange,
onPropertyInfoChange, onPropertyInfoChange,
onDatabasePropertyChange, onDatabasePropertyChange,
className, className,
}: DocPropertiesTableProps) => { }: WorkspacePropertiesTableProps) => {
const [expanded, setExpanded] = useState(!!defaultOpenProperty); const [expanded, setExpanded] = useState(!!defaultOpenProperty);
const defaultOpen = useMemo(() => { const defaultOpen = useMemo(() => {
return defaultOpenProperty?.type === 'database' return defaultOpenProperty?.type === 'database'
@@ -438,7 +439,7 @@ const DocPropertiesTableInner = ({
return ( return (
<div className={clsx(styles.root, className)}> <div className={clsx(styles.root, className)}>
<Collapsible.Root open={expanded} onOpenChange={setExpanded}> <Collapsible.Root open={expanded} onOpenChange={setExpanded}>
<DocPropertiesTableHeader <WorkspacePropertiesTableHeader
style={{ width: '100%' }} style={{ width: '100%' }}
open={expanded} open={expanded}
onOpenChange={setExpanded} onOpenChange={setExpanded}
@@ -447,7 +448,7 @@ const DocPropertiesTableInner = ({
<DocIntegrationPropertiesTable <DocIntegrationPropertiesTable
divider={<div className={styles.tableHeaderDivider} />} divider={<div className={styles.tableHeaderDivider} />}
/> />
<DocWorkspacePropertiesTableBody <WorkspaceWorkspacePropertiesTableBody
defaultOpen={ defaultOpen={
!defaultOpenProperty || defaultOpenProperty.type === 'workspace' !defaultOpenProperty || defaultOpenProperty.type === 'workspace'
} }
@@ -468,6 +469,8 @@ const DocPropertiesTableInner = ({
// this is the main component that renders the page properties table at the top of the page below // this is the main component that renders the page properties table at the top of the page below
// the page title // the page title
export const DocPropertiesTable = (props: DocPropertiesTableProps) => { export const WorkspacePropertiesTable = (
return <DocPropertiesTableInner {...props} />; props: WorkspacePropertiesTableProps
) => {
return <WorkspacePropertiesTableInner {...props} />;
}; };

View File

@@ -6,5 +6,3 @@ export interface PropertyValueProps {
readonly?: boolean; readonly?: boolean;
onChange: (value: any, skipCommit?: boolean) => void; // if skipCommit is true, the change will be handled in the component itself onChange: (value: any, skipCommit?: boolean) => void; // if skipCommit is true, the change will be handled in the component itself
} }
export type PageLayoutMode = 'standard' | 'fullWidth';

View File

@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import * as styles from './radio-group.css'; import * as styles from './radio-group.css';
export const DocPropertyRadioGroup = ({ export const PropertyRadioGroup = ({
width = 194, width = 194,
items, items,
value, value,

View File

@@ -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<React.SVGProps<SVGSVGElement>>;
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;
};

View File

@@ -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' ? (
<WorkspaceTagsInlineEditor
placeholder={
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
}
selectedTags={selectedTags}
onSelectTag={handleSelectTag}
onDeselectTag={handleDeselectTag}
tagMode="inline-tag"
/>
) : undefined;
};

View File

@@ -34,6 +34,7 @@ export const tagsMenu = style({
left: '-3.5px', left: '-3.5px',
width: 'calc(var(--radix-popper-anchor-width) + 16px)', width: 'calc(var(--radix-popper-anchor-width) + 16px)',
overflow: 'hidden', overflow: 'hidden',
minWidth: 400,
}); });
export const tagsEditorSelectedTags = style({ export const tagsEditorSelectedTags = style({

View File

@@ -39,7 +39,7 @@ const DesktopTagEditMenu = ({
if (name.trim() === '') { if (name.trim() === '') {
return; return;
} }
onTagChange('value', name); onTagChange('name', name);
}; };
return { return {
@@ -51,7 +51,7 @@ const DesktopTagEditMenu = ({
items: ( items: (
<> <>
<Input <Input
defaultValue={tag.value} defaultValue={tag.name}
onBlur={e => { onBlur={e => {
updateTagName(e.currentTarget.value); updateTagName(e.currentTarget.value);
}} }}
@@ -131,10 +131,10 @@ const MobileTagEditMenu = ({
const [localTag, setLocalTag] = useState({ ...tag }); const [localTag, setLocalTag] = useState({ ...tag });
useEffect(() => { useEffect(() => {
if (localTag.value !== tag.value) { if (localTag.name !== tag.name) {
setLocalTag({ ...tag }); setLocalTag({ ...tag });
} }
}, [tag, localTag.value]); }, [tag, localTag.name]);
const handleTriggerClick: MouseEventHandler<HTMLDivElement> = useCallback( const handleTriggerClick: MouseEventHandler<HTMLDivElement> = useCallback(
e => { e => {
@@ -145,8 +145,8 @@ const MobileTagEditMenu = ({
); );
const handleOnDone = () => { const handleOnDone = () => {
setOpen(false); setOpen(false);
if (localTag.value.trim() !== tag.value) { if (localTag.name.trim() !== tag.name) {
onTagChange('value', localTag.value); onTagChange('name', localTag.name);
} }
if (localTag.color !== tag.color) { if (localTag.color !== tag.color) {
onTagChange('color', localTag.color); onTagChange('color', localTag.color);
@@ -167,9 +167,9 @@ const MobileTagEditMenu = ({
}} }}
autoSelect={false} autoSelect={false}
className={styles.mobileTagEditInput} className={styles.mobileTagEditInput}
value={localTag.value} value={localTag.name}
onChange={e => { onChange={e => {
setLocalTag({ ...localTag, value: e }); setLocalTag({ ...localTag, name: e });
}} }}
placeholder={t['Untitled']()} placeholder={t['Untitled']()}
/> />

View File

@@ -26,7 +26,7 @@ export const TagItem = ({
style, style,
maxWidth, maxWidth,
}: TagItemProps) => { }: TagItemProps) => {
const { value, color, id } = tag; const { name, color, id } = tag;
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback( const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
e => { e => {
e.stopPropagation(); e.stopPropagation();
@@ -39,8 +39,8 @@ export const TagItem = ({
className={styles.tag} className={styles.tag}
data-idx={idx} data-idx={idx}
data-tag-id={id} data-tag-id={id}
data-tag-value={value} data-tag-value={name}
title={value} title={name}
style={{ style={{
...style, ...style,
...assignInlineVars({ ...assignInlineVars({
@@ -58,7 +58,7 @@ export const TagItem = ({
})} })}
> >
{mode !== 'db-label' ? <div className={styles.tagIndicator} /> : null} {mode !== 'db-label' ? <div className={styles.tagIndicator} /> : null}
<div className={styles.tagLabel}>{value}</div> <div className={styles.tagLabel}>{name}</div>
{onRemoved ? ( {onRemoved ? (
<div <div
data-testid="remove-tag-button" data-testid="remove-tag-button"

View File

@@ -5,13 +5,16 @@ import {
RowInput, RowInput,
Scrollable, Scrollable,
} from '@affine/component'; } from '@affine/component';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import type { KeyboardEvent, ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { ConfigModal } from '../mobile'; import { ConfigModal } from '../mobile';
import { InlineTagList } from './inline-tag-list'; import { InlineTagList } from './inline-tag-list';
import * as styles from './styles.css'; import * as styles from './styles.css';
@@ -30,6 +33,7 @@ export interface TagsEditorProps {
onDeleteTag: (id: string) => void; // a candidate to be deleted onDeleteTag: (id: string) => void; // a candidate to be deleted
jumpToTag?: (id: string) => void; jumpToTag?: (id: string) => void;
tagMode: 'inline-tag' | 'db-label'; tagMode: 'inline-tag' | 'db-label';
style?: React.CSSProperties;
} }
export interface TagsInlineEditorProps extends TagsEditorProps { export interface TagsInlineEditorProps extends TagsEditorProps {
@@ -39,6 +43,7 @@ export interface TagsInlineEditorProps extends TagsEditorProps {
title?: ReactNode; // only used for mobile title?: ReactNode; // only used for mobile
modalMenu?: boolean; modalMenu?: boolean;
menuClassName?: string; menuClassName?: string;
style?: React.CSSProperties;
} }
type TagOption = TagLike | { readonly create: true; readonly value: string }; type TagOption = TagLike | { readonly create: true; readonly value: string };
@@ -56,10 +61,11 @@ export const TagsEditor = ({
onDeselectTag, onDeselectTag,
onCreateTag, onCreateTag,
tagColors, tagColors,
onDeleteTag: onTagDelete, onDeleteTag,
onTagChange, onTagChange,
jumpToTag, jumpToTag,
tagMode, tagMode,
style,
}: TagsEditorProps) => { }: TagsEditorProps) => {
const t = useI18n(); const t = useI18n();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
@@ -67,11 +73,11 @@ export const TagsEditor = ({
const trimmedInputValue = inputValue.trim(); const trimmedInputValue = inputValue.trim();
const filteredTags = tags.filter(tag => const filteredTags = tags.filter(tag =>
tag.value.toLowerCase().includes(trimmedInputValue.toLowerCase()) tag.name.toLowerCase().includes(trimmedInputValue.toLowerCase())
); );
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value === trimmedInputValue); const exactMatch = filteredTags.find(tag => tag.name === trimmedInputValue);
const showCreateTag = !exactMatch && trimmedInputValue; const showCreateTag = !exactMatch && trimmedInputValue;
// tag option candidates to show in the tag dropdown // tag option candidates to show in the tag dropdown
@@ -145,6 +151,13 @@ export const TagsEditor = ({
[onCreateTag, nextColor] [onCreateTag, nextColor]
); );
const handleDeleteTag = useCallback(
(tagId: string) => {
onDeleteTag(tagId);
},
[onDeleteTag]
);
const onSelectTagOption = useCallback( const onSelectTagOption = useCallback(
(tagOption: TagOption) => { (tagOption: TagOption) => {
const id = isCreateNewTag(tagOption) const id = isCreateNewTag(tagOption)
@@ -230,6 +243,7 @@ export const TagsEditor = ({
return ( return (
<div <div
style={style}
data-testid="tags-editor-popup" data-testid="tags-editor-popup"
className={ className={
BUILD_CONFIG.isMobileEdition BUILD_CONFIG.isMobileEdition
@@ -289,7 +303,7 @@ export const TagsEditor = ({
mode={tagMode} mode={tagMode}
tag={{ tag={{
id: 'create-new-tag', id: 'create-new-tag',
value: inputValue, name: inputValue,
color: nextColor, color: nextColor,
}} }}
/> />
@@ -301,13 +315,13 @@ export const TagsEditor = ({
key={tag.id} key={tag.id}
{...commonProps} {...commonProps}
data-tag-id={tag.id} data-tag-id={tag.id}
data-tag-value={tag.value} data-tag-value={tag.name}
> >
<TagItem maxWidth="100%" tag={tag} mode={tagMode} /> <TagItem maxWidth="100%" tag={tag} mode={tagMode} />
<div className={styles.spacer} /> <div className={styles.spacer} />
<TagEditMenu <TagEditMenu
tag={tag} tag={tag}
onTagDelete={onTagDelete} onTagDelete={handleDeleteTag}
onTagChange={(property, value) => { onTagChange={(property, value) => {
onTagChange(tag.id, property, value); onTagChange(tag.id, property, value);
}} }}
@@ -335,6 +349,7 @@ const MobileInlineEditor = ({
placeholder, placeholder,
className, className,
title, title,
style,
...props ...props
}: TagsInlineEditorProps) => { }: TagsInlineEditorProps) => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -360,6 +375,7 @@ const MobileInlineEditor = ({
data-empty={empty} data-empty={empty}
data-readonly={readonly} data-readonly={readonly}
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
style={style}
> >
{empty ? ( {empty ? (
placeholder placeholder
@@ -377,6 +393,7 @@ const DesktopTagsInlineEditor = ({
className, className,
modalMenu, modalMenu,
menuClassName, menuClassName,
style,
...props ...props
}: TagsInlineEditorProps) => { }: TagsInlineEditorProps) => {
const empty = !props.selectedTags || props.selectedTags.length === 0; const empty = !props.selectedTags || props.selectedTags.length === 0;
@@ -406,6 +423,7 @@ const DesktopTagsInlineEditor = ({
className={clsx(styles.tagsInlineEditor, className)} className={clsx(styles.tagsInlineEditor, className)}
data-empty={empty} data-empty={empty}
data-readonly={readonly} data-readonly={readonly}
style={style}
> >
{empty ? ( {empty ? (
placeholder placeholder
@@ -425,3 +443,69 @@ const DesktopTagsInlineEditor = ({
export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition
? MobileInlineEditor ? MobileInlineEditor
: DesktopTagsInlineEditor; : 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 (
<TagsInlineEditor
tags={tags}
selectedTags={selectedTags}
onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onCreateTag={onCreateTag}
onDeleteTag={onDeleteTag}
onTagChange={onTagChange}
{...otherProps}
/>
);
};

View File

@@ -1,6 +1,6 @@
export interface TagLike { export interface TagLike {
id: string; id: string;
value: string; // value is the tag name name: string; // display name
color: string; // css color value color: string; // css color value
} }

View File

@@ -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 (
<PropertyValue onClick={handleClick} className={styles.container}>
<Checkbox
className={styles.checkboxProperty}
checked={parsedValue}
onChange={() => {}}
disabled={readonly}
/>
</PropertyValue>
);
};
export const CheckboxFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};

View File

@@ -1,10 +1,14 @@
import { PropertyValue } from '@affine/component'; import { PropertyValue } from '@affine/component';
import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user'; 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 { DocService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; 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'; import { userWrapper } from './created-updated-by.css';
const CreatedByUpdatedByAvatar = (props: { const CreatedByUpdatedByAvatar = (props: {
@@ -78,3 +82,40 @@ export const UpdatedByValue = () => {
</PropertyValue> </PropertyValue>
); );
}; };
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 (
<MemberSelectorInline
placeholder={
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
}
selected={selected}
onChange={handleChange}
/>
);
};

View File

@@ -1,10 +1,13 @@
import { DatePicker, Menu, PropertyValue, Tooltip } from '@affine/component'; 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 { DocService } from '@affine/core/modules/doc';
import { i18nTime, useI18n } from '@affine/i18n'; import { i18nTime, useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra'; 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 * as styles from './date.css';
import type { PropertyValueProps } from './types';
const useParsedDate = (value: string) => { const useParsedDate = (value: string) => {
const parsedValue = const parsedValue =
@@ -105,3 +108,79 @@ export const CreateDateValue = MetaDateValueFactory({
export const UpdatedDateValue = MetaDateValueFactory({ export const UpdatedDateValue = MetaDateValueFactory({
type: 'updatedDate', 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' ? (
<Menu
items={
<DatePicker value={values[0] || undefined} onChange={handleChange} />
}
>
{displayDates[0] ? (
<span>{displayDates[0]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
) : filter.method === 'between' ? (
<>
<Menu
items={
<DatePicker
value={values[0] || undefined}
onChange={value => handleChange(`${value},${values[1] || ''}`)}
/>
}
>
{displayDates[0] ? (
<span>{displayDates[0]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
<span style={{ color: cssVarV2('text/placeholder') }}>&nbsp;-&nbsp;</span>
<Menu
items={
<DatePicker
value={values[1] || undefined}
onChange={value => handleChange(`${values[0] || ''},${value}`)}
/>
}
>
{displayDates[1] ? (
<span>{displayDates[1]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
</>
) : undefined;
};

View File

@@ -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 { DocService } from '@affine/core/modules/doc';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/model'; import type { DocMode } from '@blocksuite/affine/model';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; 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 * as styles from './doc-primary-mode.css';
import type { PropertyValueProps } from './types';
export const DocPrimaryModeValue = ({ export const DocPrimaryModeValue = ({
onChange, onChange,
@@ -55,7 +62,7 @@ export const DocPrimaryModeValue = ({
hoverable={false} hoverable={false}
readonly={readonly} readonly={readonly}
> >
<DocPropertyRadioGroup <PropertyRadioGroup
value={primaryMode} value={primaryMode}
onChange={handleChange} onChange={handleChange}
items={DocModeItems} items={DocModeItems}
@@ -64,3 +71,46 @@ export const DocPrimaryModeValue = ({
</PropertyValue> </PropertyValue>
); );
}; };
export const DocPrimaryModeFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
const t = useI18n();
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'page',
});
}}
selected={filter.value !== 'edgeless'}
>
{t['Page']()}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'edgeless',
});
}}
selected={filter.value === 'edgeless'}
>
{t['Edgeless']()}
</MenuItem>
</>
}
>
<span>{filter.value === 'edgeless' ? t['Edgeless']() : t['Page']()}</span>
</Menu>
);
};

View File

@@ -4,9 +4,9 @@ import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; 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 * as styles from './edgeless-theme.css';
import type { PropertyValueProps } from './types';
const getThemeOptions = (t: ReturnType<typeof useI18n>) => const getThemeOptions = (t: ReturnType<typeof useI18n>) =>
[ [
@@ -47,7 +47,7 @@ export const EdgelessThemeValue = ({
hoverable={false} hoverable={false}
readonly={readonly} readonly={readonly}
> >
<DocPropertyRadioGroup <PropertyRadioGroup
value={edgelessTheme || 'system'} value={edgelessTheme || 'system'}
onChange={handleChange} onChange={handleChange}
items={themeItems} items={themeItems}

View File

@@ -0,0 +1,264 @@
import type { FilterParams } from '@affine/core/modules/collection-rules';
import type {
WorkspacePropertyFilter,
WorkspacePropertyType,
} from '@affine/core/modules/workspace-property';
import type { I18nString } from '@affine/i18n';
import {
CheckBoxCheckLinearIcon,
DateTimeIcon,
EdgelessIcon,
FileIcon,
HistoryIcon,
LongerIcon,
MemberIcon,
NumberIcon,
PropertyIcon,
TagIcon,
TemplateIcon,
TextIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
import type { PropertyValueProps } from '../properties/types';
import { CheckboxFilterValue, CheckboxValue } from './checkbox';
import {
CreatedByUpdatedByFilterValue,
CreatedByValue,
UpdatedByValue,
} from './created-updated-by';
import {
CreateDateValue,
DateFilterValue,
DateValue,
UpdatedDateValue,
} from './date';
import {
DocPrimaryModeFilterValue,
DocPrimaryModeValue,
} from './doc-primary-mode';
import { EdgelessThemeValue } from './edgeless-theme';
import { JournalFilterValue, JournalValue } from './journal';
import { NumberValue } from './number';
import { PageWidthValue } from './page-width';
import { TagsFilterValue, TagsValue } from './tags';
import { TemplateValue } from './template';
import { TextFilterValue, TextValue } from './text';
const DateFilterMethod = {
after: 'com.affine.filter.after',
before: 'com.affine.filter.before',
between: 'com.affine.filter.between',
'last-3-days': 'com.affine.filter.last 3 days',
'last-7-days': 'com.affine.filter.last 7 days',
'last-15-days': 'com.affine.filter.last 15 days',
'last-30-days': 'com.affine.filter.last 30 days',
'this-week': 'com.affine.filter.this week',
'this-month': 'com.affine.filter.this month',
'this-quarter': 'com.affine.filter.this quarter',
'this-year': 'com.affine.filter.this year',
} as const;
export const WorkspacePropertyTypes = {
tags: {
icon: TagIcon,
value: TagsValue,
name: 'com.affine.page-properties.property.tags',
uniqueId: 'tags',
renameable: false,
description: 'com.affine.page-properties.property.tags.tooltips',
filterMethod: {
include: 'com.affine.filter.contains all',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},
allowInGroupBy: true,
allowInOrderBy: true,
defaultFilter: { method: 'is-not-empty' },
filterValue: TagsFilterValue,
},
text: {
icon: TextIcon,
value: TextValue,
name: 'com.affine.page-properties.property.text',
description: 'com.affine.page-properties.property.text.tooltips',
filterMethod: {
is: 'com.affine.editCollection.rules.include.is',
'is-not': 'com.affine.editCollection.rules.include.is-not',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},
allowInGroupBy: true,
allowInOrderBy: true,
filterValue: TextFilterValue,
defaultFilter: { method: 'is-not-empty' },
},
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',
filterMethod: {
is: 'com.affine.editCollection.rules.include.is',
'is-not': 'com.affine.editCollection.rules.include.is-not',
},
allowInGroupBy: true,
allowInOrderBy: true,
filterValue: CheckboxFilterValue,
defaultFilter: { method: 'is', value: 'true' },
},
date: {
icon: DateTimeIcon,
value: DateValue,
name: 'com.affine.page-properties.property.date',
description: 'com.affine.page-properties.property.date.tooltips',
filterMethod: {
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
...DateFilterMethod,
},
allowInGroupBy: true,
allowInOrderBy: true,
filterValue: DateFilterValue,
defaultFilter: { method: 'is-not-empty' },
},
createdBy: {
icon: MemberIcon,
value: CreatedByValue,
name: 'com.affine.page-properties.property.createdBy',
description: 'com.affine.page-properties.property.createdBy.tooltips',
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
include: 'com.affine.filter.contains all',
},
filterValue: CreatedByUpdatedByFilterValue,
defaultFilter: { method: 'include', value: '' },
},
updatedBy: {
icon: MemberIcon,
value: UpdatedByValue,
name: 'com.affine.page-properties.property.updatedBy',
description: 'com.affine.page-properties.property.updatedBy.tooltips',
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
include: 'com.affine.filter.contains all',
},
filterValue: CreatedByUpdatedByFilterValue,
defaultFilter: { method: 'include', value: '' },
},
updatedAt: {
icon: DateTimeIcon,
value: UpdatedDateValue,
name: 'com.affine.page-properties.property.updatedAt',
description: 'com.affine.page-properties.property.updatedAt.tooltips',
renameable: false,
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
...DateFilterMethod,
},
filterValue: DateFilterValue,
defaultFilter: { method: 'this-week' },
},
createdAt: {
icon: HistoryIcon,
value: CreateDateValue,
name: 'com.affine.page-properties.property.createdAt',
description: 'com.affine.page-properties.property.createdAt.tooltips',
renameable: false,
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
...DateFilterMethod,
},
filterValue: DateFilterValue,
defaultFilter: { method: 'this-week' },
},
docPrimaryMode: {
icon: FileIcon,
value: DocPrimaryModeValue,
name: 'com.affine.page-properties.property.docPrimaryMode',
description: 'com.affine.page-properties.property.docPrimaryMode.tooltips',
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
is: 'com.affine.editCollection.rules.include.is',
'is-not': 'com.affine.editCollection.rules.include.is-not',
},
filterValue: DocPrimaryModeFilterValue,
defaultFilter: { method: 'is', value: 'page' },
},
journal: {
icon: TodayIcon,
value: JournalValue,
name: 'com.affine.page-properties.property.journal',
description: 'com.affine.page-properties.property.journal.tooltips',
allowInGroupBy: true,
allowInOrderBy: true,
filterMethod: {
is: 'com.affine.editCollection.rules.include.is',
'is-not': 'com.affine.editCollection.rules.include.is-not',
},
filterValue: JournalFilterValue,
defaultFilter: { method: 'is', value: 'true' },
},
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',
},
unknown: {
icon: PropertyIcon,
name: 'Unknown',
renameable: false,
},
} as {
[type in WorkspacePropertyType]: {
icon: React.FC<React.SVGProps<SVGSVGElement>>;
value?: React.FC<PropertyValueProps>;
allowInOrderBy?: boolean;
allowInGroupBy?: boolean;
filterMethod?: { [key in WorkspacePropertyFilter<type>]: I18nString };
filterValue?: React.FC<{
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}>;
defaultFilter?: Omit<FilterParams, 'type' | 'key'>;
/**
* 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;
};

View File

@@ -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 { 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 { DocService } from '@affine/core/modules/doc';
import { JournalService } from '@affine/core/modules/journal'; import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
@@ -13,8 +20,8 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './journal.css'; import * as styles from './journal.css';
import type { PropertyValueProps } from './types';
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation(); const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
export const JournalValue = ({ readonly }: PropertyValueProps) => { export const JournalValue = ({ readonly }: PropertyValueProps) => {
@@ -168,3 +175,44 @@ export const JournalValue = ({ readonly }: PropertyValueProps) => {
</PropertyValue> </PropertyValue>
); );
}; };
export const JournalFilterValue = ({
filter,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
}) => {
return (
<Menu
items={
<>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'true',
});
}}
selected={filter.value === 'true'}
>
{'True'}
</MenuItem>
<MenuItem
onClick={() => {
onChange({
...filter,
value: 'false',
});
}}
selected={filter.value !== 'true'}
>
{'False'}
</MenuItem>
</>
}
>
<span>{filter.value === 'true' ? 'True' : 'False'}</span>
</Menu>
);
};

View File

@@ -7,8 +7,8 @@ import {
useState, useState,
} from 'react'; } from 'react';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './number.css'; import * as styles from './number.css';
import type { PropertyValueProps } from './types';
export const NumberValue = ({ export const NumberValue = ({
value, value,

View File

@@ -5,9 +5,9 @@ import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; 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 { container } from './page-width.css';
import type { PageLayoutMode, PropertyValueProps } from './types';
export const PageWidthValue = ({ readonly }: PropertyValueProps) => { export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
const t = useI18n(); const t = useI18n();
@@ -17,14 +17,12 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
const doc = useService(DocService).doc; const doc = useService(DocService).doc;
const pageWidth = useLiveData(doc.properties$.selector(p => p.pageWidth)); const pageWidth = useLiveData(doc.properties$.selector(p => p.pageWidth));
const radioValue = const radioValue = pageWidth ?? (defaultPageWidth ? 'fullWidth' : 'standard');
pageWidth ??
((defaultPageWidth ? 'fullWidth' : 'standard') as PageLayoutMode);
const radioItems = useMemo<RadioItem[]>( const radioItems = useMemo<RadioItem[]>(
() => [ () => [
{ {
value: 'standard' as PageLayoutMode, value: 'standard',
label: label:
t[ t[
'com.affine.settings.editorSettings.page.default-page-width.standard' 'com.affine.settings.editorSettings.page.default-page-width.standard'
@@ -32,7 +30,7 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
testId: 'standard-width-trigger', testId: 'standard-width-trigger',
}, },
{ {
value: 'fullWidth' as PageLayoutMode, value: 'fullWidth',
label: label:
t[ t[
'com.affine.settings.editorSettings.page.default-page-width.full-width' 'com.affine.settings.editorSettings.page.default-page-width.full-width'
@@ -44,14 +42,14 @@ export const PageWidthValue = ({ readonly }: PropertyValueProps) => {
); );
const handleChange = useCallback( const handleChange = useCallback(
(value: PageLayoutMode) => { (value: string) => {
doc.record.setProperty('pageWidth', value); doc.record.setProperty('pageWidth', value);
}, },
[doc] [doc]
); );
return ( return (
<PropertyValue className={container} hoverable={false} readonly={readonly}> <PropertyValue className={container} hoverable={false} readonly={readonly}>
<DocPropertyRadioGroup <PropertyRadioGroup
value={radioValue} value={radioValue}
onChange={handleChange} onChange={handleChange}
items={radioItems} items={radioItems}

View File

@@ -0,0 +1,166 @@
import { PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { DocService } from '@affine/core/modules/doc';
import { TagService } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useMemo } from 'react';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import type { PropertyValueProps } from '../properties/types';
import {
WorkspaceTagsInlineEditor as TagsInlineEditorComponent,
WorkspaceTagsInlineEditor,
} from '../tags';
import * as styles from './tags.css';
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 (
<PropertyValue
className={styles.container}
isEmpty={empty}
data-testid="property-tags-value"
readonly={readonly}
>
<TagsInlineEditor
className={styles.tagInlineEditor}
placeholder={t[
'com.affine.page-properties.property-value-placeholder'
]()}
pageId={doc.id}
onChange={() => {}}
readonly={readonly}
/>
</PropertyValue>
);
};
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' ? (
<WorkspaceTagsInlineEditor
placeholder={
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
}
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 (
<TagsInlineEditorComponent
tagMode="inline-tag"
jumpToTag={jumpToTag}
readonly={readonly}
placeholder={placeholder}
className={className}
selectedTags={tagIds}
onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag}
title={
<>
<TagsIcon />
{t['Tags']()}
</>
}
/>
);
};

View File

@@ -3,8 +3,8 @@ import { DocService } from '@affine/core/modules/doc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { type ChangeEvent, useCallback } from 'react'; import { type ChangeEvent, useCallback } from 'react';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './template.css'; import * as styles from './template.css';
import type { PropertyValueProps } from './types';
export const TemplateValue = ({ readonly }: PropertyValueProps) => { export const TemplateValue = ({ readonly }: PropertyValueProps) => {
const docService = useService(DocService); const docService = useService(DocService);

View File

@@ -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 { useI18n } from '@affine/i18n';
import { TextIcon } from '@blocksuite/icons/rc'; import { TextIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { import {
type ChangeEventHandler, type ChangeEventHandler,
useCallback, useCallback,
@@ -9,9 +12,9 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { ConfigModal } from '../../mobile'; import { ConfigModal } from '../mobile';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './text.css'; import * as styles from './text.css';
import type { PropertyValueProps } from './types';
const DesktopTextValue = ({ const DesktopTextValue = ({
value, value,
@@ -168,3 +171,80 @@ const MobileTextValue = ({
export const TextValue = BUILD_CONFIG.isMobileWeb export const TextValue = BUILD_CONFIG.isMobileWeb
? MobileTextValue ? MobileTextValue
: DesktopTextValue; : 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<HTMLInputElement>) => {
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' ? (
<Menu
rootOptions={{
open: valueMenuOpen,
onOpenChange: setValueMenuOpen,
}}
contentOptions={{
onPointerDownOutside: submitTempValue,
sideOffset: -28,
}}
items={
<Input
inputStyle={{
fontSize: cssVar('fontBase'),
}}
autoFocus
autoSelect
value={tempValue}
onChange={value => {
setTempValue(value);
}}
onEnter={handleInputEnter}
onKeyDown={handleInputKeyDown}
style={{ height: 34, borderRadius: 4 }}
/>
}
>
{filter.value ? (
<span>{filter.value}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
) : null;
};

View File

@@ -6,10 +6,9 @@ import {
PropertyCollapsibleSection, PropertyCollapsibleSection,
} from '@affine/component'; } from '@affine/component';
import { BacklinkGroups } from '@affine/core/blocksuite/block-suite-editor/bi-directional-link-panel'; 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 { CreatePropertyMenuItems } from '@affine/core/components/properties/menu/create-doc-property';
import { DocPropertyRow } from '@affine/core/components/doc-properties/table'; import { WorkspacePropertyRow } from '@affine/core/components/properties/table';
import type { DocCustomPropertyInfo } from '@affine/core/modules/db'; import type { DocCustomPropertyInfo } from '@affine/core/modules/db';
import { DocsService } from '@affine/core/modules/doc';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info'; import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import type { import type {
DatabaseRow, DatabaseRow,
@@ -17,6 +16,7 @@ import type {
} from '@affine/core/modules/doc-info/types'; } from '@affine/core/modules/doc-info/types';
import { DocsSearchService } from '@affine/core/modules/docs-search'; import { DocsSearchService } from '@affine/core/modules/docs-search';
import { GuardService } from '@affine/core/modules/permissions'; import { GuardService } from '@affine/core/modules/permissions';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import track from '@affine/track'; import track from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc'; import { PlusIcon } from '@blocksuite/icons/rc';
@@ -34,17 +34,18 @@ export const InfoTable = ({
onClose: () => void; onClose: () => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const { docsSearchService, docsService, guardService } = useServices({ const { docsSearchService, workspacePropertyService, guardService } =
DocsSearchService, useServices({
DocsService, DocsSearchService,
GuardService, WorkspacePropertyService,
}); GuardService,
});
const canEditPropertyInfo = useLiveData( const canEditPropertyInfo = useLiveData(
guardService.can$('Workspace_Properties_Update') guardService.can$('Workspace_Properties_Update')
); );
const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId)); const canEditProperty = useLiveData(guardService.can$('Doc_Update', docId));
const [newPropertyId, setNewPropertyId] = useState<string | null>(null); const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const properties = useLiveData(docsService.propertyList.sortedProperties$); const properties = useLiveData(workspacePropertyService.sortedProperties$);
const links = useLiveData( const links = useLiveData(
useMemo( useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null), () => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
@@ -136,7 +137,7 @@ export const InfoTable = ({
} }
> >
{properties.map(property => ( {properties.map(property => (
<DocPropertyRow <WorkspacePropertyRow
key={property.id} key={property.id}
propertyInfo={property} propertyInfo={property}
readonly={!canEditProperty} readonly={!canEditProperty}

View File

@@ -62,7 +62,7 @@ export const TagSelectorDialog = ({
const filteredTagMetas = useMemo(() => { const filteredTagMetas = useMemo(() => {
return tagMetas.filter(tag => { return tagMetas.filter(tag => {
const reg = new RegExp(keyword, 'i'); const reg = new RegExp(keyword, 'i');
return reg.test(tag.title); return reg.test(tag.name);
}); });
}, [keyword, tagMetas]); }, [keyword, tagMetas]);

View File

@@ -3,7 +3,6 @@ import {
SettingRow, SettingRow,
SettingWrapper, SettingWrapper,
} from '@affine/component/setting-components'; } 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 { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
@@ -19,7 +18,7 @@ export const Page = () => {
const radioItems = useMemo<RadioItem[]>( const radioItems = useMemo<RadioItem[]>(
() => [ () => [
{ {
value: 'standard' as PageLayoutMode, value: 'standard',
label: label:
t[ t[
'com.affine.settings.editorSettings.page.default-page-width.standard' 'com.affine.settings.editorSettings.page.default-page-width.standard'
@@ -27,7 +26,7 @@ export const Page = () => {
testId: 'standard-width-trigger', testId: 'standard-width-trigger',
}, },
{ {
value: 'fullWidth' as PageLayoutMode, value: 'fullWidth',
label: label:
t[ t[
'com.affine.settings.editorSettings.page.default-page-width.full-width' 'com.affine.settings.editorSettings.page.default-page-width.full-width'
@@ -39,7 +38,7 @@ export const Page = () => {
); );
const handleFullWidthLayoutChange = useCallback( const handleFullWidthLayoutChange = useCallback(
(value: PageLayoutMode) => { (value: string) => {
const checked = value === 'fullWidth'; const checked = value === 'fullWidth';
editorSetting.set('fullWidthLayout', checked); editorSetting.set('fullWidthLayout', checked);
}, },

View File

@@ -1,5 +1,5 @@
import { Button } from '@affine/component'; import { Button } from '@affine/component';
import { type TagLike, TagsInlineEditor } from '@affine/core/components/tags'; import { WorkspaceTagsInlineEditor } from '@affine/core/components/tags';
import { import {
IntegrationService, IntegrationService,
IntegrationTypeIcon, IntegrationTypeIcon,
@@ -8,7 +8,7 @@ import type { ReadwiseConfig } from '@affine/core/modules/integration/type';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc'; 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 { type ReactNode, useCallback, useMemo, useState } from 'react';
import { import {
@@ -245,47 +245,21 @@ const TagsSetting = () => {
const t = useI18n(); const t = useI18n();
const tagService = useService(TagService); const tagService = useService(TagService);
const readwise = useService(IntegrationService).readwise; const readwise = useService(IntegrationService).readwise;
const allTags = useLiveData(tagService.tagList.tags$); const tagMetas = useLiveData(tagService.tagList.tagMetas$);
const tagColors = tagService.tagColors;
const tagIds = useLiveData( const tagIds = useLiveData(
useMemo(() => readwise.setting$('tags'), [readwise]) 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( const updateReadwiseTags = useCallback(
(tagIds: string[]) => { (tagIds: string[]) => {
readwise.updateSetting( readwise.updateSetting(
'tags', '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( const onSelectTag = useCallback(
(tagId: string) => { (tagId: string) => {
trackModifySetting('Tag', 'on'); trackModifySetting('Tag', 'on');
@@ -300,32 +274,12 @@ const TagsSetting = () => {
}, },
[tagIds, updateReadwiseTags] [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 ( return (
<li> <li>
<h6 className={styles.tagsLabel}> <h6 className={styles.tagsLabel}>
{t['com.affine.integration.readwise.setting.tags-label']()} {t['com.affine.integration.readwise.setting.tags-label']()}
</h6> </h6>
<TagsInlineEditor <WorkspaceTagsInlineEditor
placeholder={ placeholder={
<span className={styles.tagsPlaceholder}> <span className={styles.tagsPlaceholder}>
{t['com.affine.integration.readwise.setting.tags-placeholder']()} {t['com.affine.integration.readwise.setting.tags-placeholder']()}
@@ -333,14 +287,9 @@ const TagsSetting = () => {
} }
className={styles.tagsEditor} className={styles.tagsEditor}
tagMode="inline-tag" tagMode="inline-tag"
tags={adaptedTags}
selectedTags={tagIds ?? []} selectedTags={tagIds ?? []}
onCreateTag={onCreateTag}
onSelectTag={onSelectTag} onSelectTag={onSelectTag}
onDeselectTag={onDeselectTag} onDeselectTag={onDeselectTag}
tagColors={adaptedTagColors}
onTagChange={onTagChange}
onDeleteTag={onDeleteTag}
modalMenu={true} modalMenu={true}
menuClassName={styles.tagsMenu} menuClassName={styles.tagsMenu}
/> />

Some files were not shown because too many files have changed in this diff Show More