Merge pull request #2317 from paperclipai/PAP-881-document-revisions-bulid-it
Add issue document revision restore flow
This commit is contained in:
commit
46ce546174
15 changed files with 12577 additions and 142 deletions
|
|
@ -415,7 +415,7 @@ describe("worktree helpers", () => {
|
|||
});
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
||||
expect(config.server.port).toBe(3102);
|
||||
expect(config.server.port).toBeGreaterThan(3101);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
||||
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
||||
|
|
|
|||
|
|
@ -169,4 +169,76 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0046 safely when document revision columns already exist",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const smoothSentinelsHash = await migrationHash("0046_smooth_sentinels.sql");
|
||||
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${smoothSentinelsHash}'`,
|
||||
);
|
||||
|
||||
const columns = await sql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
|
||||
`
|
||||
SELECT column_name, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'document_revisions'
|
||||
AND column_name IN ('title', 'format')
|
||||
ORDER BY column_name
|
||||
`,
|
||||
);
|
||||
expect(columns).toHaveLength(2);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0046_smooth_sentinels.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
|
||||
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
|
||||
`
|
||||
SELECT column_name, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'document_revisions'
|
||||
AND column_name IN ('title', 'format')
|
||||
ORDER BY column_name
|
||||
`,
|
||||
);
|
||||
expect(columns).toEqual([
|
||||
expect.objectContaining({
|
||||
column_name: "format",
|
||||
is_nullable: "NO",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
column_name: "title",
|
||||
is_nullable: "YES",
|
||||
}),
|
||||
]);
|
||||
expect(columns[0]?.column_default).toContain("'markdown'");
|
||||
} finally {
|
||||
await verifySql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
11
packages/db/src/migrations/0046_smooth_sentinels.sql
Normal file
11
packages/db/src/migrations/0046_smooth_sentinels.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "title" text;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "format" text;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET DEFAULT 'markdown';
|
||||
--> statement-breakpoint
|
||||
UPDATE "document_revisions" AS "dr"
|
||||
SET
|
||||
"title" = COALESCE("dr"."title", "d"."title"),
|
||||
"format" = COALESCE("dr"."format", "d"."format", 'markdown')
|
||||
FROM "documents" AS "d"
|
||||
WHERE "d"."id" = "dr"."document_id";--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET NOT NULL;
|
||||
11870
packages/db/src/migrations/meta/0046_snapshot.json
Normal file
11870
packages/db/src/migrations/meta/0046_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -323,6 +323,13 @@
|
|||
"when": 1774530504348,
|
||||
"tag": "0045_workable_shockwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "7",
|
||||
"when": 1774960197878,
|
||||
"tag": "0046_smooth_sentinels",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ export const documentRevisions = pgTable(
|
|||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
title: text("title"),
|
||||
format: text("format").notNull().default("markdown"),
|
||||
body: text("body").notNull(),
|
||||
changeSummary: text("change_summary"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ export {
|
|||
issueDocumentFormatSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
|
|
@ -418,6 +419,7 @@ export {
|
|||
type UpdateExecutionWorkspace,
|
||||
type IssueDocumentFormat,
|
||||
type UpsertIssueDocument,
|
||||
type RestoreIssueDocumentRevision,
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
type CreateGoal,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ export interface DocumentRevision {
|
|||
issueId: string;
|
||||
key: string;
|
||||
revisionNumber: number;
|
||||
title: string | null;
|
||||
format: DocumentFormat;
|
||||
body: string;
|
||||
changeSummary: string | null;
|
||||
createdByAgentId: string | null;
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export {
|
|||
issueDocumentFormatSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
|
|
@ -141,6 +142,7 @@ export {
|
|||
type CreateIssueAttachmentMetadata,
|
||||
type IssueDocumentFormat,
|
||||
type UpsertIssueDocument,
|
||||
type RestoreIssueDocumentRevision,
|
||||
} from "./issue.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -120,5 +120,8 @@ export const upsertIssueDocumentSchema = z.object({
|
|||
baseRevisionId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const restoreIssueDocumentRevisionSchema = z.object({});
|
||||
|
||||
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
|
||||
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
|
||||
export type RestoreIssueDocumentRevision = z.infer<typeof restoreIssueDocumentRevisionSchema>;
|
||||
|
|
|
|||
173
server/src/__tests__/issue-document-restore-routes.test.ts
Normal file
173
server/src/__tests__/issue-document-restore-routes.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDocumentsService = vi.hoisted(() => ({
|
||||
listIssueDocumentRevisions: vi.fn(),
|
||||
restoreIssueDocumentRevision: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
executionWorkspaceService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: [companyId],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue document revision routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-881",
|
||||
title: "Document revisions",
|
||||
status: "in_progress",
|
||||
});
|
||||
mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([
|
||||
{
|
||||
id: "revision-2",
|
||||
companyId,
|
||||
documentId: "document-1",
|
||||
issueId,
|
||||
key: "plan",
|
||||
revisionNumber: 2,
|
||||
title: "Plan v2",
|
||||
format: "markdown",
|
||||
body: "# Two",
|
||||
changeSummary: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
mockDocumentsService.restoreIssueDocumentRevision.mockResolvedValue({
|
||||
restoredFromRevisionId: "revision-1",
|
||||
restoredFromRevisionNumber: 1,
|
||||
document: {
|
||||
id: "document-1",
|
||||
companyId,
|
||||
issueId,
|
||||
key: "plan",
|
||||
title: "Plan v1",
|
||||
format: "markdown",
|
||||
body: "# One",
|
||||
latestRevisionId: "revision-3",
|
||||
latestRevisionNumber: 3,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "board-user",
|
||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns revision snapshots including title and format", async () => {
|
||||
const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan");
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
revisionNumber: 2,
|
||||
title: "Plan v2",
|
||||
format: "markdown",
|
||||
body: "# Two",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores a revision through the append-only route and logs the action", async () => {
|
||||
const res = await request(createApp())
|
||||
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockDocumentsService.restoreIssueDocumentRevision).toHaveBeenCalledWith({
|
||||
issueId,
|
||||
key: "plan",
|
||||
revisionId: "revision-1",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board-user",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.document_restored",
|
||||
details: expect.objectContaining({
|
||||
key: "plan",
|
||||
restoredFromRevisionId: "revision-1",
|
||||
restoredFromRevisionNumber: 1,
|
||||
revisionNumber: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
key: "plan",
|
||||
title: "Plan v1",
|
||||
latestRevisionNumber: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects invalid document keys before attempting restore", async () => {
|
||||
const res = await request(createApp())
|
||||
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockDocumentsService.restoreIssueDocumentRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
|
|
@ -582,6 +583,57 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/issues/:id/documents/:key/revisions/:revisionId/restore",
|
||||
validate(restoreIssueDocumentRevisionSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const revisionId = req.params.revisionId as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const result = await documentsSvc.restoreIssueDocumentRevision({
|
||||
issueId: issue.id,
|
||||
key: keyParsed.data,
|
||||
revisionId,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.document_restored",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: result.document.key,
|
||||
documentId: result.document.id,
|
||||
title: result.document.title,
|
||||
format: result.document.format,
|
||||
revisionNumber: result.document.latestRevisionNumber,
|
||||
restoredFromRevisionId: result.restoredFromRevisionId,
|
||||
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result.document);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/issues/:id/documents/:key", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
|
|
|||
|
|
@ -64,50 +64,36 @@ function mapIssueDocumentRow(
|
|||
};
|
||||
}
|
||||
|
||||
const issueDocumentSelect = {
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
};
|
||||
|
||||
export function documentService(db: Db) {
|
||||
return {
|
||||
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
||||
const [planDocument, documentSummaries] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issue.id))
|
||||
|
|
@ -131,23 +117,7 @@ export function documentService(db: Db) {
|
|||
|
||||
listIssueDocuments: async (issueId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issueId))
|
||||
|
|
@ -158,23 +128,7 @@ export function documentService(db: Db) {
|
|||
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
const row = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
|
|
@ -192,6 +146,8 @@ export function documentService(db: Db) {
|
|||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
title: documentRevisions.title,
|
||||
format: documentRevisions.format,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
|
|
@ -269,6 +225,8 @@ export function documentService(db: Db) {
|
|||
companyId: issue.companyId,
|
||||
documentId: existing.id,
|
||||
revisionNumber: nextRevisionNumber,
|
||||
title: input.title ?? null,
|
||||
format: input.format,
|
||||
body: input.body,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
|
|
@ -340,6 +298,8 @@ export function documentService(db: Db) {
|
|||
companyId: issue.companyId,
|
||||
documentId: document.id,
|
||||
revisionNumber: 1,
|
||||
title: input.title ?? null,
|
||||
format: input.format,
|
||||
body: input.body,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
|
|
@ -391,27 +351,105 @@ export function documentService(db: Db) {
|
|||
}
|
||||
},
|
||||
|
||||
restoreIssueDocumentRevision: async (input: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
revisionId: string;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
}) => {
|
||||
const key = normalizeDocumentKey(input.key);
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) throw notFound("Document not found");
|
||||
|
||||
const revision = await tx
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
title: documentRevisions.title,
|
||||
format: documentRevisions.format,
|
||||
body: documentRevisions.body,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.where(and(eq(documentRevisions.id, input.revisionId), eq(documentRevisions.documentId, existing.id)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!revision) throw notFound("Document revision not found");
|
||||
if (existing.latestRevisionId === revision.id) {
|
||||
throw conflict("Selected revision is already the latest revision", {
|
||||
currentRevisionId: existing.latestRevisionId,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nextRevisionNumber = existing.latestRevisionNumber + 1;
|
||||
const [restoredRevision] = await tx
|
||||
.insert(documentRevisions)
|
||||
.values({
|
||||
companyId: existing.companyId,
|
||||
documentId: existing.id,
|
||||
revisionNumber: nextRevisionNumber,
|
||||
title: revision.title ?? null,
|
||||
format: revision.format,
|
||||
body: revision.body,
|
||||
changeSummary: `Restored from revision ${revision.revisionNumber}`,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
title: revision.title ?? null,
|
||||
format: revision.format,
|
||||
latestBody: revision.body,
|
||||
latestRevisionId: restoredRevision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documents.id, existing.id));
|
||||
|
||||
await tx
|
||||
.update(issueDocuments)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(issueDocuments.documentId, existing.id));
|
||||
|
||||
return {
|
||||
restoredFromRevisionId: revision.id,
|
||||
restoredFromRevisionNumber: revision.revisionNumber,
|
||||
document: {
|
||||
...existing,
|
||||
title: revision.title ?? null,
|
||||
format: revision.format,
|
||||
body: revision.body,
|
||||
latestRevisionId: restoredRevision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deleteIssueDocument: async (issueId: string, rawKey: string) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ export const issuesApi = {
|
|||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
listDocumentRevisions: (id: string, key: string) =>
|
||||
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||
restoreDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||
api.post<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}),
|
||||
deleteDocument: (id: string, key: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -15,6 +15,9 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
|
@ -84,6 +87,17 @@ function downloadDocumentFile(key: string, body: string) {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getRevisionActorLabel(revision: DocumentRevision) {
|
||||
if (revision.createdByUserId) return "board";
|
||||
if (revision.createdByAgentId) return "agent";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null) {
|
||||
if (!draft || draft.isNew || draft.key !== doc.key) return false;
|
||||
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
|
|
@ -107,6 +121,8 @@ export function IssueDocumentsSection({
|
|||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasScrolledToHashRef = useRef(false);
|
||||
|
|
@ -122,10 +138,28 @@ export function IssueDocumentsSection({
|
|||
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = () => {
|
||||
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({
|
||||
queryKey: revisionMenuOpenKey
|
||||
? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey)
|
||||
: ["issues", "document-revisions", issue.id, "__idle__"],
|
||||
queryFn: async () => {
|
||||
if (!revisionMenuOpenKey) return [];
|
||||
return issuesApi.listDocumentRevisions(issue.id, revisionMenuOpenKey);
|
||||
},
|
||||
enabled: Boolean(revisionMenuOpenKey),
|
||||
});
|
||||
|
||||
const invalidateIssueDocuments = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
};
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey)
|
||||
&& query.queryKey[0] === "issues"
|
||||
&& query.queryKey[1] === "document-revisions"
|
||||
&& query.queryKey[2] === issue.id,
|
||||
});
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
|
|
@ -149,6 +183,22 @@ export function IssueDocumentsSection({
|
|||
},
|
||||
});
|
||||
|
||||
const restoreDocumentRevision = useMutation({
|
||||
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
|
||||
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
|
||||
onSuccess: (_document, variables) => {
|
||||
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
|
||||
setDraft((current) => current?.key === variables.key ? null : current);
|
||||
setDocumentConflict((current) => current?.key === variables.key ? null : current);
|
||||
resetAutosaveState();
|
||||
setError(null);
|
||||
invalidateIssueDocuments();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to restore document revision");
|
||||
},
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
|
|
@ -391,6 +441,38 @@ export function IssueDocumentsSection({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const getDocumentRevisions = useCallback((key: string) => {
|
||||
const cached = queryClient.getQueryData<DocumentRevision[]>(queryKeys.issues.documentRevisions(issue.id, key));
|
||||
if (cached) return cached;
|
||||
if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? [];
|
||||
return [];
|
||||
}, [activeDocumentRevisions, issue.id, queryClient, revisionMenuOpenKey]);
|
||||
|
||||
const returnToLatestRevision = useCallback((key: string) => {
|
||||
setSelectedRevisionIds((current) => ({ ...current, [key]: null }));
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
||||
const revisions = getDocumentRevisions(doc.key);
|
||||
const selectedRevision = revisions.find((revision) => revision.id === revisionId);
|
||||
if (!selectedRevision) return;
|
||||
if (selectedRevision.id === doc.latestRevisionId) {
|
||||
returnToLatestRevision(doc.key);
|
||||
return;
|
||||
}
|
||||
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
|
||||
setError("Save or cancel your local changes before viewing an older revision.");
|
||||
return;
|
||||
}
|
||||
resetAutosaveState();
|
||||
setDraft((current) => current?.key === doc.key ? null : current);
|
||||
setDocumentConflict((current) => current?.key === doc.key ? null : current);
|
||||
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== doc.key));
|
||||
setSelectedRevisionIds((current) => ({ ...current, [doc.key]: selectedRevision.id }));
|
||||
setError(null);
|
||||
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
|
||||
|
||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
|
|
@ -623,7 +705,19 @@ export function IssueDocumentsSection({
|
|||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
||||
const revisionHistory = getDocumentRevisions(doc.key);
|
||||
const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
|
||||
const selectedHistoricalRevision = selectedRevisionId
|
||||
? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
|
||||
: null;
|
||||
const isHistoricalPreview = Boolean(selectedHistoricalRevision);
|
||||
const displayedTitle = selectedHistoricalRevision
|
||||
? selectedHistoricalRevision.title ?? ""
|
||||
: activeDraft?.title ?? doc.title ?? "";
|
||||
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body;
|
||||
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
|
||||
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
|
||||
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -649,14 +743,68 @@ export function IssueDocumentsSection({
|
|||
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{doc.key}
|
||||
</span>
|
||||
<DropdownMenu
|
||||
open={revisionMenuOpenKey === doc.key}
|
||||
onOpenChange={(open) => setRevisionMenuOpenKey(open ? doc.key : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-auto px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||
isHistoricalPreview && "text-amber-300 hover:text-amber-200",
|
||||
)}
|
||||
>
|
||||
rev {displayedRevisionNumber}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuLabel>Revision history</DropdownMenuLabel>
|
||||
{revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? (
|
||||
<DropdownMenuItem disabled>Loading revisions...</DropdownMenuItem>
|
||||
) : revisionHistory.length > 0 ? (
|
||||
<DropdownMenuRadioGroup value={selectedRevisionId ?? doc.latestRevisionId ?? ""}>
|
||||
{revisionHistory.map((revision) => {
|
||||
const isCurrentRevision = revision.id === doc.latestRevisionId;
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={revision.id}
|
||||
value={revision.id}
|
||||
onSelect={() => previewRevision(doc, revision.id)}
|
||||
className="items-start"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">rev {revision.revisionNumber}</span>
|
||||
{isCurrentRevision ? (
|
||||
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(revision.createdAt)} • {getRevisionActorLabel(revision)}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No revisions yet</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<a
|
||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
||||
updated {relativeTime(displayedUpdatedAt)}
|
||||
</a>
|
||||
</div>
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
||||
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
|
|
@ -667,7 +815,7 @@ export function IssueDocumentsSection({
|
|||
copiedDocumentKey === doc.key && "text-foreground",
|
||||
)}
|
||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
||||
onClick={() => void copyDocumentBody(doc.key, displayedBody)}
|
||||
>
|
||||
{copiedDocumentKey === doc.key ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
|
|
@ -688,7 +836,7 @@ export function IssueDocumentsSection({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
||||
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download document
|
||||
|
|
@ -711,23 +859,64 @@ export function IssueDocumentsSection({
|
|||
{!isFolded ? (
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={() => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}}
|
||||
onFocusCapture={!isHistoricalPreview
|
||||
? () => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
onBlurCapture={!isHistoricalPreview
|
||||
? async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftBlur(event);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
onKeyDown={!isHistoricalPreview
|
||||
? async (event) => {
|
||||
if (activeDraft) {
|
||||
await handleDraftKeyDown(event);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{activeConflict && (
|
||||
{isHistoricalPreview && selectedHistoricalRevision && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-amber-200">
|
||||
Viewing revision {selectedHistoricalRevision.revisionNumber}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is a historical preview. Restoring it creates a new latest revision and keeps history append-only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => returnToLatestRevision(doc.key)}
|
||||
>
|
||||
Return to latest
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => restoreDocumentRevision.mutate({
|
||||
key: doc.key,
|
||||
revisionId: selectedHistoricalRevision.id,
|
||||
})}
|
||||
disabled={restoreDocumentRevision.isPending}
|
||||
>
|
||||
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
|
||||
? "Restoring..."
|
||||
: "Restore this revision"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeConflict && !isHistoricalPreview && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -788,7 +977,7 @@ export function IssueDocumentsSection({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeDraft && !isPlanKey(doc.key) && (
|
||||
{activeDraft && !isPlanKey(doc.key) && !isHistoricalPreview && (
|
||||
<Input
|
||||
value={activeDraft.title}
|
||||
onChange={(event) => {
|
||||
|
|
@ -800,47 +989,57 @@ export function IssueDocumentsSection({
|
|||
)}
|
||||
<div
|
||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||
activeDraft ? "" : "hover:bg-accent/10"
|
||||
activeDraft || isHistoricalPreview ? "" : "hover:bg-accent/10"
|
||||
}`}
|
||||
>
|
||||
<MarkdownEditor
|
||||
value={activeDraft?.body ?? doc.body}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
{isHistoricalPreview ? (
|
||||
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
markDocumentDirty(doc.key);
|
||||
setDraft((current) => {
|
||||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
bordered={false}
|
||||
className="bg-transparent"
|
||||
contentClassName={documentBodyContentClassName}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
<span
|
||||
className={`text-[11px] transition-opacity duration-150 ${
|
||||
activeConflict
|
||||
isHistoricalPreview
|
||||
? "text-amber-300"
|
||||
: activeConflict
|
||||
? "text-amber-300"
|
||||
: autosaveState === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
||||
} ${activeDraft || isHistoricalPreview ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{activeDraft
|
||||
? activeConflict
|
||||
{isHistoricalPreview
|
||||
? "Viewing historical revision"
|
||||
: activeDraft
|
||||
? activeConflict
|
||||
? "Out of date"
|
||||
: autosaveDocumentKey === doc.key
|
||||
? autosaveState === "saving"
|
||||
|
|
@ -851,7 +1050,7 @@ export function IssueDocumentsSection({
|
|||
? "Could not save"
|
||||
: ""
|
||||
: ""
|
||||
: ""}
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue