mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08:00
feat(core): add collection rules module (#11683)
whats changed:
### orm
add a new `select$` method, can subscribe on only one field to improve batch subscribe performance
### yjs-observable
add a new `yjsObservePath` method, which can subscribe to changes from specific path in yjs. Improves batch subscribe performance
```ts
yjsGetPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(pages => yjsObservePath(pages, '*.tags')),
map(pages => {
// only when tags changed
})
)
```
### standard property naming
All `DocProperty` components renamed to `WorkspaceProperty` which is consistent with the product definition.
### `WorkspacePropertyService`
Split the workspace property management logic from the `doc` module and create a new `WorkspacePropertyService`. The new service manages the creation and modification of properties, and the `docService` is only responsible for storing the property value data.
### new `<Filters />` component
in `core/component/filter`
### new `<ExplorerDisplayMenuButton />` component
in `core/component/explorer/display-menu`

### new `/workspace/xxx/all-new` route
New route for test components and functions
### new collection role service
Implemented some filter group order rules
see `collection-rules/index.ts`
### standard property type definition
define type in `modules\workspace-property\types.ts`

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
- **New Features**
- Introduced comprehensive filtering, grouping, and ordering capabilities for workspace documents with reactive updates.
- Added a new "All Pages" workspace view supporting dynamic filters and display preferences.
- Developed UI components for filter creation, condition editing, and display menu controls.
- Launched enhanced tag management with inline editors, selection, creation, and deletion workflows.
- Added workspace property types with dedicated filter UIs including checkbox, date, tags, and text.
- Introduced workspace property management replacing document property handling.
- Added modular providers for filters, group-by, and order-by operations supporting various property types and system attributes.
- **Improvements**
- Standardized tag and property naming conventions across the application (using `name` instead of `value` or `title`).
- Migrated document property handling to workspace property-centric logic.
- Enhanced internationalization with additional filter and display menu labels.
- Improved styling for filter conditions, display menus, and workspace pages.
- Optimized reactive data subscriptions and state management for performance.
- Refined schema typings and type safety for workspace properties.
- Updated imports and component references to workspace property equivalents throughout frontend.
- **Bug Fixes**
- Resolved tag property inconsistencies affecting display and filtering.
- Fixed filter and tag selection behaviors for accurate and reliable UI interactions.
- **Chores**
- Added and refined test cases for ORM, observables, and filtering logic.
- Cleaned up legacy document property code and improved type safety.
- Modularized and restructured components for better maintainability.
- Introduced new CSS styles for workspace pages and display menus.
- Added framework module configurations for collection rules and workspace property features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
10
packages/common/env/src/filter.ts
vendored
10
packages/common/env/src/filter.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: [] }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './table';
|
|
||||||
@@ -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']()}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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'),
|
||||||
|
});
|
||||||
11
packages/frontend/core/src/components/explorer/types.ts
Normal file
11
packages/frontend/core/src/components/explorer/types.ts
Normal 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;
|
||||||
|
}
|
||||||
77
packages/frontend/core/src/components/filter/add-filter.tsx
Normal file
77
packages/frontend/core/src/components/filter/add-filter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
packages/frontend/core/src/components/filter/filter.tsx
Normal file
30
packages/frontend/core/src/components/filter/filter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
packages/frontend/core/src/components/filter/filters.tsx
Normal file
46
packages/frontend/core/src/components/filter/filters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
packages/frontend/core/src/components/filter/index.ts
Normal file
2
packages/frontend/core/src/components/filter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './filter';
|
||||||
|
export * from './filters';
|
||||||
53
packages/frontend/core/src/components/filter/styles.css.ts
Normal file
53
packages/frontend/core/src/components/filter/styles.css.ts
Normal 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',
|
||||||
|
});
|
||||||
342
packages/frontend/core/src/components/member-selector/index.tsx
Normal file
342
packages/frontend/core/src/components/member-selector/index.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
packages/frontend/core/src/components/member-selector/item.tsx
Normal file
109
packages/frontend/core/src/components/member-selector/item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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} />;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { WorkspacePropertyIcon } from './icons/workspace-property-icon';
|
||||||
|
export { WorkspacePropertyName } from './name';
|
||||||
|
export * from './table';
|
||||||
@@ -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}>
|
||||||
@@ -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"
|
||||||
@@ -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',
|
||||||
14
packages/frontend/core/src/components/properties/name.tsx
Normal file
14
packages/frontend/core/src/components/properties/name.tsx
Normal 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']());
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -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}>
|
||||||
@@ -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} />;
|
||||||
};
|
};
|
||||||
@@ -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';
|
|
||||||
@@ -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,
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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']()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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') }}> - </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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
@@ -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}
|
||||||
@@ -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']()}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user