feat: add editor record (#7938)

fix CLOUD-58, CLOUD-61, CLOUD-62, PD-1607, PD-1608
This commit is contained in:
darkskygit
2024-09-02 09:37:39 +00:00
parent d9cedf89e1
commit d93d39e29d
33 changed files with 622 additions and 55 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[] aiSessions AiSession[]
updatedRuntimeConfigs RuntimeConfig[] updatedRuntimeConfigs RuntimeConfig[]
userSnapshots UserSnapshot[] userSnapshots UserSnapshot[]
createdSnapshot Snapshot[] @relation("createdSnapshot")
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
createdUpdate Update[] @relation("createdUpdate")
createdHistory SnapshotHistory[] @relation("createdHistory")
@@index([email]) @@index([email])
@@map("users") @@map("users")
@@ -241,9 +245,16 @@ model Snapshot {
// the `updated_at` field will not record the time of record changed, // 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. // but the created time of last seen update that has been merged into snapshot.
updatedAt DateTime @map("updated_at") @db.Timestamptz(3) 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 // @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 // 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) // workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -274,9 +285,14 @@ model Update {
id String @map("guid") @db.VarChar id String @map("guid") @db.VarChar
blob Bytes @db.ByteA blob Bytes @db.ByteA
createdAt DateTime @map("created_at") @db.Timestamptz(3) 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 // @deprecated use createdAt only
seq Int? @db.Integer seq Int? @db.Integer
@@id([workspaceId, id, createdAt]) @@id([workspaceId, id, createdAt])
@@map("updates") @@map("updates")
@@ -289,6 +305,10 @@ model SnapshotHistory {
blob Bytes @db.ByteA blob Bytes @db.ByteA
state Bytes? @db.ByteA state Bytes? @db.ByteA
expiredAt DateTime @map("expired_at") @db.Timestamptz(3) 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]) @@id([workspaceId, id, timestamp])
@@map("snapshot_histories") @@map("snapshot_histories")

View File

@@ -45,7 +45,12 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
return this.getDocSnapshot(spaceId, docId); 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) { if (!updates.length) {
return 0; return 0;
} }
@@ -67,6 +72,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
docId, docId,
bin, bin,
timestamp, timestamp,
editor: editorId,
}); });
return timestamp; return timestamp;
@@ -135,6 +141,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
docId, docId,
bin: snapshot.blob, bin: snapshot.blob,
timestamp: snapshot.updatedAt.getTime(), timestamp: snapshot.updatedAt.getTime(),
editor: snapshot.userId,
}; };
} }

View File

@@ -38,7 +38,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
async pushDocUpdates( async pushDocUpdates(
workspaceId: string, workspaceId: string,
docId: string, docId: string,
updates: Uint8Array[] updates: Uint8Array[],
editorId?: string
) { ) {
if (!updates.length) { if (!updates.length) {
return 0; return 0;
@@ -82,6 +83,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
blob: Buffer.from(update), blob: Buffer.from(update),
seq, seq,
createdAt: new Date(createdAt), createdAt: new Date(createdAt),
createdBy: editorId || null,
}; };
}), }),
}); });
@@ -113,6 +115,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
return rows.map(row => ({ return rows.map(row => ({
bin: row.blob, bin: row.blob,
timestamp: row.createdAt.getTime(), timestamp: row.createdAt.getTime(),
editor: row.createdBy || undefined,
})); }));
} }
@@ -216,6 +219,12 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
const histories = await this.db.snapshotHistory.findMany({ const histories = await this.db.snapshotHistory.findMany({
select: { select: {
timestamp: true, timestamp: true,
createdByUser: {
select: {
name: true,
avatarUrl: true,
},
},
}, },
where: { where: {
workspaceId, workspaceId,
@@ -230,7 +239,10 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
take: query.limit, 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) { async getDocHistory(workspaceId: string, docId: string, timestamp: number) {
@@ -253,13 +265,15 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
docId, docId,
bin: history.blob, bin: history.blob,
timestamp, timestamp,
editor: history.createdBy || undefined,
}; };
} }
override async rollbackDoc( override async rollbackDoc(
spaceId: string, spaceId: string,
docId: string, docId: string,
timestamp: number timestamp: number,
editorId?: string
): Promise<void> { ): Promise<void> {
await using _lock = await this.lockDocForUpdate(spaceId, docId); await using _lock = await this.lockDocForUpdate(spaceId, docId);
const toSnapshot = await this.getDocHistory(spaceId, docId, timestamp); 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 // 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: // WARN:
// we should never do the snapshot updating in recovering, // we should never do the snapshot updating in recovering,
// which is not the solution in CRDT. // which is not the solution in CRDT.
@@ -331,6 +352,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
id: snapshot.docId, id: snapshot.docId,
timestamp: new Date(snapshot.timestamp), timestamp: new Date(snapshot.timestamp),
blob: Buffer.from(snapshot.bin), blob: Buffer.from(snapshot.bin),
createdBy: snapshot.editor,
expiredAt: new Date( expiredAt: new Date(
Date.now() + (await this.options.historyMaxAge(snapshot.spaceId)) Date.now() + (await this.options.historyMaxAge(snapshot.spaceId))
), ),
@@ -374,6 +396,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
docId, docId,
bin: snapshot.blob, bin: snapshot.blob,
timestamp: snapshot.updatedAt.getTime(), 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 { try {
const result: { updatedAt: Date }[] = await this.db.$queryRaw` const result: { updatedAt: Date }[] = await this.db.$queryRaw`
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at") INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at", "created_by", "updated_by")
VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt}) VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt}, ${snapshot.editor}, ${snapshot.editor})
ON CONFLICT ("workspace_id", "guid") 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} 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" 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 class DocStorageModule {}
export { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter }; export { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter };
export { DocStorageAdapter } from './storage'; export { DocStorageAdapter, type Editor } from './storage';

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { import {
Args, Args,
Field,
Int, Int,
Mutation, Mutation,
ObjectType,
Parent, Parent,
Query, Query,
ResolveField, ResolveField,
@@ -16,6 +18,7 @@ import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals'; import type { FileUpload } from '../../../fundamentals';
import { import {
CantChangeSpaceOwner, CantChangeSpaceOwner,
DocNotFound,
EventEmitter, EventEmitter,
InternalServerError, InternalServerError,
MailService, MailService,
@@ -28,6 +31,7 @@ import {
UserNotFound, UserNotFound,
} from '../../../fundamentals'; } from '../../../fundamentals';
import { CurrentUser, Public } from '../../auth'; import { CurrentUser, Public } from '../../auth';
import type { Editor } from '../../doc';
import { Permission, PermissionService } from '../../permission'; import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
@@ -40,6 +44,30 @@ import {
} from '../types'; } from '../types';
import { defaultWorkspaceAvatar } from '../utils'; 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 * Workspace resolver
* Public apis rate limit: 10 req/m * 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, { @ResolveField(() => QuotaQueryType, {
name: 'quota', name: 'quota',
description: 'quota of workspace', description: 'quota of workspace',

View File

@@ -189,6 +189,7 @@ type DocHistoryNotFoundDataType {
} }
type DocHistoryType { type DocHistoryType {
editor: EditorType
id: String! id: String!
timestamp: DateTime! timestamp: DateTime!
workspaceId: String! workspaceId: String!
@@ -199,6 +200,11 @@ type DocNotFoundDataType {
spaceId: String! 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 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 { enum ErrorNames {
@@ -875,6 +881,13 @@ type WorkspacePage {
workspaceId: String! workspaceId: String!
} }
type WorkspacePageMeta {
createdAt: DateTime!
createdBy: EditorType
updatedAt: DateTime!
updatedBy: EditorType
}
type WorkspaceType { type WorkspaceType {
"""Available features of workspace""" """Available features of workspace"""
availableFeatures: [FeatureType!]! availableFeatures: [FeatureType!]!
@@ -905,6 +918,9 @@ type WorkspaceType {
"""Owner of workspace""" """Owner of workspace"""
owner: UserType! owner: UserType!
"""Cloud page metadata of workspace"""
pageMeta(pageId: String!): WorkspacePageMeta!
"""Permission of current signed in user in workspace""" """Permission of current signed in user in workspace"""
permission: Permission! permission: Permission!

View File

@@ -48,6 +48,8 @@ const snapshot: Snapshot = {
seq: 0, seq: 0,
updatedAt: new Date(), updatedAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
createdBy: null,
updatedBy: null,
}; };
function getSnapshot(timestamp: number = Date.now()): DocRecord { function getSnapshot(timestamp: number = Date.now()): DocRecord {

View File

@@ -177,6 +177,7 @@ test('should be able to merge updates as snapshot', async t => {
blob: Buffer.from(update), blob: Buffer.from(update),
seq: 1, seq: 1,
createdAt: new Date(Date.now() + 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, blob: appendUpdate,
seq: 2, seq: 2,
createdAt: new Date(), createdAt: new Date(),
createdBy: null,
}, },
}); });

View File

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

View File

@@ -35,9 +35,16 @@ export const newPropertyTypes: PagePropertyType[] = [
PagePropertyType.Number, PagePropertyType.Number,
PagePropertyType.Checkbox, PagePropertyType.Checkbox,
PagePropertyType.Date, PagePropertyType.Date,
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
// TODO(@Peng): add more // TODO(@Peng): add more
]; ];
export const readonlyPropertyTypes: PagePropertyType[] = [
PagePropertyType.CreatedBy,
PagePropertyType.UpdatedBy,
];
export class PagePropertiesMetaManager { export class PagePropertiesMetaManager {
constructor(private readonly adapter: WorkspacePropertiesAdapter) {} constructor(private readonly adapter: WorkspacePropertiesAdapter) {}
@@ -95,6 +102,7 @@ export class PagePropertiesMetaManager {
type, type,
order: newOrder, order: newOrder,
icon: icon ?? getDefaultIconName(type), icon: icon ?? getDefaultIconName(type),
readonly: readonlyPropertyTypes.includes(type) || undefined,
} as const; } as const;
this.customPropertiesSchema[id] = property; this.customPropertiesSchema[id] = property;
return 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 { import type {
PageInfoCustomProperty, PageInfoCustomProperty,
PageInfoCustomPropertyMeta, PageInfoCustomPropertyMeta,
PagePropertyType, PagePropertyType,
} from '@affine/core/modules/properties/services/schema'; } from '@affine/core/modules/properties/services/schema';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { i18nTime, useI18n } from '@affine/i18n'; 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 { noop } from 'lodash-es';
import type { ChangeEventHandler } from 'react'; 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 { managerContext } from './common';
import * as styles from './styles.css'; 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< export const propertyValueRenderers: Record<
PagePropertyType, PagePropertyType,
typeof DateValue typeof DateValue
@@ -198,6 +308,8 @@ export const propertyValueRenderers: Record<
checkbox: CheckboxValue, checkbox: CheckboxValue,
text: TextValue, text: TextValue,
number: NumberValue, number: NumberValue,
createdBy: CreatedUserValue,
updatedBy: UpdatedUserValue,
// TODO(@Peng): fix following // TODO(@Peng): fix following
tags: TagsValue, tags: TagsValue,
progress: TextValue, progress: TextValue,

View File

@@ -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([ export const propertyRowValueTextarea = style([
propertyRowValueCell, propertyRowValueCell,
{ {

View File

@@ -279,29 +279,51 @@ const CustomPropertyRowsList = ({
return <CustomPropertyRows properties={filtered} statistics={statistics} />; return <CustomPropertyRows properties={filtered} statistics={statistics} />;
} else { } else {
const required = properties.filter(property => property.required); const partition = Object.groupBy(properties, p =>
const optional = properties.filter(property => !property.required); p.required ? 'required' : p.readonly ? 'readonly' : 'optional'
);
return ( return (
<> <>
{required.length > 0 ? ( {partition.required && partition.required.length > 0 ? (
<> <>
<div className={styles.subListHeader}> <div className={styles.subListHeader}>
{t[ {t[
'com.affine.settings.workspace.properties.required-properties' 'com.affine.settings.workspace.properties.required-properties'
]()} ]()}
</div> </div>
<CustomPropertyRows properties={required} statistics={statistics} /> <CustomPropertyRows
properties={partition.required}
statistics={statistics}
/>
</> </>
) : null} ) : null}
{optional.length > 0 ? ( {partition.optional && partition.optional.length > 0 ? (
<> <>
<div className={styles.subListHeader}> <div className={styles.subListHeader}>
{t[ {t[
'com.affine.settings.workspace.properties.general-properties' 'com.affine.settings.workspace.properties.general-properties'
]()} ]()}
</div> </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} ) : 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'; export { WebSocketService } from './services/websocket';
import { import {
DocScope,
DocService,
type Framework, type Framework,
GlobalCacheService, GlobalCache,
GlobalStateService, GlobalState,
WorkspaceScope,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { CloudDocMeta } from './entities/cloud-doc-meta';
import { ServerConfig } from './entities/server-config'; import { ServerConfig } from './entities/server-config';
import { AuthSession } from './entities/session'; import { AuthSession } from './entities/session';
import { Subscription } from './entities/subscription'; import { Subscription } from './entities/subscription';
@@ -29,6 +33,7 @@ import { UserCopilotQuota } from './entities/user-copilot-quota';
import { UserFeature } from './entities/user-feature'; import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota'; import { UserQuota } from './entities/user-quota';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { CloudDocMetaService } from './services/cloud-doc-meta';
import { FetchService } from './services/fetch'; import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql'; import { GraphQLService } from './services/graphql';
import { ServerConfigService } from './services/server-config'; import { ServerConfigService } from './services/server-config';
@@ -38,6 +43,7 @@ import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota'; import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket'; import { WebSocketService } from './services/websocket';
import { AuthStore } from './stores/auth'; import { AuthStore } from './stores/auth';
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
import { ServerConfigStore } from './stores/server-config'; import { ServerConfigStore } from './stores/server-config';
import { SubscriptionStore } from './stores/subscription'; import { SubscriptionStore } from './stores/subscription';
import { UserCopilotQuotaStore } from './stores/user-copilot-quota'; import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
@@ -53,10 +59,10 @@ export function configureCloudModule(framework: Framework) {
.entity(ServerConfig, [ServerConfigStore]) .entity(ServerConfig, [ServerConfigStore])
.store(ServerConfigStore, [GraphQLService]) .store(ServerConfigStore, [GraphQLService])
.service(AuthService, [FetchService, AuthStore]) .service(AuthService, [FetchService, AuthStore])
.store(AuthStore, [FetchService, GraphQLService, GlobalStateService]) .store(AuthStore, [FetchService, GraphQLService, GlobalState])
.entity(AuthSession, [AuthStore]) .entity(AuthSession, [AuthStore])
.service(SubscriptionService, [SubscriptionStore]) .service(SubscriptionService, [SubscriptionStore])
.store(SubscriptionStore, [GraphQLService, GlobalCacheService]) .store(SubscriptionStore, [GraphQLService, GlobalCache])
.entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore]) .entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore])
.entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore]) .entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore])
.service(UserQuotaService) .service(UserQuotaService)
@@ -71,5 +77,10 @@ export function configureCloudModule(framework: Framework) {
]) ])
.service(UserFeatureService) .service(UserFeatureService)
.entity(UserFeature, [AuthService, UserFeatureStore]) .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, updateUserProfileMutation,
uploadAvatarMutation, uploadAvatarMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import type { GlobalStateService } from '@toeverything/infra'; import type { GlobalState } from '@toeverything/infra';
import { Store } from '@toeverything/infra'; import { Store } from '@toeverything/infra';
import type { AuthSessionInfo } from '../entities/session'; import type { AuthSessionInfo } from '../entities/session';
@@ -24,19 +24,17 @@ export class AuthStore extends Store {
constructor( constructor(
private readonly fetchService: FetchService, private readonly fetchService: FetchService,
private readonly gqlService: GraphQLService, private readonly gqlService: GraphQLService,
private readonly globalStateService: GlobalStateService private readonly globalState: GlobalState
) { ) {
super(); super();
} }
watchCachedAuthSession() { watchCachedAuthSession() {
return this.globalStateService.globalState.watch<AuthSessionInfo>( return this.globalState.watch<AuthSessionInfo>('affine-cloud-auth');
'affine-cloud-auth'
);
} }
setCachedAuthSession(session: AuthSessionInfo | null) { setCachedAuthSession(session: AuthSessionInfo | null) {
this.globalStateService.globalState.set('affine-cloud-auth', session); this.globalState.set('affine-cloud-auth', session);
} }
async fetchSession() { 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, subscriptionQuery,
updateSubscriptionMutation, updateSubscriptionMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import type { GlobalCacheService } from '@toeverything/infra'; import type { GlobalCache } from '@toeverything/infra';
import { Store } from '@toeverything/infra'; import { Store } from '@toeverything/infra';
import type { SubscriptionType } from '../entities/subscription'; import type { SubscriptionType } from '../entities/subscription';
@@ -37,7 +37,7 @@ const getDefaultSubscriptionSuccessCallbackLink = (
export class SubscriptionStore extends Store { export class SubscriptionStore extends Store {
constructor( constructor(
private readonly gqlService: GraphQLService, private readonly gqlService: GraphQLService,
private readonly globalCacheService: GlobalCacheService private readonly globalCache: GlobalCache
) { ) {
super(); super();
} }
@@ -97,16 +97,13 @@ export class SubscriptionStore extends Store {
} }
getCachedSubscriptions(userId: string) { getCachedSubscriptions(userId: string) {
return this.globalCacheService.globalCache.get<SubscriptionType[]>( return this.globalCache.get<SubscriptionType[]>(
SUBSCRIPTION_CACHE_KEY + userId SUBSCRIPTION_CACHE_KEY + userId
); );
} }
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) { setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
return this.globalCacheService.globalCache.set( return this.globalCache.set(SUBSCRIPTION_CACHE_KEY + userId, subscriptions);
SUBSCRIPTION_CACHE_KEY + userId,
subscriptions
);
} }
setSubscriptionRecurring( setSubscriptionRecurring(

View File

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

View File

@@ -6,12 +6,13 @@ import {
catchErrorInto, catchErrorInto,
effect, effect,
Entity, Entity,
exhaustMapWithTrailing,
fromPromise, fromPromise,
LiveData, LiveData,
onComplete, onComplete,
onStart, onStart,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap } from 'rxjs'; import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud'; import { isBackendError, isNetworkError } from '../../cloud';
import type { ShareDocsStore } from '../stores/share-docs'; import type { ShareDocsStore } from '../stores/share-docs';
@@ -35,7 +36,7 @@ export class ShareDocsList extends Entity {
} }
revalidate = effect( revalidate = effect(
switchMap(() => exhaustMapWithTrailing(() =>
fromPromise(signal => { fromPromise(signal => {
return this.store.getWorkspacesShareDocs( return this.store.getWorkspacesShareDocs(
this.workspaceService.workspace.id, 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) { histories(guid: $pageDocId, take: $take, before: $before) {
id id
timestamp 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 = { export const getWorkspacePublicByIdQuery = {
id: 'getWorkspacePublicByIdQuery' as const, id: 'getWorkspacePublicByIdQuery' as const,
operationName: 'getWorkspacePublicById', operationName: 'getWorkspacePublicById',
@@ -696,6 +720,10 @@ query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $befor
histories(guid: $pageDocId, take: $take, before: $before) { histories(guid: $pageDocId, take: $take, before: $before) {
id id
timestamp timestamp
editor {
name
avatarUrl
}
} }
} }
}`, }`,

View File

@@ -238,6 +238,7 @@ export interface DocHistoryNotFoundDataType {
export interface DocHistoryType { export interface DocHistoryType {
__typename?: 'DocHistoryType'; __typename?: 'DocHistoryType';
editor: Maybe<EditorType>;
id: Scalars['String']['output']; id: Scalars['String']['output'];
timestamp: Scalars['DateTime']['output']; timestamp: Scalars['DateTime']['output'];
workspaceId: Scalars['String']['output']; workspaceId: Scalars['String']['output'];
@@ -249,6 +250,12 @@ export interface DocNotFoundDataType {
spaceId: Scalars['String']['output']; spaceId: Scalars['String']['output'];
} }
export interface EditorType {
__typename?: 'EditorType';
avatarUrl: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
}
export type ErrorDataUnion = export type ErrorDataUnion =
| AlreadyInSpaceDataType | AlreadyInSpaceDataType
| BlobNotFoundDataType | BlobNotFoundDataType
@@ -1189,6 +1196,14 @@ export interface WorkspacePage {
workspaceId: Scalars['String']['output']; 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 { export interface WorkspaceType {
__typename?: 'WorkspaceType'; __typename?: 'WorkspaceType';
/** Available features of workspace */ /** Available features of workspace */
@@ -1211,6 +1226,8 @@ export interface WorkspaceType {
members: Array<InviteUserType>; members: Array<InviteUserType>;
/** Owner of workspace */ /** Owner of workspace */
owner: UserType; owner: UserType;
/** Cloud page metadata of workspace */
pageMeta: WorkspacePageMeta;
/** Permission of current signed in user in workspace */ /** Permission of current signed in user in workspace */
permission: Permission; permission: Permission;
/** is Public workspace */ /** is Public workspace */
@@ -1239,6 +1256,10 @@ export interface WorkspaceTypeMembersArgs {
take: InputMaybe<Scalars['Int']['input']>; take: InputMaybe<Scalars['Int']['input']>;
} }
export interface WorkspaceTypePageMetaArgs {
pageId: Scalars['String']['input'];
}
export interface WorkspaceTypePublicPageArgs { export interface WorkspaceTypePublicPageArgs {
pageId: Scalars['String']['input']; pageId: Scalars['String']['input'];
} }
@@ -1787,6 +1808,33 @@ export type GetWorkspaceFeaturesQuery = {
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> }; 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<{ export type GetWorkspacePublicByIdQueryVariables = Exact<{
id: Scalars['String']['input']; id: Scalars['String']['input'];
}>; }>;
@@ -1865,6 +1913,11 @@ export type ListHistoryQuery = {
__typename?: 'DocHistoryType'; __typename?: 'DocHistoryType';
id: string; id: string;
timestamp: string; timestamp: string;
editor: {
__typename?: 'EditorType';
name: string;
avatarUrl: string | null;
} | null;
}>; }>;
}; };
}; };
@@ -2487,6 +2540,11 @@ export type Queries =
variables: GetWorkspaceFeaturesQueryVariables; variables: GetWorkspaceFeaturesQueryVariables;
response: GetWorkspaceFeaturesQuery; response: GetWorkspaceFeaturesQuery;
} }
| {
name: 'getWorkspacePageMetaByIdQuery';
variables: GetWorkspacePageMetaByIdQueryVariables;
response: GetWorkspacePageMetaByIdQuery;
}
| { | {
name: 'getWorkspacePublicByIdQuery'; name: 'getWorkspacePublicByIdQuery';
variables: GetWorkspacePublicByIdQueryVariables; variables: GetWorkspacePublicByIdQueryVariables;

View File

@@ -858,6 +858,8 @@
"com.affine.page-properties.page-info": "Info", "com.affine.page-properties.page-info": "Info",
"com.affine.page-properties.page-info.view": "View Info", "com.affine.page-properties.page-info.view": "View Info",
"com.affine.page-properties.property-value-placeholder": "Empty", "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-hide": "Always hide",
"com.affine.page-properties.property.always-show": "Always show", "com.affine.page-properties.property.always-show": "Always show",
"com.affine.page-properties.property.checkbox": "Checkbox", "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.show-in-view": "Show in view",
"com.affine.page-properties.property.tags": "Tags", "com.affine.page-properties.property.tags": "Tags",
"com.affine.page-properties.property.text": "Text", "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.settings.title": "customize properties",
"com.affine.page-properties.tags.open-tags-page": "Open tag page", "com.affine.page-properties.tags.open-tags-page": "Open tag page",
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one", "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}}</0> docs", "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.edit-property": "Edit property",
"com.affine.settings.workspace.properties.general-properties": "General properties", "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.subtitle": "Manage workspace <1>{{name}}</1> properties",
"com.affine.settings.workspace.properties.header.title": "Properties", "com.affine.settings.workspace.properties.header.title": "Properties",
"com.affine.settings.workspace.properties.in-use": "In use", "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, 'Number');
await addCustomProperty(page, 'Date'); await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox'); 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, 'Number');
await addCustomProperty(page, 'Date'); await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox'); await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
}); });
test('add custom property & edit', async ({ page }) => { test('add custom property & edit', async ({ page }) => {
@@ -103,6 +105,8 @@ test('property table reordering', async ({ page }) => {
await addCustomProperty(page, 'Number'); await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date'); await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox'); await addCustomProperty(page, 'Checkbox');
await addCustomProperty(page, 'Created by');
await addCustomProperty(page, 'Last edited by');
await dragTo( await dragTo(
page, page,
@@ -119,6 +123,8 @@ test('property table reordering', async ({ page }) => {
'Date', 'Date',
'Checkbox', 'Checkbox',
'Text', 'Text',
'Created by',
'Last edited by',
].entries()) { ].entries()) {
await expect( await expect(
page page
@@ -141,6 +147,8 @@ test('page info show more will show all properties', async ({ page }) => {
await addCustomProperty(page, 'Number'); await addCustomProperty(page, 'Number');
await addCustomProperty(page, 'Date'); await addCustomProperty(page, 'Date');
await addCustomProperty(page, 'Checkbox'); 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 expect(page.getByTestId('page-info-show-more')).toBeVisible();
await page.click('[data-testid="page-info-show-more"]'); 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', 'Number',
'Date', 'Date',
'Checkbox', 'Checkbox',
'Created by',
'Last edited by',
].entries()) { ].entries()) {
await expect( await expect(
page page