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

whats changed:

### orm

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

### yjs-observable

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

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

### standard property naming

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

### `WorkspacePropertyService`

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

### new `<Filters />` component

in `core/component/filter`

### new `<ExplorerDisplayMenuButton />` component

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

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

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

New route for test components and functions

### new collection role service

Implemented some filter group order rules

see `collection-rules/index.ts`

### standard property type definition

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

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

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

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

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

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

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

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

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

View File

@@ -74,14 +74,4 @@ export type DeleteCollectionInfo = {
} | null;
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;

View File

@@ -102,6 +102,102 @@ describe('ORM entity CRUD', () => {
expect(user2).toEqual(user);
});
test('should be able to select', t => {
const { client } = t;
client.users.create({
name: 'u1',
email: 'e1@example.com',
});
client.users.create({
name: 'u2',
});
const users = client.users.select('name');
expect(users).toStrictEqual([
{ id: expect.any(Number), name: 'u1' },
{ id: expect.any(Number), name: 'u2' },
]);
const user2 = client.users.select('email');
expect(user2).toStrictEqual([
{ id: expect.any(Number), email: 'e1@example.com' },
{ id: expect.any(Number), email: undefined },
]);
const user3 = client.users.select('name', {
email: null,
});
expect(user3).toStrictEqual([{ id: expect.any(Number), name: 'u2' }]);
});
test('should be able to observe select', t => {
const { client } = t;
const t1 = client.tags.create({
name: 't1',
color: 'red',
});
const t2 = client.tags.create({
name: 't2',
color: 'blue',
});
let currentValue: any;
let callbackCount = 0;
client.tags.select$('name', { color: 'red' }).subscribe(data => {
currentValue = data;
callbackCount++;
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1' },
]);
expect(callbackCount).toBe(1);
const t3 = client.tags.create({
name: 't3',
color: 'blue',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1' },
]);
expect(callbackCount).toBe(1);
client.tags.update(t1.id, {
name: 't1-updated',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1-updated' },
]);
expect(callbackCount).toBe(2);
client.tags.update(t2.id, {
color: 'red',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1-updated' },
{ id: expect.any(String), name: 't2' },
]);
expect(callbackCount).toBe(3);
client.tags.delete(t1.id);
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't2' },
]);
expect(callbackCount).toBe(4);
client.tags.delete(t3.id);
expect(callbackCount).toBe(4);
});
test('should be able to filter with nullable condition', t => {
const { client } = t;

View File

@@ -6,6 +6,7 @@ import {
type Transaction,
} from 'yjs';
import { shallowEqual } from '../../../../utils/shallow-equal';
import { validators } from '../../validators';
import { HookAdapter } from '../mixins';
import type {
@@ -133,7 +134,16 @@ export class YjsTableAdapter implements TableAdapter {
if (isMatch && isPrevMatched) {
const newValue = this.value(record, select);
if (prevMatch !== newValue) {
if (
!(
prevMatch === newValue ||
(!select && // if select is set, we will check the value
select !== '*' &&
select !== 'key' &&
// skip if the value is the same
shallowEqual(prevMatch, newValue))
)
) {
results.set(key, newValue);
hasChanged = true;
}

View File

@@ -273,6 +273,62 @@ export class Table<T extends TableSchemaBuilder> {
});
}
select<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Pick<Entity<T>, Key | PrimaryKeyField<T>>[] {
const items = this.adapter.find({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
});
return items.map(item => {
const { [this.keyField]: key, [selectKey]: selected } = item;
return {
[this.keyField]: key,
[selectKey]: selected,
} as Pick<Entity<T>, Key | PrimaryKeyField<T>>;
});
}
select$<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Observable<Pick<Entity<T>, Key | PrimaryKeyField<T>>[]> {
return new Observable(subscriber => {
const unsubscribe = this.adapter.observe({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
select: [this.keyField, selectKey as string],
callback: data => {
subscriber.next(
data.map(item => {
const { [this.keyField]: key, [selectKey]: selected } = item;
return {
[this.keyField]: key,
[selectKey]: selected,
} as Pick<Entity<T>, Key | PrimaryKeyField<T>>;
})
);
},
});
return unsubscribe;
});
}
keys(): PrimaryKeyFieldType<T>[] {
return this.adapter.find({
select: 'key',

View File

@@ -1,13 +1,13 @@
import { describe, expect, test } from 'vitest';
import { Doc as YDoc, Map as YMap } from 'yjs';
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
import { yjsObserveByPath } from '../yjs-observable';
import { yjsGetPath, yjsObservePath } from '../yjs-observable';
describe('yjs observable', () => {
test('basic', async () => {
const ydoc = new YDoc();
let currentValue: any = false;
yjsObserveByPath(ydoc.getMap('foo'), 'key.subkey').subscribe(
yjsGetPath(ydoc.getMap('foo'), 'key.subkey').subscribe(
v => (currentValue = v)
);
expect(currentValue).toBe(undefined);
@@ -28,4 +28,84 @@ describe('yjs observable', () => {
ydoc.getMap('foo').set('key', 'text');
expect(currentValue).toBe(undefined);
});
test('observe with path', async () => {
const ydoc = new YDoc();
/**
* {
* metas: {
* pages: [
* {
* id: '1',
* title: 'page 1',
* tags: ['tag1', 'tag2']
* }
* ]
* }
* }
*/
let currentValue: any = false;
let callbackCount = 0;
yjsObservePath(ydoc.getMap('metas'), 'pages.*.tags').subscribe(v => {
callbackCount++;
currentValue = (v as any)
.toJSON()
.pages?.map((page: any) => ({ id: page.id, tags: page.tags ?? [] }));
});
expect(callbackCount).toBe(1);
ydoc.getMap('metas').set('pages', new YArray<any>());
expect(callbackCount).toBe(2);
expect(currentValue).toStrictEqual([]);
const pages = ydoc.getMap('metas').get('pages') as YArray<any>;
pages.push([
new YMap([
['id', '1'],
['title', 'page 1'],
['tags', YArray.from(['tag1', 'tag2'])],
]),
]);
expect(callbackCount).toBe(3);
expect(currentValue).toStrictEqual([{ id: '1', tags: ['tag1', 'tag2'] }]);
pages.get(0).set('title', 'page 1*');
expect(callbackCount).toBe(3); // no change
pages.get(0).get('tags').push(['tag3']);
expect(callbackCount).toBe(4);
expect(currentValue).toStrictEqual([
{ id: '1', tags: ['tag1', 'tag2', 'tag3'] },
]);
ydoc.getMap('metas').set('otherMeta', 'true');
expect(callbackCount).toBe(4); // no change
pages.push([
new YMap([
['id', '2'],
['title', 'page 2'],
]),
]);
expect(callbackCount).toBe(5);
expect(currentValue).toStrictEqual([
{ id: '1', tags: ['tag1', 'tag2', 'tag3'] },
{ id: '2', tags: [] },
]);
pages.delete(0);
expect(callbackCount).toBe(6);
expect(currentValue).toStrictEqual([{ id: '2', tags: [] }]);
});
});

View File

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