mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 16:44:56 +00:00
Compare commits
19 Commits
preview-al
...
eyhn/feat/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07aec9a6b7 | ||
|
|
42b8aefe96 | ||
|
|
a778481ce0 | ||
|
|
6d5185f4f1 | ||
|
|
c28ef3189c | ||
|
|
1e1a9552c0 | ||
|
|
9f74d17d67 | ||
|
|
d3c93ff053 | ||
|
|
ad99587fe2 | ||
|
|
f61a902bac | ||
|
|
bf8f49771e | ||
|
|
74d7ca7f8e | ||
|
|
8be3ecbdbd | ||
|
|
35ef9af264 | ||
|
|
0417c70d54 | ||
|
|
266607e3ae | ||
|
|
7901a55fd7 | ||
|
|
f5f5b05a4c | ||
|
|
d04e766ce9 |
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -45,7 +45,12 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
return this.getDocSnapshot(spaceId, docId);
|
||||
}
|
||||
|
||||
async pushDocUpdates(userId: string, docId: string, updates: Uint8Array[]) {
|
||||
async pushDocUpdates(
|
||||
userId: string,
|
||||
docId: string,
|
||||
updates: Uint8Array[],
|
||||
editorId?: string
|
||||
) {
|
||||
if (!updates.length) {
|
||||
return 0;
|
||||
}
|
||||
@@ -67,6 +72,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
docId,
|
||||
bin,
|
||||
timestamp,
|
||||
editor: editorId,
|
||||
});
|
||||
|
||||
return timestamp;
|
||||
@@ -135,6 +141,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
docId,
|
||||
bin: snapshot.blob,
|
||||
timestamp: snapshot.updatedAt.getTime(),
|
||||
editor: snapshot.userId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,6 +158,7 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
update: {
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
updatedAt: new Date(snapshot.timestamp),
|
||||
updatedBy: snapshot.editor,
|
||||
},
|
||||
create: {
|
||||
userId: snapshot.spaceId,
|
||||
@@ -158,6 +166,8 @@ export class PgUserspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
createdAt: new Date(snapshot.timestamp),
|
||||
updatedAt: new Date(snapshot.timestamp),
|
||||
createdBy: snapshot.editor,
|
||||
updatedBy: snapshot.editor,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
async pushDocUpdates(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
updates: Uint8Array[]
|
||||
updates: Uint8Array[],
|
||||
editorId?: string
|
||||
) {
|
||||
if (!updates.length) {
|
||||
return 0;
|
||||
@@ -82,6 +83,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
blob: Buffer.from(update),
|
||||
seq,
|
||||
createdAt: new Date(createdAt),
|
||||
createdBy: editorId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -113,6 +115,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
return rows.map(row => ({
|
||||
bin: row.blob,
|
||||
timestamp: row.createdAt.getTime(),
|
||||
editor: row.createdBy || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -216,6 +219,12 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
const histories = await this.db.snapshotHistory.findMany({
|
||||
select: {
|
||||
timestamp: true,
|
||||
createdByUser: {
|
||||
select: {
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -230,7 +239,10 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
take: query.limit,
|
||||
});
|
||||
|
||||
return histories.map(h => h.timestamp.getTime());
|
||||
return histories.map(h => ({
|
||||
timestamp: h.timestamp.getTime(),
|
||||
editor: h.createdByUser,
|
||||
}));
|
||||
}
|
||||
|
||||
async getDocHistory(workspaceId: string, docId: string, timestamp: number) {
|
||||
@@ -253,10 +265,12 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
docId,
|
||||
bin: history.blob,
|
||||
timestamp,
|
||||
editor: history.createdBy || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
override async rollbackDoc(
|
||||
editorId: string | undefined,
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
timestamp: number
|
||||
@@ -274,7 +288,14 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
}
|
||||
|
||||
// force create a new history record after rollback
|
||||
await this.createDocHistory(fromSnapshot, true);
|
||||
await this.createDocHistory(
|
||||
{
|
||||
...fromSnapshot,
|
||||
// override the editor to the one who requested the rollback
|
||||
editor: editorId,
|
||||
},
|
||||
true
|
||||
);
|
||||
// WARN:
|
||||
// we should never do the snapshot updating in recovering,
|
||||
// which is not the solution in CRDT.
|
||||
@@ -331,6 +352,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
id: snapshot.docId,
|
||||
timestamp: new Date(snapshot.timestamp),
|
||||
blob: Buffer.from(snapshot.bin),
|
||||
createdBy: snapshot.editor,
|
||||
expiredAt: new Date(
|
||||
Date.now() + (await this.options.historyMaxAge(snapshot.spaceId))
|
||||
),
|
||||
@@ -374,6 +396,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
docId,
|
||||
bin: snapshot.blob,
|
||||
timestamp: snapshot.updatedAt.getTime(),
|
||||
// creator and editor may null if their account is deleted
|
||||
editor: snapshot.updatedBy || snapshot.createdBy || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,10 +420,10 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
try {
|
||||
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at")
|
||||
VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt})
|
||||
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at", "created_by", "updated_by")
|
||||
VALUES (${spaceId}, ${docId}, ${bin}, DEFAULT, ${updatedAt}, ${snapshot.editor}, ${snapshot.editor})
|
||||
ON CONFLICT ("workspace_id", "guid")
|
||||
DO UPDATE SET "blob" = ${bin}, "updated_at" = ${updatedAt}
|
||||
DO UPDATE SET "blob" = ${bin}, "updated_at" = ${updatedAt}, "updated_by" = ${snapshot.editor}
|
||||
WHERE "snapshots"."workspace_id" = ${spaceId} AND "snapshots"."guid" = ${docId} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
|
||||
`;
|
||||
|
||||
@@ -22,4 +22,4 @@ import { DocStorageOptions } from './options';
|
||||
export class DocStorageModule {}
|
||||
export { PgUserspaceDocStorageAdapter, PgWorkspaceDocStorageAdapter };
|
||||
|
||||
export { DocStorageAdapter } from './storage';
|
||||
export { DocStorageAdapter, type Editor } from './storage';
|
||||
|
||||
@@ -16,11 +16,13 @@ export interface DocRecord {
|
||||
docId: string;
|
||||
bin: Uint8Array;
|
||||
timestamp: number;
|
||||
editor?: string;
|
||||
}
|
||||
|
||||
export interface DocUpdate {
|
||||
bin: Uint8Array;
|
||||
timestamp: number;
|
||||
editor?: string;
|
||||
}
|
||||
|
||||
export interface HistoryFilter {
|
||||
@@ -28,6 +30,11 @@ export interface HistoryFilter {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface Editor {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DocStorageOptions {
|
||||
mergeUpdates?: (updates: Uint8Array[]) => Promise<Uint8Array> | Uint8Array;
|
||||
}
|
||||
@@ -61,7 +68,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
const updates = await this.getDocUpdates(spaceId, docId);
|
||||
|
||||
if (updates.length) {
|
||||
const { timestamp, bin } = await this.squash(
|
||||
const { timestamp, bin, editor } = await this.squash(
|
||||
snapshot ? [snapshot, ...updates] : updates
|
||||
);
|
||||
|
||||
@@ -70,6 +77,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
docId,
|
||||
bin,
|
||||
timestamp,
|
||||
editor,
|
||||
};
|
||||
|
||||
const success = await this.setDocSnapshot(newSnapshot);
|
||||
@@ -91,12 +99,14 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
abstract pushDocUpdates(
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
updates: Uint8Array[]
|
||||
updates: Uint8Array[],
|
||||
editorId?: string
|
||||
): Promise<number>;
|
||||
|
||||
abstract deleteDoc(spaceId: string, docId: string): Promise<void>;
|
||||
abstract deleteSpace(spaceId: string): Promise<void>;
|
||||
async rollbackDoc(
|
||||
editorId: string,
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
timestamp: number
|
||||
@@ -114,7 +124,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
}
|
||||
|
||||
const change = this.generateChangeUpdate(fromSnapshot.bin, toSnapshot.bin);
|
||||
await this.pushDocUpdates(spaceId, docId, [change]);
|
||||
await this.pushDocUpdates(spaceId, docId, [change], editorId);
|
||||
// force create a new history record after rollback
|
||||
await this.createDocHistory(fromSnapshot, true);
|
||||
}
|
||||
@@ -127,7 +137,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
query: { skip?: number; limit?: number }
|
||||
): Promise<number[]>;
|
||||
): Promise<{ timestamp: number; editor: Editor | null }[]>;
|
||||
abstract getDocHistory(
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
@@ -173,6 +183,7 @@ export abstract class DocStorageAdapter extends Connection {
|
||||
return {
|
||||
bin: finalUpdate,
|
||||
timestamp: lastUpdate.timestamp,
|
||||
editor: lastUpdate.editor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,6 @@ export {
|
||||
DocStorageAdapter,
|
||||
type DocStorageOptions,
|
||||
type DocUpdate,
|
||||
type Editor,
|
||||
type HistoryFilter,
|
||||
} from './doc';
|
||||
|
||||
@@ -264,9 +264,11 @@ export class SpaceSyncGateway
|
||||
};
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('space:push-doc-updates')
|
||||
async onReceiveDocUpdates(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody()
|
||||
message: PushDocUpdatesMessage
|
||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||
@@ -277,7 +279,8 @@ export class SpaceSyncGateway
|
||||
const timestamp = await adapter.push(
|
||||
spaceId,
|
||||
docId,
|
||||
updates.map(update => Buffer.from(update, 'base64'))
|
||||
updates.map(update => Buffer.from(update, 'base64')),
|
||||
user.id
|
||||
);
|
||||
|
||||
// could be put in [adapter.push]
|
||||
@@ -448,8 +451,10 @@ export class SpaceSyncGateway
|
||||
});
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-update-v2')
|
||||
async handleClientUpdateV2(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
@@ -462,7 +467,7 @@ export class SpaceSyncGateway
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||
return this.onReceiveDocUpdates(client, {
|
||||
return this.onReceiveDocUpdates(client, user, {
|
||||
spaceType: SpaceType.Workspace,
|
||||
spaceId: workspaceId,
|
||||
docId: guid,
|
||||
@@ -596,9 +601,9 @@ abstract class SyncSocketAdapter {
|
||||
permission?: Permission
|
||||
): Promise<void>;
|
||||
|
||||
push(spaceId: string, docId: string, updates: Buffer[]) {
|
||||
push(spaceId: string, docId: string, updates: Buffer[], editorId: string) {
|
||||
this.assertIn(spaceId);
|
||||
return this.storage.pushDocUpdates(spaceId, docId, updates);
|
||||
return this.storage.pushDocUpdates(spaceId, docId, updates, editorId);
|
||||
}
|
||||
|
||||
get(spaceId: string, docId: string) {
|
||||
@@ -621,9 +626,14 @@ class WorkspaceSyncAdapter extends SyncSocketAdapter {
|
||||
super(SpaceType.Workspace, client, storage);
|
||||
}
|
||||
|
||||
override push(spaceId: string, docId: string, updates: Buffer[]) {
|
||||
override push(
|
||||
spaceId: string,
|
||||
docId: string,
|
||||
updates: Buffer[],
|
||||
editorId: string
|
||||
) {
|
||||
const id = new DocID(docId, spaceId);
|
||||
return super.push(spaceId, id.guid, updates);
|
||||
return super.push(spaceId, id.guid, updates, editorId);
|
||||
}
|
||||
|
||||
override get(spaceId: string, docId: string) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||
import { Permission, PermissionService } from '../../permission';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { EditorType } from './workspace';
|
||||
|
||||
@ObjectType()
|
||||
class DocHistoryType implements Partial<SnapshotHistory> {
|
||||
@@ -27,6 +28,9 @@ class DocHistoryType implements Partial<SnapshotHistory> {
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
timestamp!: Date;
|
||||
|
||||
@Field(() => EditorType, { nullable: true })
|
||||
editor!: EditorType | null;
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
@@ -47,17 +51,18 @@ export class DocHistoryResolver {
|
||||
): Promise<DocHistoryType[]> {
|
||||
const docId = new DocID(guid, workspace.id);
|
||||
|
||||
const timestamps = await this.workspace.listDocHistories(
|
||||
const histories = await this.workspace.listDocHistories(
|
||||
workspace.id,
|
||||
docId.guid,
|
||||
{ before: timestamp.getTime(), limit: take }
|
||||
);
|
||||
|
||||
return timestamps.map(timestamp => {
|
||||
return histories.map(history => {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
id: docId.guid,
|
||||
timestamp: new Date(timestamp),
|
||||
timestamp: new Date(history.timestamp),
|
||||
editor: history.editor,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -79,6 +84,7 @@ export class DocHistoryResolver {
|
||||
);
|
||||
|
||||
await this.workspace.rollbackDoc(
|
||||
user.id,
|
||||
docId.workspace,
|
||||
docId.guid,
|
||||
timestamp.getTime()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
@@ -16,6 +18,7 @@ import { applyUpdate, Doc } from 'yjs';
|
||||
import type { FileUpload } from '../../../fundamentals';
|
||||
import {
|
||||
CantChangeSpaceOwner,
|
||||
DocNotFound,
|
||||
EventEmitter,
|
||||
InternalServerError,
|
||||
MailService,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
UserNotFound,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import type { Editor } from '../../doc';
|
||||
import { Permission, PermissionService } from '../../permission';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
@@ -40,6 +44,30 @@ import {
|
||||
} from '../types';
|
||||
import { defaultWorkspaceAvatar } from '../utils';
|
||||
|
||||
@ObjectType()
|
||||
export class EditorType implements Partial<Editor> {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class WorkspacePageMeta {
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
|
||||
@Field(() => EditorType, { nullable: true })
|
||||
createdBy!: EditorType | null;
|
||||
|
||||
@Field(() => EditorType, { nullable: true })
|
||||
updatedBy!: EditorType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace resolver
|
||||
* Public apis rate limit: 10 req/m
|
||||
@@ -140,6 +168,35 @@ export class WorkspaceResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@ResolveField(() => WorkspacePageMeta, {
|
||||
description: 'Cloud page metadata of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async pageMeta(
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
const metadata = await this.prisma.snapshot.findFirst({
|
||||
where: { workspaceId: workspace.id, id: pageId },
|
||||
select: {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdByUser: { select: { name: true, avatarUrl: true } },
|
||||
updatedByUser: { select: { name: true, avatarUrl: true } },
|
||||
},
|
||||
});
|
||||
if (!metadata) {
|
||||
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt: metadata.createdAt,
|
||||
updatedAt: metadata.updatedAt,
|
||||
createdBy: metadata.createdByUser || null,
|
||||
updatedBy: metadata.updatedByUser || null,
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => QuotaQueryType, {
|
||||
name: 'quota',
|
||||
description: 'quota of workspace',
|
||||
|
||||
@@ -189,6 +189,7 @@ type DocHistoryNotFoundDataType {
|
||||
}
|
||||
|
||||
type DocHistoryType {
|
||||
editor: EditorType
|
||||
id: String!
|
||||
timestamp: DateTime!
|
||||
workspaceId: String!
|
||||
@@ -199,6 +200,11 @@ type DocNotFoundDataType {
|
||||
spaceId: String!
|
||||
}
|
||||
|
||||
type EditorType {
|
||||
avatarUrl: String
|
||||
name: String!
|
||||
}
|
||||
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType
|
||||
|
||||
enum ErrorNames {
|
||||
@@ -875,6 +881,13 @@ type WorkspacePage {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type WorkspacePageMeta {
|
||||
createdAt: DateTime!
|
||||
createdBy: EditorType
|
||||
updatedAt: DateTime!
|
||||
updatedBy: EditorType
|
||||
}
|
||||
|
||||
type WorkspaceType {
|
||||
"""Available features of workspace"""
|
||||
availableFeatures: [FeatureType!]!
|
||||
@@ -902,6 +915,9 @@ type WorkspaceType {
|
||||
"""Owner of workspace"""
|
||||
owner: UserType!
|
||||
|
||||
"""Cloud page metadata of workspace"""
|
||||
pageMeta(pageId: String!): WorkspacePageMeta!
|
||||
|
||||
"""Permission of current signed in user in workspace"""
|
||||
permission: Permission!
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ const snapshot: Snapshot = {
|
||||
seq: 0,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
};
|
||||
|
||||
function getSnapshot(timestamp: number = Date.now()): DocRecord {
|
||||
@@ -262,6 +264,7 @@ test('should be able to recover from history', async t => {
|
||||
await adapter.createDocHistory(getSnapshot(history1Timestamp));
|
||||
|
||||
await adapter.rollbackDoc(
|
||||
undefined,
|
||||
snapshot.workspaceId,
|
||||
snapshot.id,
|
||||
history1Timestamp
|
||||
|
||||
@@ -89,6 +89,7 @@ export const iconNames = [
|
||||
'edgeless',
|
||||
'journal',
|
||||
'payment',
|
||||
'createdEdited',
|
||||
] as const satisfies fromLibIconName<LibIconComponentName>[];
|
||||
|
||||
export type PagePropertyIcon = (typeof iconNames)[number];
|
||||
@@ -109,6 +110,10 @@ export const getDefaultIconName = (
|
||||
return 'checkBoxCheckLinear';
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'createdBy':
|
||||
return 'createdEdited';
|
||||
case 'updatedBy':
|
||||
return 'createdEdited';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { Checkbox, DatePicker, Menu } from '@affine/component';
|
||||
import { Avatar, Checkbox, DatePicker, Menu } from '@affine/component';
|
||||
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
|
||||
import type {
|
||||
PageInfoCustomProperty,
|
||||
PageInfoCustomPropertyMeta,
|
||||
PagePropertyType,
|
||||
} from '@affine/core/modules/properties/services/schema';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { i18nTime, useI18n } from '@affine/i18n';
|
||||
import { DocService, useService } from '@toeverything/infra';
|
||||
import {
|
||||
DocService,
|
||||
useLiveData,
|
||||
useService,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { noop } from 'lodash-es';
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { managerContext } from './common';
|
||||
import * as styles from './styles.css';
|
||||
@@ -190,6 +204,102 @@ export const TagsValue = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CloudUserAvatar = (props: { type: 'CreatedBy' | 'UpdatedBy' }) => {
|
||||
const cloudDocMetaService = useService(CloudDocMetaService);
|
||||
const cloudDocMeta = useLiveData(cloudDocMetaService.cloudDocMeta.meta$);
|
||||
const isRevalidating = useLiveData(
|
||||
cloudDocMetaService.cloudDocMeta.isRevalidating$
|
||||
);
|
||||
const error = useLiveData(cloudDocMetaService.cloudDocMeta.error$);
|
||||
|
||||
useEffect(() => {
|
||||
cloudDocMetaService.cloudDocMeta.revalidate();
|
||||
}, [cloudDocMetaService]);
|
||||
|
||||
const user = useMemo(() => {
|
||||
if (!cloudDocMeta) return null;
|
||||
if (props.type === 'CreatedBy' && cloudDocMeta.createdBy) {
|
||||
return {
|
||||
name: cloudDocMeta.createdBy.name,
|
||||
avatarUrl: cloudDocMeta.createdBy.avatarUrl,
|
||||
};
|
||||
} else if (props.type === 'UpdatedBy' && cloudDocMeta.updatedBy) {
|
||||
return {
|
||||
name: cloudDocMeta.updatedBy.name,
|
||||
avatarUrl: cloudDocMeta.updatedBy.avatarUrl,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [cloudDocMeta, props.type]);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
if (!cloudDocMeta) {
|
||||
if (isRevalidating) {
|
||||
// TODO: loading ui
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
// error ui
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (user) {
|
||||
return (
|
||||
<>
|
||||
<Avatar url={user.avatarUrl || ''} name={user.name} size={20} />
|
||||
<span>{user.name}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Avatar name="?" size={20} />
|
||||
<span>
|
||||
{t['com.affine.page-properties.property-user-avatar-no-record']()}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LocalUserValue = () => {
|
||||
const t = useI18n();
|
||||
return <span>{t['com.affine.page-properties.local-user']()}</span>;
|
||||
};
|
||||
|
||||
export const CreatedUserValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return <LocalUserValue />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.propertyRowValueUserCell}>
|
||||
<CloudUserAvatar type="CreatedBy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdatedUserValue = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloud =
|
||||
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||
|
||||
if (!isCloud) {
|
||||
return <LocalUserValue />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.propertyRowValueUserCell}>
|
||||
<CloudUserAvatar type="UpdatedBy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const propertyValueRenderers: Record<
|
||||
PagePropertyType,
|
||||
typeof DateValue
|
||||
@@ -198,6 +308,8 @@ export const propertyValueRenderers: Record<
|
||||
checkbox: CheckboxValue,
|
||||
text: TextValue,
|
||||
number: NumberValue,
|
||||
createdBy: CreatedUserValue,
|
||||
updatedBy: UpdatedUserValue,
|
||||
// TODO(@Peng): fix following
|
||||
tags: TagsValue,
|
||||
progress: TextValue,
|
||||
|
||||
@@ -349,6 +349,16 @@ export const propertyRowValueTextCell = style([
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueUserCell = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
border: 'none',
|
||||
overflow: 'hidden',
|
||||
columnGap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
export const propertyRowValueTextarea = style([
|
||||
propertyRowValueCell,
|
||||
{
|
||||
|
||||
@@ -279,29 +279,51 @@ const CustomPropertyRowsList = ({
|
||||
|
||||
return <CustomPropertyRows properties={filtered} statistics={statistics} />;
|
||||
} else {
|
||||
const required = properties.filter(property => property.required);
|
||||
const optional = properties.filter(property => !property.required);
|
||||
const partition = Object.groupBy(properties, p =>
|
||||
p.required ? 'required' : p.readonly ? 'readonly' : 'optional'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{required.length > 0 ? (
|
||||
{partition.required && partition.required.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.required-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows properties={required} statistics={statistics} />
|
||||
<CustomPropertyRows
|
||||
properties={partition.required}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{optional.length > 0 ? (
|
||||
{partition.optional && partition.optional.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.general-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows properties={optional} statistics={statistics} />
|
||||
<CustomPropertyRows
|
||||
properties={partition.optional}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{partition.readonly && partition.readonly.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.subListHeader}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.properties.readonly-properties'
|
||||
]()}
|
||||
</div>
|
||||
<CustomPropertyRows
|
||||
properties={partition.readonly}
|
||||
statistics={statistics}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
updateUserProfileMutation,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalStateService } from '@toeverything/infra';
|
||||
import type { GlobalState } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { AuthSessionInfo } from '../entities/session';
|
||||
@@ -24,19 +24,17 @@ export class AuthStore extends Store {
|
||||
constructor(
|
||||
private readonly fetchService: FetchService,
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalStateService: GlobalStateService
|
||||
private readonly globalState: GlobalState
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchCachedAuthSession() {
|
||||
return this.globalStateService.globalState.watch<AuthSessionInfo>(
|
||||
'affine-cloud-auth'
|
||||
);
|
||||
return this.globalState.watch<AuthSessionInfo>('affine-cloud-auth');
|
||||
}
|
||||
|
||||
setCachedAuthSession(session: AuthSessionInfo | null) {
|
||||
this.globalStateService.globalState.set('affine-cloud-auth', session);
|
||||
this.globalState.set('affine-cloud-auth', session);
|
||||
}
|
||||
|
||||
async fetchSession() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
subscriptionQuery,
|
||||
updateSubscriptionMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { GlobalCacheService } from '@toeverything/infra';
|
||||
import type { GlobalCache } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { SubscriptionType } from '../entities/subscription';
|
||||
@@ -37,7 +37,7 @@ const getDefaultSubscriptionSuccessCallbackLink = (
|
||||
export class SubscriptionStore extends Store {
|
||||
constructor(
|
||||
private readonly gqlService: GraphQLService,
|
||||
private readonly globalCacheService: GlobalCacheService
|
||||
private readonly globalCache: GlobalCache
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -97,16 +97,13 @@ export class SubscriptionStore extends Store {
|
||||
}
|
||||
|
||||
getCachedSubscriptions(userId: string) {
|
||||
return this.globalCacheService.globalCache.get<SubscriptionType[]>(
|
||||
return this.globalCache.get<SubscriptionType[]>(
|
||||
SUBSCRIPTION_CACHE_KEY + userId
|
||||
);
|
||||
}
|
||||
|
||||
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
|
||||
return this.globalCacheService.globalCache.set(
|
||||
SUBSCRIPTION_CACHE_KEY + userId,
|
||||
subscriptions
|
||||
);
|
||||
return this.globalCache.set(SUBSCRIPTION_CACHE_KEY + userId, subscriptions);
|
||||
}
|
||||
|
||||
setSubscriptionRecurring(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
query getWorkspacePageMetaById($id: String!, $pageId: String!) {
|
||||
workspace(id: $id) {
|
||||
pageMeta(pageId: $pageId) {
|
||||
createdAt
|
||||
updatedAt
|
||||
createdBy {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
updatedBy {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ query listHistory(
|
||||
histories(guid: $pageDocId, take: $take, before: $before) {
|
||||
id
|
||||
timestamp
|
||||
editor {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +610,30 @@ query getWorkspaceFeatures($workspaceId: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getWorkspacePageMetaByIdQuery = {
|
||||
id: 'getWorkspacePageMetaByIdQuery' as const,
|
||||
operationName: 'getWorkspacePageMetaById',
|
||||
definitionName: 'workspace',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getWorkspacePageMetaById($id: String!, $pageId: String!) {
|
||||
workspace(id: $id) {
|
||||
pageMeta(pageId: $pageId) {
|
||||
createdAt
|
||||
updatedAt
|
||||
createdBy {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
updatedBy {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getWorkspacePublicByIdQuery = {
|
||||
id: 'getWorkspacePublicByIdQuery' as const,
|
||||
operationName: 'getWorkspacePublicById',
|
||||
@@ -695,6 +719,10 @@ query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $befor
|
||||
histories(guid: $pageDocId, take: $take, before: $before) {
|
||||
id
|
||||
timestamp
|
||||
editor {
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
|
||||
@@ -238,6 +238,7 @@ export interface DocHistoryNotFoundDataType {
|
||||
|
||||
export interface DocHistoryType {
|
||||
__typename?: 'DocHistoryType';
|
||||
editor: Maybe<EditorType>;
|
||||
id: Scalars['String']['output'];
|
||||
timestamp: Scalars['DateTime']['output'];
|
||||
workspaceId: Scalars['String']['output'];
|
||||
@@ -249,6 +250,12 @@ export interface DocNotFoundDataType {
|
||||
spaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface EditorType {
|
||||
__typename?: 'EditorType';
|
||||
avatarUrl: Maybe<Scalars['String']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export type ErrorDataUnion =
|
||||
| AlreadyInSpaceDataType
|
||||
| BlobNotFoundDataType
|
||||
@@ -1189,6 +1196,14 @@ export interface WorkspacePage {
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface WorkspacePageMeta {
|
||||
__typename?: 'WorkspacePageMeta';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
createdBy: Maybe<EditorType>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
updatedBy: Maybe<EditorType>;
|
||||
}
|
||||
|
||||
export interface WorkspaceType {
|
||||
__typename?: 'WorkspaceType';
|
||||
/** Available features of workspace */
|
||||
@@ -1209,6 +1224,8 @@ export interface WorkspaceType {
|
||||
members: Array<InviteUserType>;
|
||||
/** Owner of workspace */
|
||||
owner: UserType;
|
||||
/** Cloud page metadata of workspace */
|
||||
pageMeta: WorkspacePageMeta;
|
||||
/** Permission of current signed in user in workspace */
|
||||
permission: Permission;
|
||||
/** is Public workspace */
|
||||
@@ -1237,6 +1254,10 @@ export interface WorkspaceTypeMembersArgs {
|
||||
take: InputMaybe<Scalars['Int']['input']>;
|
||||
}
|
||||
|
||||
export interface WorkspaceTypePageMetaArgs {
|
||||
pageId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface WorkspaceTypePublicPageArgs {
|
||||
pageId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -1785,6 +1806,33 @@ export type GetWorkspaceFeaturesQuery = {
|
||||
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
|
||||
};
|
||||
|
||||
export type GetWorkspacePageMetaByIdQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
pageId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetWorkspacePageMetaByIdQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
pageMeta: {
|
||||
__typename?: 'WorkspacePageMeta';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: {
|
||||
__typename?: 'EditorType';
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
updatedBy: {
|
||||
__typename?: 'EditorType';
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GetWorkspacePublicByIdQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -1862,6 +1910,11 @@ export type ListHistoryQuery = {
|
||||
__typename?: 'DocHistoryType';
|
||||
id: string;
|
||||
timestamp: string;
|
||||
editor: {
|
||||
__typename?: 'EditorType';
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -2484,6 +2537,11 @@ export type Queries =
|
||||
variables: GetWorkspaceFeaturesQueryVariables;
|
||||
response: GetWorkspaceFeaturesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getWorkspacePageMetaByIdQuery';
|
||||
variables: GetWorkspacePageMetaByIdQueryVariables;
|
||||
response: GetWorkspacePageMetaByIdQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getWorkspacePublicByIdQuery';
|
||||
variables: GetWorkspacePublicByIdQueryVariables;
|
||||
|
||||
@@ -856,6 +856,8 @@
|
||||
"com.affine.page-properties.page-info": "Info",
|
||||
"com.affine.page-properties.page-info.view": "View Info",
|
||||
"com.affine.page-properties.property-value-placeholder": "Empty",
|
||||
"com.affine.page-properties.property-user-avatar-no-record": "No Record",
|
||||
"com.affine.page-properties.property-user-local": "Local User",
|
||||
"com.affine.page-properties.property.always-hide": "Always hide",
|
||||
"com.affine.page-properties.property.always-show": "Always show",
|
||||
"com.affine.page-properties.property.checkbox": "Checkbox",
|
||||
@@ -870,6 +872,9 @@
|
||||
"com.affine.page-properties.property.show-in-view": "Show in view",
|
||||
"com.affine.page-properties.property.tags": "Tags",
|
||||
"com.affine.page-properties.property.text": "Text",
|
||||
"com.affine.page-properties.property.createdBy": "Created by",
|
||||
"com.affine.page-properties.property.updatedBy": "Last edited by",
|
||||
"com.affine.page-properties.local-user": "Local user",
|
||||
"com.affine.page-properties.settings.title": "customize properties",
|
||||
"com.affine.page-properties.tags.open-tags-page": "Open tag page",
|
||||
"com.affine.page-properties.tags.selector-header-title": "Select tag or create one",
|
||||
@@ -1354,6 +1359,7 @@
|
||||
"com.affine.settings.workspace.properties.doc_others": "<0>{{count}}</0> docs",
|
||||
"com.affine.settings.workspace.properties.edit-property": "Edit property",
|
||||
"com.affine.settings.workspace.properties.general-properties": "General properties",
|
||||
"com.affine.settings.workspace.properties.readonly-properties": "Readonly properties",
|
||||
"com.affine.settings.workspace.properties.header.subtitle": "Manage workspace <1>{{name}}</1> properties",
|
||||
"com.affine.settings.workspace.properties.header.title": "Properties",
|
||||
"com.affine.settings.workspace.properties.in-use": "In use",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user