Compare commits

...

19 Commits

Author SHA1 Message Date
EYHN
07aec9a6b7 feat(core): cloud doc meta service 2024-08-26 17:54:50 +08:00
DarkSky
42b8aefe96 chore: update schema comment 2024-08-26 17:54:50 +08:00
DarkSky
a778481ce0 fix: lint 2024-08-26 17:54:50 +08:00
DarkSky
6d5185f4f1 chore: adjust onDelete behavior 2024-08-26 17:54:50 +08:00
DarkSky
c28ef3189c fix: test case 2024-08-26 17:54:49 +08:00
DarkSky
1e1a9552c0 feat: update field name 2024-08-26 17:54:49 +08:00
DarkSky
9f74d17d67 chore: update test case 2024-08-26 17:54:49 +08:00
DarkSky
d3c93ff053 fix: lint 2024-08-26 17:54:49 +08:00
DarkSky
ad99587fe2 feat: record readonly properties 2024-08-26 17:54:49 +08:00
DarkSky
f61a902bac feat: split readonly properties 2024-08-26 17:54:49 +08:00
DarkSky
bf8f49771e fix: user cell style 2024-08-26 17:54:49 +08:00
DarkSky
74d7ca7f8e feat: add local user avatar 2024-08-26 17:54:49 +08:00
DarkSky
8be3ecbdbd feat: update icon 2024-08-26 17:54:49 +08:00
DarkSky
35ef9af264 feat: add editor to article header 2024-08-26 17:54:49 +08:00
DarkSky
0417c70d54 feat: add page meta api 2024-08-26 17:54:49 +08:00
DarkSky
266607e3ae feat: add editor for histories api 2024-08-26 17:54:49 +08:00
DarkSky
7901a55fd7 feat: integrate editor record for snapshots 2024-08-26 17:54:49 +08:00
DarkSky
f5f5b05a4c feat: get user id from gateway 2024-08-26 17:54:49 +08:00
DarkSky
d04e766ce9 feat: add editor record migration 2024-08-26 17:54:49 +08:00
32 changed files with 621 additions and 52 deletions

View File

@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "snapshot_histories" ADD COLUMN "created_by" VARCHAR;
-- AlterTable
ALTER TABLE "snapshots" ADD COLUMN "created_by" VARCHAR,
ADD COLUMN "updated_by" VARCHAR;
-- AlterTable
ALTER TABLE "updates" ADD COLUMN "created_by" VARCHAR DEFAULT 'system';
-- AddForeignKey
ALTER TABLE "snapshots" ADD CONSTRAINT "snapshots_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "snapshots" ADD CONSTRAINT "snapshots_updated_by_fkey" FOREIGN KEY ("updated_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "updates" ADD CONSTRAINT "updates_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "snapshot_histories" ADD CONSTRAINT "snapshot_histories_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -33,6 +33,10 @@ model User {
aiSessions AiSession[]
updatedRuntimeConfigs RuntimeConfig[]
userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
@@index([email])
@@map("users")
@@ -241,9 +245,16 @@ model Snapshot {
// the `updated_at` field will not record the time of record changed,
// but the created time of last seen update that has been merged into snapshot.
updatedAt DateTime @map("updated_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
updatedBy String? @map("updated_by") @db.VarChar
// should not delete origin snapshot even if user is deleted
// we only delete the snapshot if the workspace is deleted
createdByUser User? @relation(name: "createdSnapshot", fields: [createdBy], references: [id], onDelete: SetNull)
updatedByUser User? @relation(name: "updatedSnapshot", fields: [updatedBy], references: [id], onDelete: SetNull)
// @deprecated use updatedAt only
seq Int? @default(0) @db.Integer
seq Int? @default(0) @db.Integer
// we need to clear all hanging updates and snapshots before enable the foreign key on workspaceId
// workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -274,9 +285,14 @@ model Update {
id String @map("guid") @db.VarChar
blob Bytes @db.ByteA
createdAt DateTime @map("created_at") @db.Timestamptz(3)
// TODO(@darkskygit): fullfill old update, remove default value in next release
createdBy String? @default("system") @map("created_by") @db.VarChar
// will delete createor record if createor's account is deleted
createdByUser User? @relation(name: "createdUpdate", fields: [createdBy], references: [id], onDelete: SetNull)
// @deprecated use createdAt only
seq Int? @db.Integer
seq Int? @db.Integer
@@id([workspaceId, id, createdAt])
@@map("updates")
@@ -289,6 +305,10 @@ model SnapshotHistory {
blob Bytes @db.ByteA
state Bytes? @db.ByteA
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
// will delete createor record if creator's account is deleted
createdByUser User? @relation(name: "createdHistory", fields: [createdBy], references: [id], onDelete: SetNull)
@@id([workspaceId, id, timestamp])
@@map("snapshot_histories")

View File

@@ -45,7 +45,12 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
return this.getDocSnapshot(spaceId, docId);
}
async pushDocUpdates(userId: string, docId: string, updates: Uint8Array[]) {
async pushDocUpdates(
userId: string,
docId: string,
updates: Uint8Array[],
editorId?: string
) {
if (!updates.length) {
return 0;
}
@@ -67,6 +72,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
docId,
bin,
timestamp,
editor: editorId,
});
return timestamp;
@@ -135,6 +141,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
docId,
bin: snapshot.blob,
timestamp: snapshot.updatedAt.getTime(),
editor: snapshot.userId,
};
}
@@ -151,6 +158,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
update: {
blob: Buffer.from(snapshot.bin),
updatedAt: new Date(snapshot.timestamp),
updatedBy: snapshot.editor,
},
create: {
userId: snapshot.spaceId,
@@ -158,6 +166,8 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
blob: Buffer.from(snapshot.bin),
createdAt: new Date(snapshot.timestamp),
updatedAt: new Date(snapshot.timestamp),
createdBy: snapshot.editor,
updatedBy: snapshot.editor,
},
});

View File

@@ -38,7 +38,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
async pushDocUpdates(
workspaceId: string,
docId: string,
updates: Uint8Array[]
updates: Uint8Array[],
editorId?: string
) {
if (!updates.length) {
return 0;
@@ -82,6 +83,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
blob: Buffer.from(update),
seq,
createdAt: new Date(createdAt),
createdBy: editorId,
};
}),
});
@@ -113,6 +115,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
return rows.map(row => ({
bin: row.blob,
timestamp: row.createdAt.getTime(),
editor: row.createdBy || undefined,
}));
}
@@ -216,6 +219,12 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
const histories = await this.db.snapshotHistory.findMany({
select: {
timestamp: true,
createdByUser: {
select: {
name: true,
avatarUrl: true,
},
},
},
where: {
workspaceId,
@@ -230,7 +239,10 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
take: query.limit,
});
return histories.map(h => h.timestamp.getTime());
return histories.map(h => ({
timestamp: h.timestamp.getTime(),
editor: h.createdByUser,
}));
}
async getDocHistory(workspaceId: string, docId: string, timestamp: number) {
@@ -253,10 +265,12 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
docId,
bin: history.blob,
timestamp,
editor: history.createdBy || undefined,
};
}
override async rollbackDoc(
editorId: string | undefined,
spaceId: string,
docId: string,
timestamp: number
@@ -274,7 +288,14 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
}
// force create a new history record after rollback
await this.createDocHistory(fromSnapshot, true);
await this.createDocHistory(
{
...fromSnapshot,
// override the editor to the one who requested the rollback
editor: editorId,
},
true
);
// WARN:
// we should never do the snapshot updating in recovering,
// which is not the solution in CRDT.
@@ -331,6 +352,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
id: snapshot.docId,
timestamp: new Date(snapshot.timestamp),
blob: Buffer.from(snapshot.bin),
createdBy: snapshot.editor,
expiredAt: new Date(
Date.now() + (await this.options.historyMaxAge(snapshot.spaceId))
),
@@ -374,6 +396,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
docId,
bin: snapshot.blob,
timestamp: snapshot.updatedAt.getTime(),
// creator and editor may null if their account is deleted
editor: snapshot.updatedBy || snapshot.createdBy || undefined,
};
}
@@ -396,10 +420,10 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
try {
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at")
VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt})
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at", "created_by", "updated_by")
VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt}, ${snapshot.editor}, ${snapshot.editor})
ON CONFLICT ("workspace_id", "guid")
DO UPDATE SET "blob" = ${bin}, "updated_at" = ${updatedAt}
DO UPDATE SET "blob" = ${bin}, "updated_at" = ${updatedAt}, "updated_by" = ${snapshot.editor}
WHERE "snapshots"."workspace_id" = ${spaceId} AND "snapshots"."guid" = ${docId} AND "snapshots"."updated_at" <= ${updatedAt}
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
`;

View File

@@ -22,4 +22,4 @@ import { DocStorageOptions } from './options';
export class DocStorageModule {}
export { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter };
export { DocStorageAdapter } from './storage';
export { DocStorageAdapter, type Editor } from './storage';

View File

@@ -16,11 +16,13 @@ export interface DocRecord {
docId: string;
bin: Uint8Array;
timestamp: number;
editor?: string;
}
export interface DocUpdate {
bin: Uint8Array;
timestamp: number;
editor?: string;
}
export interface HistoryFilter {
@@ -28,6 +30,11 @@ export interface HistoryFilter {
limit?: number;
}
export interface Editor {
name: string;
avatarUrl: string | null;
}
export interface DocStorageOptions {
mergeUpdates?: (updates: Uint8Array[]) => Promise<Uint8Array> | Uint8Array;
}
@@ -61,7 +68,7 @@ export abstract class DocStorageAdapter extends Connection {
const updates = await this.getDocUpdates(spaceId, docId);
if (updates.length) {
const { timestamp, bin } = await this.squash(
const { timestamp, bin, editor } = await this.squash(
snapshot ? [snapshot, ...updates] : updates
);
@@ -70,6 +77,7 @@ export abstract class DocStorageAdapter extends Connection {
docId,
bin,
timestamp,
editor,
};
const success = await this.setDocSnapshot(newSnapshot);
@@ -91,12 +99,14 @@ export abstract class DocStorageAdapter extends Connection {
abstract pushDocUpdates(
spaceId: string,
docId: string,
updates: Uint8Array[]
updates: Uint8Array[],
editorId?: string
): Promise<number>;
abstract deleteDoc(spaceId: string, docId: string): Promise<void>;
abstract deleteSpace(spaceId: string): Promise<void>;
async rollbackDoc(
editorId: string,
spaceId: string,
docId: string,
timestamp: number
@@ -114,7 +124,7 @@ export abstract class DocStorageAdapter extends Connection {
}
const change = this.generateChangeUpdate(fromSnapshot.bin, toSnapshot.bin);
await this.pushDocUpdates(spaceId, docId, [change]);
await this.pushDocUpdates(spaceId, docId, [change], editorId);
// force create a new history record after rollback
await this.createDocHistory(fromSnapshot, true);
}
@@ -127,7 +137,7 @@ export abstract class DocStorageAdapter extends Connection {
spaceId: string,
docId: string,
query: { skip?: number; limit?: number }
): Promise<number[]>;
): Promise<{ timestamp: number; editor: Editor | null }[]>;
abstract getDocHistory(
spaceId: string,
docId: string,
@@ -173,6 +183,7 @@ export abstract class DocStorageAdapter extends Connection {
return {
bin: finalUpdate,
timestamp: lastUpdate.timestamp,
editor: lastUpdate.editor,
};
}

View File

@@ -28,5 +28,6 @@ export {
DocStorageAdapter,
type DocStorageOptions,
type DocUpdate,
type Editor,
type HistoryFilter,
} from './doc';

View File

@@ -264,9 +264,11 @@ export class SpaceSyncGateway
};
}
@Auth()
@SubscribeMessage('space:push-doc-updates')
async onReceiveDocUpdates(
@ConnectedSocket() client: Socket,
@CurrentUser() user: CurrentUser,
@MessageBody()
message: PushDocUpdatesMessage
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
@@ -277,7 +279,8 @@ export class SpaceSyncGateway
const timestamp = await adapter.push(
spaceId,
docId,
updates.map(update => Buffer.from(update, 'base64'))
updates.map(update => Buffer.from(update, 'base64')),
user.id
);
// could be put in [adapter.push]
@@ -448,8 +451,10 @@ export class SpaceSyncGateway
});
}
@Auth()
@SubscribeMessage('client-update-v2')
async handleClientUpdateV2(
@CurrentUser() user: CurrentUser,
@MessageBody()
{
workspaceId,
@@ -462,7 +467,7 @@ export class SpaceSyncGateway
},
@ConnectedSocket() client: Socket
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
return this.onReceiveDocUpdates(client, {
return this.onReceiveDocUpdates(client, user, {
spaceType: SpaceType.Workspace,
spaceId: workspaceId,
docId: guid,
@@ -596,9 +601,9 @@ abstract class SyncSocketAdapter {
permission?: Permission
): Promise<void>;
push(spaceId: string, docId: string, updates: Buffer[]) {
push(spaceId: string, docId: string, updates: Buffer[], editorId: string) {
this.assertIn(spaceId);
return this.storage.pushDocUpdates(spaceId, docId, updates);
return this.storage.pushDocUpdates(spaceId, docId, updates, editorId);
}
get(spaceId: string, docId: string) {
@@ -621,9 +626,14 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
super(SpaceType.Workspace, client, storage);
}
override push(spaceId: string, docId: string, updates: Buffer[]) {
override push(
spaceId: string,
docId: string,
updates: Buffer[],
editorId: string
) {
const id = new DocID(docId, spaceId);
return super.push(spaceId, id.guid, updates);
return super.push(spaceId, id.guid, updates, editorId);
}
override get(spaceId: string, docId: string) {

View File

@@ -16,6 +16,7 @@ import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { Permission, PermissionService } from '../../permission';
import { DocID } from '../../utils/doc';
import { WorkspaceType } from '../types';
import { EditorType } from './workspace';
@ObjectType()
class DocHistoryType implements Partial<SnapshotHistory> {
@@ -27,6 +28,9 @@ class DocHistoryType implements Partial<SnapshotHistory> {
@Field(() => GraphQLISODateTime)
timestamp!: Date;
@Field(() => EditorType, { nullable: true })
editor!: EditorType | null;
}
@Resolver(() => WorkspaceType)
@@ -47,17 +51,18 @@ export class DocHistoryResolver {
): Promise<DocHistoryType[]> {
const docId = new DocID(guid, workspace.id);
const timestamps = await this.workspace.listDocHistories(
const histories = await this.workspace.listDocHistories(
workspace.id,
docId.guid,
{ before: timestamp.getTime(), limit: take }
);
return timestamps.map(timestamp => {
return histories.map(history => {
return {
workspaceId: workspace.id,
id: docId.guid,
timestamp: new Date(timestamp),
timestamp: new Date(history.timestamp),
editor: history.editor,
};
});
}
@@ -79,6 +84,7 @@ export class DocHistoryResolver {
);
await this.workspace.rollbackDoc(
user.id,
docId.workspace,
docId.guid,
timestamp.getTime()

View File

@@ -1,8 +1,10 @@
import { Logger } from '@nestjs/common';
import {
Args,
Field,
Int,
Mutation,
ObjectType,
Parent,
Query,
ResolveField,
@@ -16,6 +18,7 @@ import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals';
import {
CantChangeSpaceOwner,
DocNotFound,
EventEmitter,
InternalServerError,
MailService,
@@ -28,6 +31,7 @@ import {
UserNotFound,
} from '../../../fundamentals';
import { CurrentUser, Public } from '../../auth';
import type { Editor } from '../../doc';
import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
@@ -40,6 +44,30 @@ import {
} from '../types';
import { defaultWorkspaceAvatar } from '../utils';
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
class WorkspacePageMeta {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
@Field(() => EditorType, { nullable: true })
createdBy!: EditorType | null;
@Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null;
}
/**
* Workspace resolver
* Public apis rate limit: 10 req/m
@@ -140,6 +168,35 @@ export class WorkspaceResolver {
}));
}
@ResolveField(() => WorkspacePageMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
})
async pageMeta(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
const metadata = await this.prisma.snapshot.findFirst({
where: { workspaceId: workspace.id, id: pageId },
select: {
createdAt: true,
updatedAt: true,
createdByUser: { select: { name: true, avatarUrl: true } },
updatedByUser: { select: { name: true, avatarUrl: true } },
},
});
if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => QuotaQueryType, {
name: 'quota',
description: 'quota of workspace',

View File

@@ -189,6 +189,7 @@ type DocHistoryNotFoundDataType {
}
type DocHistoryType {
editor: EditorType
id: String!
timestamp: DateTime!
workspaceId: String!
@@ -199,6 +200,11 @@ type DocNotFoundDataType {
spaceId: String!
}
type EditorType {
avatarUrl: String
name: String!
}
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType
enum ErrorNames {
@@ -875,6 +881,13 @@ type WorkspacePage {
workspaceId: String!
}
type WorkspacePageMeta {
createdAt: DateTime!
createdBy: EditorType
updatedAt: DateTime!
updatedBy: EditorType
}
type WorkspaceType {
"""Available features of workspace"""
availableFeatures: [FeatureType!]!
@@ -902,6 +915,9 @@ type WorkspaceType {
"""Owner of workspace"""
owner: UserType!
"""Cloud page metadata of workspace"""
pageMeta(pageId: String!): WorkspacePageMeta!
"""Permission of current signed in user in workspace"""
permission: Permission!

View File

@@ -48,6 +48,8 @@ const snapshot: Snapshot = {
seq: 0,
updatedAt: new Date(),
createdAt: new Date(),
createdBy: null,
updatedBy: null,
};
function getSnapshot(timestamp: number = Date.now()): DocRecord {
@@ -262,6 +264,7 @@ test('should be able to recover from history', async t => {
await adapter.createDocHistory(getSnapshot(history1Timestamp));
await adapter.rollbackDoc(
undefined,
snapshot.workspaceId,
snapshot.id,
history1Timestamp

View File

@@ -89,6 +89,7 @@ export const iconNames = [
'edgeless',
'journal',
'payment',
'createdEdited',
] as const satisfies fromLibIconName<LibIconComponentName>[];
export type PagePropertyIcon = (typeof iconNames)[number];
@@ -109,6 +110,10 @@ export const getDefaultIconName = (
return 'checkBoxCheckLinear';
case 'number':
return 'number';
case 'createdBy':
return 'createdEdited';
case 'updatedBy':
return 'createdEdited';
default:
return 'text';
}

View File

@@ -35,9 +35,16 @@ export const newPropertyTypes: PagePropertyType[] = [
PagePropertyType.Number,
PagePropertyType.Checkbox,
PagePropertyType.Date,
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
// TODO(@Peng): add more
];
export const readonlyPropertyTypes: PagePropertyType[] = [
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
];
export class PagePropertiesMetaManager {
constructor(private readonly adapter: WorkspacePropertiesAdapter) {}
@@ -95,6 +102,7 @@ export class PagePropertiesMetaManager {
type,
order: newOrder,
icon: icon ?? getDefaultIconName(type),
readonly: readonlyPropertyTypes.includes(type) || undefined,
} as const;
this.customPropertiesSchema[id] = property;
return property;

View File

@@ -1,14 +1,28 @@
import { Checkbox, DatePicker, Menu } from '@affine/component';
import { Avatar, Checkbox, DatePicker, Menu } from '@affine/component';
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/properties/services/schema';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { i18nTime, useI18n } from '@affine/i18n';
import { DocService, useService } from '@toeverything/infra';
import {
DocService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { noop } from 'lodash-es';
import type { ChangeEventHandler } from 'react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { managerContext } from './common';
import * as styles from './styles.css';
@@ -190,6 +204,102 @@ export const TagsValue = () => {
);
};
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
const cloudDocMetaService = useService(CloudDocMetaService);
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
const isRevalidating = useLiveData(
cloudDocMetaService.cloudDocMeta.isRevalidating$
);
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
useEffect(() => {
cloudDocMetaService.cloudDocMeta.revalidate();
}, [cloudDocMetaService]);
const user = useMemo(() => {
if (!cloudDocMeta) return null;
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
return {
name: cloudDocMeta.createdBy.name,
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
};
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
return {
name: cloudDocMeta.updatedBy.name,
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
};
}
return null;
}, [cloudDocMeta, props.type]);
const t = useI18n();
if (!cloudDocMeta) {
if (isRevalidating) {
// TODO: loading ui
return null;
}
if (error) {
// error ui
return;
}
return null;
}
if (user) {
return (
<>
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
<span>{user.name}</span>
</>
);
}
return (
<>
<Avatar name="?" size={20} />
<span>
{t['com.affine.page-properties.property-user-avatar-no-record']()}
</span>
</>
);
};
export const LocalUserValue = () => {
const t = useI18n();
return <span>{t['com.affine.page-properties.local-user']()}</span>;
};
export const CreatedUserValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return <LocalUserValue />;
}
return (
<div className={styles.propertyRowValueUserCell}>
<CloudUserAvatar type="CreatedBy" />
</div>
);
};
export const UpdatedUserValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
if (!isCloud) {
return <LocalUserValue />;
}
return (
<div className={styles.propertyRowValueUserCell}>
<CloudUserAvatar type="UpdatedBy" />
</div>
);
};
export const propertyValueRenderers: Record<
PagePropertyType,
typeof DateValue
@@ -198,6 +308,8 @@ export const propertyValueRenderers: Record<
checkbox: CheckboxValue,
text: TextValue,
number: NumberValue,
createdBy: CreatedUserValue,
updatedBy: UpdatedUserValue,
// TODO(@Peng): fix following
tags: TagsValue,
progress: TextValue,

View File

@@ -349,6 +349,16 @@ export const propertyRowValueTextCell = style([
},
]);
export const propertyRowValueUserCell = style([
propertyRowValueCell,
{
border: 'none',
overflow: 'hidden',
columnGap: '0.5rem',
alignItems: 'center',
},
]);
export const propertyRowValueTextarea = style([
propertyRowValueCell,
{

View File

@@ -279,29 +279,51 @@ const CustomPropertyRowsList = ({
return <CustomPropertyRows properties={filtered} statistics={statistics} />;
} else {
const required = properties.filter(property => property.required);
const optional = properties.filter(property => !property.required);
const partition = Object.groupBy(properties, p =>
p.required ? 'required' : p.readonly ? 'readonly' : 'optional'
);
return (
<>
{required.length > 0 ? (
{partition.required && partition.required.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.required-properties'
]()}
</div>
<CustomPropertyRows properties={required} statistics={statistics} />
<CustomPropertyRows
properties={partition.required}
statistics={statistics}
/>
</>
) : null}
{optional.length > 0 ? (
{partition.optional && partition.optional.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.general-properties'
]()}
</div>
<CustomPropertyRows properties={optional} statistics={statistics} />
<CustomPropertyRows
properties={partition.optional}
statistics={statistics}
/>
</>
) : null}
{partition.readonly && partition.readonly.length > 0 ? (
<>
<div className={styles.subListHeader}>
{t[
'com.affine.settings.workspace.properties.readonly-properties'
]()}
</div>
<CustomPropertyRows
properties={partition.readonly}
statistics={statistics}
/>
</>
) : null}
</>

View File

@@ -0,0 +1,66 @@
import type { GetWorkspacePageMetaByIdQuery } from '@affine/graphql';
import type { DocService, GlobalCache } from '@toeverything/infra';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { CloudDocMetaStore } from '../stores/cloud-doc-meta';
export type CloudDocMetaType =
GetWorkspacePageMetaByIdQuery['workspace']['pageMeta'];
const CACHE_KEY_PREFIX = 'cloud-doc-meta:';
export class CloudDocMeta extends Entity {
constructor(
private readonly store: CloudDocMetaStore,
private readonly docService: DocService,
private readonly cache: GlobalCache
) {
super();
}
readonly docId = this.docService.doc.id;
readonly workspaceId = this.docService.doc.workspace.id;
readonly cacheKey = `${CACHE_KEY_PREFIX}${this.workspaceId}:${this.docId}`;
meta$ = LiveData.from<CloudDocMetaType | undefined>(
this.cache.watch<CloudDocMetaType>(this.cacheKey),
undefined
);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
revalidate = effect(
exhaustMapWithTrailing(() => {
return fromPromise(
this.store.fetchCloudDocMeta(this.workspaceId, this.docId)
).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(meta => {
this.cache.set<CloudDocMetaType>(this.cacheKey, meta);
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
})
);
}

View File

@@ -16,11 +16,15 @@ export { UserQuotaService } from './services/user-quota';
export { WebSocketService } from './services/websocket';
import {
DocScope,
DocService,
type Framework,
GlobalCacheService,
GlobalStateService,
GlobalCache,
GlobalState,
WorkspaceScope,
} from '@toeverything/infra';
import { CloudDocMeta } from './entities/cloud-doc-meta';
import { ServerConfig } from './entities/server-config';
import { AuthSession } from './entities/session';
import { Subscription } from './entities/subscription';
@@ -29,6 +33,7 @@ import { UserCopilotQuota } from './entities/user-copilot-quota';
import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota';
import { AuthService } from './services/auth';
import { CloudDocMetaService } from './services/cloud-doc-meta';
import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql';
import { ServerConfigService } from './services/server-config';
@@ -38,6 +43,7 @@ import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket';
import { AuthStore } from './stores/auth';
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
import { ServerConfigStore } from './stores/server-config';
import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
@@ -53,10 +59,10 @@ export function configureCloudModule(framework: Framework) {
.entity(ServerConfig, [ServerConfigStore])
.store(ServerConfigStore, [GraphQLService])
.service(AuthService, [FetchService, AuthStore])
.store(AuthStore, [FetchService, GraphQLService, GlobalStateService])
.store(AuthStore, [FetchService, GraphQLService, GlobalState])
.entity(AuthSession, [AuthStore])
.service(SubscriptionService, [SubscriptionStore])
.store(SubscriptionStore, [GraphQLService, GlobalCacheService])
.store(SubscriptionStore, [GraphQLService, GlobalCache])
.entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore])
.entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore])
.service(UserQuotaService)
@@ -71,5 +77,10 @@ export function configureCloudModule(framework: Framework) {
])
.service(UserFeatureService)
.entity(UserFeature, [AuthService, UserFeatureStore])
.store(UserFeatureStore, [GraphQLService]);
.store(UserFeatureStore, [GraphQLService])
.scope(WorkspaceScope)
.scope(DocScope)
.service(CloudDocMetaService)
.entity(CloudDocMeta, [CloudDocMetaStore, DocService, GlobalCache])
.store(CloudDocMetaStore, [GraphQLService]);
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { CloudDocMeta } from '../entities/cloud-doc-meta';
export class CloudDocMetaService extends Service {
cloudDocMeta = this.framework.createEntity(CloudDocMeta);
}

View File

@@ -4,7 +4,7 @@ import {
updateUserProfileMutation,
uploadAvatarMutation,
} from '@affine/graphql';
import type { GlobalStateService } from '@toeverything/infra';
import type { GlobalState } from '@toeverything/infra';
import { Store } from '@toeverything/infra';
import type { AuthSessionInfo } from '../entities/session';
@@ -24,19 +24,17 @@ export class AuthStore extends Store {
constructor(
private readonly fetchService: FetchService,
private readonly gqlService: GraphQLService,
private readonly globalStateService: GlobalStateService
private readonly globalState: GlobalState
) {
super();
}
watchCachedAuthSession() {
return this.globalStateService.globalState.watch<AuthSessionInfo>(
'affine-cloud-auth'
);
return this.globalState.watch<AuthSessionInfo>('affine-cloud-auth');
}
setCachedAuthSession(session: AuthSessionInfo | null) {
this.globalStateService.globalState.set('affine-cloud-auth', session);
this.globalState.set('affine-cloud-auth', session);
}
async fetchSession() {

View File

@@ -0,0 +1,26 @@
import { getWorkspacePageMetaByIdQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import { type CloudDocMetaType } from '../entities/cloud-doc-meta';
import type { GraphQLService } from '../services/graphql';
export class CloudDocMetaStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async fetchCloudDocMeta(
workspaceId: string,
docId: string,
abortSignal?: AbortSignal
): Promise<CloudDocMetaType> {
const serverConfigData = await this.gqlService.gql({
query: getWorkspacePageMetaByIdQuery,
variables: { id: workspaceId, pageId: docId },
context: {
signal: abortSignal,
},
});
return serverConfigData.workspace.pageMeta;
}
}

View File

@@ -12,7 +12,7 @@ import {
subscriptionQuery,
updateSubscriptionMutation,
} from '@affine/graphql';
import type { GlobalCacheService } from '@toeverything/infra';
import type { GlobalCache } from '@toeverything/infra';
import { Store } from '@toeverything/infra';
import type { SubscriptionType } from '../entities/subscription';
@@ -37,7 +37,7 @@ const getDefaultSubscriptionSuccessCallbackLink = (
export class SubscriptionStore extends Store {
constructor(
private readonly gqlService: GraphQLService,
private readonly globalCacheService: GlobalCacheService
private readonly globalCache: GlobalCache
) {
super();
}
@@ -97,16 +97,13 @@ export class SubscriptionStore extends Store {
}
getCachedSubscriptions(userId: string) {
return this.globalCacheService.globalCache.get<SubscriptionType[]>(
return this.globalCache.get<SubscriptionType[]>(
SUBSCRIPTION_CACHE_KEY + userId
);
}
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
return this.globalCacheService.globalCache.set(
SUBSCRIPTION_CACHE_KEY + userId,
subscriptions
);
return this.globalCache.set(SUBSCRIPTION_CACHE_KEY + userId, subscriptions);
}
setSubscriptionRecurring(

View File

@@ -21,6 +21,8 @@ export enum PagePropertyType {
Progress = 'progress',
Checkbox = 'checkbox',
Tags = 'tags',
CreatedBy = 'createdBy',
UpdatedBy = 'updatedBy',
}
export const PagePropertyMetaBaseSchema = z.object({
@@ -30,6 +32,7 @@ export const PagePropertyMetaBaseSchema = z.object({
type: z.nativeEnum(PagePropertyType),
icon: z.string(),
required: z.boolean().optional(),
readonly: z.boolean().optional(),
});
export const PageSystemPropertyMetaBaseSchema =

View File

@@ -6,12 +6,13 @@ import {
catchErrorInto,
effect,
Entity,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { ShareDocsStore } from '../stores/share-docs';
@@ -35,7 +36,7 @@ export class ShareDocsList extends Entity {
}
revalidate = effect(
switchMap(() =>
exhaustMapWithTrailing(() =>
fromPromise(signal => {
return this.store.getWorkspacesShareDocs(
this.workspaceService.workspace.id,

View File

@@ -0,0 +1,16 @@
query getWorkspacePageMetaById($id: String!, $pageId: String!) {
workspace(id: $id) {
pageMeta(pageId: $pageId) {
createdAt
updatedAt
createdBy {
name
avatarUrl
}
updatedBy {
name
avatarUrl
}
}
}
}

View File

@@ -8,6 +8,10 @@ query listHistory(
histories(guid: $pageDocId, take: $take, before: $before) {
id
timestamp
editor {
name
avatarUrl
}
}
}
}

View File

@@ -610,6 +610,30 @@ query getWorkspaceFeatures($workspaceId: String!) {
}`,
};
export const getWorkspacePageMetaByIdQuery = {
id: 'getWorkspacePageMetaByIdQuery' as const,
operationName: 'getWorkspacePageMetaById',
definitionName: 'workspace',
containsFile: false,
query: `
query getWorkspacePageMetaById($id: String!, $pageId: String!) {
workspace(id: $id) {
pageMeta(pageId: $pageId) {
createdAt
updatedAt
createdBy {
name
avatarUrl
}
updatedBy {
name
avatarUrl
}
}
}
}`,
};
export const getWorkspacePublicByIdQuery = {
id: 'getWorkspacePublicByIdQuery' as const,
operationName: 'getWorkspacePublicById',
@@ -695,6 +719,10 @@ query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $befor
histories(guid: $pageDocId, take: $take, before: $before) {
id
timestamp
editor {
name
avatarUrl
}
}
}
}`,

View File

@@ -238,6 +238,7 @@ export interface DocHistoryNotFoundDataType {
export interface DocHistoryType {
__typename?: 'DocHistoryType';
editor: Maybe<EditorType>;
id: Scalars['String']['output'];
timestamp: Scalars['DateTime']['output'];
workspaceId: Scalars['String']['output'];
@@ -249,6 +250,12 @@ export interface DocNotFoundDataType {
spaceId: Scalars['String']['output'];
}
export interface EditorType {
__typename?: 'EditorType';
avatarUrl: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
}
export type ErrorDataUnion =
| AlreadyInSpaceDataType
| BlobNotFoundDataType
@@ -1189,6 +1196,14 @@ export interface WorkspacePage {
workspaceId: Scalars['String']['output'];
}
export interface WorkspacePageMeta {
__typename?: 'WorkspacePageMeta';
createdAt: Scalars['DateTime']['output'];
createdBy: Maybe<EditorType>;
updatedAt: Scalars['DateTime']['output'];
updatedBy: Maybe<EditorType>;
}
export interface WorkspaceType {
__typename?: 'WorkspaceType';
/** Available features of workspace */
@@ -1209,6 +1224,8 @@ export interface WorkspaceType {
members: Array<InviteUserType>;
/** Owner of workspace */
owner: UserType;
/** Cloud page metadata of workspace */
pageMeta: WorkspacePageMeta;
/** Permission of current signed in user in workspace */
permission: Permission;
/** is Public workspace */
@@ -1237,6 +1254,10 @@ export interface WorkspaceTypeMembersArgs {
take: InputMaybe<Scalars['Int']['input']>;
}
export interface WorkspaceTypePageMetaArgs {
pageId: Scalars['String']['input'];
}
export interface WorkspaceTypePublicPageArgs {
pageId: Scalars['String']['input'];
}
@@ -1785,6 +1806,33 @@ export type GetWorkspaceFeaturesQuery = {
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
};
export type GetWorkspacePageMetaByIdQueryVariables = Exact<{
id: Scalars['String']['input'];
pageId: Scalars['String']['input'];
}>;
export type GetWorkspacePageMetaByIdQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
pageMeta: {
__typename?: 'WorkspacePageMeta';
createdAt: string;
updatedAt: string;
createdBy: {
__typename?: 'EditorType';
name: string;
avatarUrl: string | null;
} | null;
updatedBy: {
__typename?: 'EditorType';
name: string;
avatarUrl: string | null;
} | null;
};
};
};
export type GetWorkspacePublicByIdQueryVariables = Exact<{
id: Scalars['String']['input'];
}>;
@@ -1862,6 +1910,11 @@ export type ListHistoryQuery = {
__typename?: 'DocHistoryType';
id: string;
timestamp: string;
editor: {
__typename?: 'EditorType';
name: string;
avatarUrl: string | null;
} | null;
}>;
};
};
@@ -2484,6 +2537,11 @@ export type Queries =
variables: GetWorkspaceFeaturesQueryVariables;
response: GetWorkspaceFeaturesQuery;
}
| {
name: 'getWorkspacePageMetaByIdQuery';
variables: GetWorkspacePageMetaByIdQueryVariables;
response: GetWorkspacePageMetaByIdQuery;
}
| {
name: 'getWorkspacePublicByIdQuery';
variables: GetWorkspacePublicByIdQueryVariables;

View File

@@ -856,6 +856,8 @@
"com.affine.page-properties.page-info": "Info",
"com.affine.page-properties.page-info.view": "View Info",
"com.affine.page-properties.property-value-placeholder": "Empty",
"com.affine.page-properties.property-user-avatar-no-record": "No Record",
"com.affine.page-properties.property-user-local": "Local User",
"com.affine.page-properties.property.always-hide": "Always hide",
"com.affine.page-properties.property.always-show": "Always show",
"com.affine.page-properties.property.checkbox": "Checkbox",
@@ -870,6 +872,9 @@
"com.affine.page-properties.property.show-in-view": "Show in view",
"com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.property.text": "Text",
"com.affine.page-properties.property.createdBy": "Created by",
"com.affine.page-properties.property.updatedBy": "Last edited by",
"com.affine.page-properties.local-user": "Local user",
"com.affine.page-properties.settings.title": "customize properties",
"com.affine.page-properties.tags.open-tags-page": "Open tag page",
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one",
@@ -1354,6 +1359,7 @@
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
"com.affine.settings.workspace.properties.edit-property": "Edit property",
"com.affine.settings.workspace.properties.general-properties": "General properties",
"com.affine.settings.workspace.properties.readonly-properties": "Readonly properties",
"com.affine.settings.workspace.properties.header.subtitle": "Manage workspace <1>{{name}}</1> properties",
"com.affine.settings.workspace.properties.header.title": "Properties",
"com.affine.settings.workspace.properties.in-use": "In use",

View File

@@ -137,4 +137,6 @@ test('add custom property', async ({ page }) => {
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
});

View File

@@ -85,6 +85,8 @@ test('add custom property', async ({ page }) => {
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
});
test('add custom property & edit', async ({ page }) => {
@@ -103,6 +105,8 @@ test('property table reordering', async ({ page }) => {
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await dragTo(
page,
@@ -119,6 +123,8 @@ test('property table reordering', async ({ page }) => {
'Date',
'Checkbox',
'Text',
'Created by',
'Last edited by',
].entries()) {
await expect(
page
@@ -141,6 +147,8 @@ test('page info show more will show all properties', async ({ page }) => {
await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await expect(page.getByTestId('page-info-show-more')).toBeVisible();
await page.click('[data-testid="page-info-show-more"]');
@@ -156,6 +164,8 @@ test('page info show more will show all properties', async ({ page }) => {
'Number',
'Date',
'Checkbox',
'Created by',
'Last edited by',
].entries()) {
await expect(
page