From d93d39e29dc8da6fa45a705111ef3b35914469c2 Mon Sep 17 00:00:00 2001 From: darkskygit Date: Mon, 2 Sep 2024 09:37:39 +0000 Subject: [PATCH] feat: add editor record (#7938) fix CLOUD-58, CLOUD-61, CLOUD-62, PD-1607, PD-1608 --- .../migration.sql | 21 ++++ packages/backend/server/schema.prisma | 24 +++- .../server/src/core/doc/adapters/userspace.ts | 9 +- .../server/src/core/doc/adapters/workspace.ts | 38 ++++-- packages/backend/server/src/core/doc/index.ts | 2 +- .../server/src/core/doc/storage/doc.ts | 21 +++- .../server/src/core/doc/storage/index.ts | 1 + .../backend/server/src/core/sync/gateway.ts | 22 +++- .../src/core/workspaces/resolvers/history.ts | 14 ++- .../core/workspaces/resolvers/workspace.ts | 57 +++++++++ packages/backend/server/src/schema.gql | 16 +++ .../backend/server/tests/doc/history.spec.ts | 2 + .../server/tests/doc/workspace.spec.ts | 2 + .../affine/page-properties/icons-mapping.tsx | 5 + .../page-properties-manager.ts | 8 ++ .../property-row-value-renderer.tsx | 118 +++++++++++++++++- .../affine/page-properties/styles.css.ts | 10 ++ .../workspace-setting/properties/index.tsx | 34 ++++- .../modules/cloud/entities/cloud-doc-meta.ts | 66 ++++++++++ .../frontend/core/src/modules/cloud/index.ts | 21 +++- .../modules/cloud/services/cloud-doc-meta.ts | 7 ++ .../core/src/modules/cloud/stores/auth.ts | 10 +- .../modules/cloud/stores/cloud-doc-meta.ts | 26 ++++ .../src/modules/cloud/stores/subscription.ts | 11 +- .../src/modules/properties/services/schema.ts | 3 + .../share-doc/entities/share-docs-list.ts | 5 +- .../src/graphql/get-workspace-page-meta.gql | 16 +++ .../graphql/src/graphql/histories.gql | 4 + .../frontend/graphql/src/graphql/index.ts | 28 +++++ packages/frontend/graphql/src/schema.ts | 58 +++++++++ packages/frontend/i18n/src/resources/en.json | 6 + tests/affine-local/e2e/doc-info-modal.spec.ts | 2 + .../affine-local/e2e/page-properties.spec.ts | 10 ++ 33 files changed, 622 insertions(+), 55 deletions(-) create mode 100644 packages/backend/server/migrations/20240826033024_editor_record/migration.sql create mode 100644 packages/frontend/core/src/modules/cloud/entities/cloud-doc-meta.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/cloud-doc-meta.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/cloud-doc-meta.ts create mode 100644 packages/frontend/graphql/src/graphql/get-workspace-page-meta.gql diff --git a/packages/backend/server/migrations/20240826033024_editor_record/migration.sql b/packages/backend/server/migrations/20240826033024_editor_record/migration.sql new file mode 100644 index 0000000000..13b0e31617 --- /dev/null +++ b/packages/backend/server/migrations/20240826033024_editor_record/migration.sql @@ -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; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 09ff855690..eb91a3b987 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -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") diff --git a/packages/backend/server/src/core/doc/adapters/userspace.ts b/packages/backend/server/src/core/doc/adapters/userspace.ts index b5fd9a2655..99b9289e25 100644 --- a/packages/backend/server/src/core/doc/adapters/userspace.ts +++ b/packages/backend/server/src/core/doc/adapters/userspace.ts @@ -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, }; } diff --git a/packages/backend/server/src/core/doc/adapters/workspace.ts b/packages/backend/server/src/core/doc/adapters/workspace.ts index 2325595c08..710e07ac0c 100644 --- a/packages/backend/server/src/core/doc/adapters/workspace.ts +++ b/packages/backend/server/src/core/doc/adapters/workspace.ts @@ -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 || null, }; }), }); @@ -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,13 +265,15 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter { docId, bin: history.blob, timestamp, + editor: history.createdBy || undefined, }; } override async rollbackDoc( spaceId: string, docId: string, - timestamp: number + timestamp: number, + editorId?: string ): Promise { await using _lock = await this.lockDocForUpdate(spaceId, docId); const toSnapshot = await this.getDocHistory(spaceId, docId, timestamp); @@ -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" `; diff --git a/packages/backend/server/src/core/doc/index.ts b/packages/backend/server/src/core/doc/index.ts index 55698352ca..b0b7cb226e 100644 --- a/packages/backend/server/src/core/doc/index.ts +++ b/packages/backend/server/src/core/doc/index.ts @@ -22,4 +22,4 @@ import { DocStorageOptions } from './options'; export class DocStorageModule {} export { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter }; -export { DocStorageAdapter } from './storage'; +export { DocStorageAdapter, type Editor } from './storage'; diff --git a/packages/backend/server/src/core/doc/storage/doc.ts b/packages/backend/server/src/core/doc/storage/doc.ts index 1e5dd8cb02..999a9226fb 100644 --- a/packages/backend/server/src/core/doc/storage/doc.ts +++ b/packages/backend/server/src/core/doc/storage/doc.ts @@ -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; } @@ -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,7 +99,8 @@ export abstract class DocStorageAdapter extends Connection { abstract pushDocUpdates( spaceId: string, docId: string, - updates: Uint8Array[] + updates: Uint8Array[], + editorId?: string ): Promise; abstract deleteDoc(spaceId: string, docId: string): Promise; @@ -99,7 +108,8 @@ export abstract class DocStorageAdapter extends Connection { async rollbackDoc( spaceId: string, docId: string, - timestamp: number + timestamp: number, + editorId?: string ): Promise { await using _lock = await this.lockDocForUpdate(spaceId, docId); const toSnapshot = await this.getDocHistory(spaceId, docId, timestamp); @@ -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; + ): 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, }; } diff --git a/packages/backend/server/src/core/doc/storage/index.ts b/packages/backend/server/src/core/doc/storage/index.ts index a69fc46d92..6ba0e23dd1 100644 --- a/packages/backend/server/src/core/doc/storage/index.ts +++ b/packages/backend/server/src/core/doc/storage/index.ts @@ -28,5 +28,6 @@ export { DocStorageAdapter, type DocStorageOptions, type DocUpdate, + type Editor, type HistoryFilter, } from './doc'; diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index b026c430ef..0c7a815b81 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -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> { @@ -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> { - 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; - 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) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index d11d2b6f03..8812d6bb3c 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -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 { @@ -27,6 +28,9 @@ class DocHistoryType implements Partial { @Field(() => GraphQLISODateTime) timestamp!: Date; + + @Field(() => EditorType, { nullable: true }) + editor!: EditorType | null; } @Resolver(() => WorkspaceType) @@ -47,17 +51,18 @@ export class DocHistoryResolver { ): Promise { 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, }; }); } @@ -81,7 +86,8 @@ export class DocHistoryResolver { await this.workspace.rollbackDoc( docId.workspace, docId.guid, - timestamp.getTime() + timestamp.getTime(), + user.id ); return timestamp; diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index ea7ff150b7..16037f03f3 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -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 { + @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 @@ -155,6 +183,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', diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index b97103c91a..c656686bfa 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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!]! @@ -905,6 +918,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! diff --git a/packages/backend/server/tests/doc/history.spec.ts b/packages/backend/server/tests/doc/history.spec.ts index 4da253c259..70350e08fc 100644 --- a/packages/backend/server/tests/doc/history.spec.ts +++ b/packages/backend/server/tests/doc/history.spec.ts @@ -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 { diff --git a/packages/backend/server/tests/doc/workspace.spec.ts b/packages/backend/server/tests/doc/workspace.spec.ts index 34389f1ff5..879f45f9b1 100644 --- a/packages/backend/server/tests/doc/workspace.spec.ts +++ b/packages/backend/server/tests/doc/workspace.spec.ts @@ -177,6 +177,7 @@ test('should be able to merge updates as snapshot', async t => { blob: Buffer.from(update), seq: 1, createdAt: new Date(Date.now() + 1), + createdBy: null, }, ], }); @@ -199,6 +200,7 @@ test('should be able to merge updates as snapshot', async t => { blob: appendUpdate, seq: 2, createdAt: new Date(), + createdBy: null, }, }); diff --git a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx b/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx index ba192afa6f..9a394d1caa 100644 --- a/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/icons-mapping.tsx @@ -89,6 +89,7 @@ export const iconNames = [ 'edgeless', 'journal', 'payment', + 'createdEdited', ] as const satisfies fromLibIconName[]; 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'; } diff --git a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts index 6db0607a62..bf1d96befb 100644 --- a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts +++ b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts @@ -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; diff --git a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx index f894280898..465ec74caf 100644 --- a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx @@ -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 ( + <> + + {user.name} + + ); + } + return ( + <> + + + {t['com.affine.page-properties.property-user-avatar-no-record']()} + + + ); +}; + +export const LocalUserValue = () => { + const t = useI18n(); + return {t['com.affine.page-properties.local-user']()}; +}; + +export const CreatedUserValue = () => { + const workspaceService = useService(WorkspaceService); + const isCloud = + workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; + + if (!isCloud) { + return ; + } + + return ( +
+ +
+ ); +}; + +export const UpdatedUserValue = () => { + const workspaceService = useService(WorkspaceService); + const isCloud = + workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; + + if (!isCloud) { + return ; + } + + return ( +
+ +
+ ); +}; + 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, diff --git a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts index d34aed52aa..037956ce0e 100644 --- a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts @@ -361,6 +361,16 @@ export const propertyRowValueTextCell = style([ }, ]); +export const propertyRowValueUserCell = style([ + propertyRowValueCell, + { + border: 'none', + overflow: 'hidden', + columnGap: '0.5rem', + alignItems: 'center', + }, +]); + export const propertyRowValueTextarea = style([ propertyRowValueCell, { diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/index.tsx index 30bc06e094..5017d7d50a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/index.tsx @@ -279,29 +279,51 @@ const CustomPropertyRowsList = ({ return ; } 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 ? ( <>
{t[ 'com.affine.settings.workspace.properties.required-properties' ]()}
- + ) : null} - {optional.length > 0 ? ( + {partition.optional && partition.optional.length > 0 ? ( <>
{t[ 'com.affine.settings.workspace.properties.general-properties' ]()}
- + + + ) : null} + + {partition.readonly && partition.readonly.length > 0 ? ( + <> +
+ {t[ + 'com.affine.settings.workspace.properties.readonly-properties' + ]()} +
+ ) : null} diff --git a/packages/frontend/core/src/modules/cloud/entities/cloud-doc-meta.ts b/packages/frontend/core/src/modules/cloud/entities/cloud-doc-meta.ts new file mode 100644 index 0000000000..94e30a0f82 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/cloud-doc-meta.ts @@ -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( + this.cache.watch(this.cacheKey), + undefined + ); + isRevalidating$ = new LiveData(false); + error$ = new LiveData(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(this.cacheKey, meta); + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 499840d6ae..add141d3cc 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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]); } diff --git a/packages/frontend/core/src/modules/cloud/services/cloud-doc-meta.ts b/packages/frontend/core/src/modules/cloud/services/cloud-doc-meta.ts new file mode 100644 index 0000000000..e1ced639bb --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/cloud-doc-meta.ts @@ -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); +} diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index c85ce2fcb9..535fb17781 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -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( - 'affine-cloud-auth' - ); + return this.globalState.watch('affine-cloud-auth'); } setCachedAuthSession(session: AuthSessionInfo | null) { - this.globalStateService.globalState.set('affine-cloud-auth', session); + this.globalState.set('affine-cloud-auth', session); } async fetchSession() { diff --git a/packages/frontend/core/src/modules/cloud/stores/cloud-doc-meta.ts b/packages/frontend/core/src/modules/cloud/stores/cloud-doc-meta.ts new file mode 100644 index 0000000000..3804babf4b --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/cloud-doc-meta.ts @@ -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 { + const serverConfigData = await this.gqlService.gql({ + query: getWorkspacePageMetaByIdQuery, + variables: { id: workspaceId, pageId: docId }, + context: { + signal: abortSignal, + }, + }); + return serverConfigData.workspace.pageMeta; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/subscription.ts b/packages/frontend/core/src/modules/cloud/stores/subscription.ts index d8b04ca234..2002dd2b4f 100644 --- a/packages/frontend/core/src/modules/cloud/stores/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/stores/subscription.ts @@ -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( + return this.globalCache.get( 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( diff --git a/packages/frontend/core/src/modules/properties/services/schema.ts b/packages/frontend/core/src/modules/properties/services/schema.ts index b93a607b3e..dd7c670882 100644 --- a/packages/frontend/core/src/modules/properties/services/schema.ts +++ b/packages/frontend/core/src/modules/properties/services/schema.ts @@ -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 = diff --git a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts index f3d83da554..d0bb50532b 100644 --- a/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts +++ b/packages/frontend/core/src/modules/share-doc/entities/share-docs-list.ts @@ -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, diff --git a/packages/frontend/graphql/src/graphql/get-workspace-page-meta.gql b/packages/frontend/graphql/src/graphql/get-workspace-page-meta.gql new file mode 100644 index 0000000000..1c047d5521 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-page-meta.gql @@ -0,0 +1,16 @@ +query getWorkspacePageMetaById($id: String!, $pageId: String!) { + workspace(id: $id) { + pageMeta(pageId: $pageId) { + createdAt + updatedAt + createdBy { + name + avatarUrl + } + updatedBy { + name + avatarUrl + } + } + } +} diff --git a/packages/frontend/graphql/src/graphql/histories.gql b/packages/frontend/graphql/src/graphql/histories.gql index b6aade3e0c..90eef6ab86 100644 --- a/packages/frontend/graphql/src/graphql/histories.gql +++ b/packages/frontend/graphql/src/graphql/histories.gql @@ -8,6 +8,10 @@ query listHistory( histories(guid: $pageDocId, take: $take, before: $before) { id timestamp + editor { + name + avatarUrl + } } } } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 9ffcaeccd9..ef12a94e5d 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -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', @@ -696,6 +720,10 @@ query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $befor histories(guid: $pageDocId, take: $take, before: $before) { id timestamp + editor { + name + avatarUrl + } } } }`, diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 3aa326ddc5..3efd417d9a 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -238,6 +238,7 @@ export interface DocHistoryNotFoundDataType { export interface DocHistoryType { __typename?: 'DocHistoryType'; + editor: Maybe; 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; + 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; + updatedAt: Scalars['DateTime']['output']; + updatedBy: Maybe; +} + export interface WorkspaceType { __typename?: 'WorkspaceType'; /** Available features of workspace */ @@ -1211,6 +1226,8 @@ export interface WorkspaceType { members: Array; /** 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 */ @@ -1239,6 +1256,10 @@ export interface WorkspaceTypeMembersArgs { take: InputMaybe; } +export interface WorkspaceTypePageMetaArgs { + pageId: Scalars['String']['input']; +} + export interface WorkspaceTypePublicPageArgs { pageId: Scalars['String']['input']; } @@ -1787,6 +1808,33 @@ export type GetWorkspaceFeaturesQuery = { workspace: { __typename?: 'WorkspaceType'; features: Array }; }; +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']; }>; @@ -1865,6 +1913,11 @@ export type ListHistoryQuery = { __typename?: 'DocHistoryType'; id: string; timestamp: string; + editor: { + __typename?: 'EditorType'; + name: string; + avatarUrl: string | null; + } | null; }>; }; }; @@ -2487,6 +2540,11 @@ export type Queries = variables: GetWorkspaceFeaturesQueryVariables; response: GetWorkspaceFeaturesQuery; } + | { + name: 'getWorkspacePageMetaByIdQuery'; + variables: GetWorkspacePageMetaByIdQueryVariables; + response: GetWorkspacePageMetaByIdQuery; + } | { name: 'getWorkspacePublicByIdQuery'; variables: GetWorkspacePublicByIdQueryVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index c0a0ecde6a..325dc9b24f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -858,6 +858,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", @@ -872,6 +874,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", @@ -1356,6 +1361,7 @@ "com.affine.settings.workspace.properties.doc_others": "<0>{{count}} 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}} properties", "com.affine.settings.workspace.properties.header.title": "Properties", "com.affine.settings.workspace.properties.in-use": "In use", diff --git a/tests/affine-local/e2e/doc-info-modal.spec.ts b/tests/affine-local/e2e/doc-info-modal.spec.ts index 78fec7a533..94edbe9a8d 100644 --- a/tests/affine-local/e2e/doc-info-modal.spec.ts +++ b/tests/affine-local/e2e/doc-info-modal.spec.ts @@ -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'); }); diff --git a/tests/affine-local/e2e/page-properties.spec.ts b/tests/affine-local/e2e/page-properties.spec.ts index d96fb3cbcb..959fcbf857 100644 --- a/tests/affine-local/e2e/page-properties.spec.ts +++ b/tests/affine-local/e2e/page-properties.spec.ts @@ -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