Move collection description rendering to JSON (#6944)

* First pass, moving collection description rendering to JSON

* tsc

* docs

* refactor

* test
This commit is contained in:
Tom Moor
2024-05-25 18:17:19 -04:00
committed by GitHub
parent d51267b8bc
commit f103d73b48
15 changed files with 225 additions and 81 deletions

View File

@@ -65,7 +65,7 @@ function CollectionDescription({ collection }: Props) {
debounce(async (getValue) => { debounce(async (getValue) => {
try { try {
await collection.save({ await collection.save({
description: getValue(), data: getValue(false),
}); });
setDirty(false); setDirty(false);
} catch (err) { } catch (err) {
@@ -109,7 +109,7 @@ function CollectionDescription({ collection }: Props) {
> >
<Editor <Editor
key={key} key={key}
defaultValue={collection.description || ""} defaultValue={collection.data}
onChange={handleChange} onChange={handleChange}
placeholder={placeholder} placeholder={placeholder}
readOnly={!isEditing} readOnly={!isEditing}

View File

@@ -1,10 +1,10 @@
import invariant from "invariant"; import invariant from "invariant";
import trim from "lodash/trim";
import { action, computed, observable, reaction, runInAction } from "mobx"; import { action, computed, observable, reaction, runInAction } from "mobx";
import { import {
CollectionPermission, CollectionPermission,
FileOperationFormat, FileOperationFormat,
NavigationNode, NavigationNode,
type ProsemirrorData,
} from "@shared/types"; } from "@shared/types";
import { sortNavigationNodes } from "@shared/utils/collections"; import { sortNavigationNodes } from "@shared/utils/collections";
import type CollectionsStore from "~/stores/CollectionsStore"; import type CollectionsStore from "~/stores/CollectionsStore";
@@ -27,34 +27,56 @@ export default class Collection extends ParanoidModel {
@observable @observable
id: string; id: string;
/**
* The name of the collection.
*/
@Field @Field
@observable @observable
name: string; name: string;
@Field @Field
@observable @observable.shallow
description: string; data: ProsemirrorData;
/**
* An emoji to use as the collection icon.
*/
@Field @Field
@observable @observable
icon: string; icon: string;
/**
* A color to use for the collection icon and other highlights.
*/
@Field @Field
@observable @observable
color: string; color: string;
/**
* The default permission for workspace users.
*/
@Field @Field
@observable @observable
permission?: CollectionPermission; permission?: CollectionPermission;
/**
* Whether public sharing is enabled for the collection. Note this can also be disabled at the
* workspace level.
*/
@Field @Field
@observable @observable
sharing: boolean; sharing: boolean;
/**
* The sort index for the collection.
*/
@Field @Field
@observable @observable
index: string; index: string;
/**
* The sort field and direction for documents in the collection.
*/
@Field @Field
@observable @observable
sort: { sort: {
@@ -112,9 +134,8 @@ export default class Collection extends ParanoidModel {
return !this.permission; return !this.permission;
} }
@computed
get hasDescription(): boolean { get hasDescription(): boolean {
return !!trim(this.description, "\\").trim(); return !!this.data;
} }
@computed @computed

View File

@@ -428,7 +428,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
subscription, subscription,
payload: { payload: {
id: event.collectionId, id: event.collectionId,
model: model && presentCollection(model), model: model && (await presentCollection(undefined, model)),
}, },
}); });
} }
@@ -454,7 +454,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
payload: { payload: {
id: event.modelId, id: event.modelId,
model: model && presentMembership(model), model: model && presentMembership(model),
collection: model && presentCollection(model.collection!), collection:
model && (await presentCollection(undefined, model.collection!)),
user: model && presentUser(model.user), user: model && presentUser(model.user),
}, },
}); });
@@ -481,7 +482,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
payload: { payload: {
id: event.modelId, id: event.modelId,
model: model && presentCollectionGroupMembership(model), model: model && presentCollectionGroupMembership(model),
collection: model && presentCollection(model.collection!), collection:
model && (await presentCollection(undefined, model.collection!)),
group: model && presentGroup(model.group), group: model && presentGroup(model.group),
}, },
}); });

View File

@@ -0,0 +1,14 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("collections", "content", {
type: Sequelize.JSONB,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("collections", "content");
},
};

View File

@@ -32,7 +32,7 @@ import {
BeforeDestroy, BeforeDestroy,
} from "sequelize-typescript"; } from "sequelize-typescript";
import isUUID from "validator/lib/isUUID"; import isUUID from "validator/lib/isUUID";
import type { CollectionSort } from "@shared/types"; import type { CollectionSort, ProsemirrorData } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types"; import { CollectionPermission, NavigationNode } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper"; import { UrlHelper } from "@shared/utils/UrlHelper";
import { sortNavigationNodes } from "@shared/utils/collections"; import { sortNavigationNodes } from "@shared/utils/collections";
@@ -49,6 +49,7 @@ import User from "./User";
import UserMembership from "./UserMembership"; import UserMembership from "./UserMembership";
import ParanoidModel from "./base/ParanoidModel"; import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
import { DocumentHelper } from "./helpers/DocumentHelper";
import IsHexColor from "./validators/IsHexColor"; import IsHexColor from "./validators/IsHexColor";
import Length from "./validators/Length"; import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl"; import NotContainsUrl from "./validators/NotContainsUrl";
@@ -163,6 +164,12 @@ class Collection extends ParanoidModel<
@Column @Column
name: string; name: string;
/**
* The content of the collection as Markdown.
*
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
* This column will be removed in a future migration.
*/
@Length({ @Length({
max: CollectionValidation.maxDescriptionLength, max: CollectionValidation.maxDescriptionLength,
msg: `description must be ${CollectionValidation.maxDescriptionLength} characters or less`, msg: `description must be ${CollectionValidation.maxDescriptionLength} characters or less`,
@@ -170,6 +177,12 @@ class Collection extends ParanoidModel<
@Column @Column
description: string | null; description: string | null;
/**
* The content of the collection as JSON, this is a snapshot at the last time the state was saved.
*/
@Column(DataType.JSONB)
content: ProsemirrorData | null;
@Length({ @Length({
max: 50, max: 50,
msg: `icon must be 50 characters or less`, msg: `icon must be 50 characters or less`,
@@ -260,6 +273,10 @@ class Collection extends ParanoidModel<
if (model.icon === "collection") { if (model.icon === "collection") {
model.icon = null; model.icon = null;
} }
if (!model.content) {
model.content = await DocumentHelper.toJSON(model);
}
} }
@BeforeDestroy @BeforeDestroy

View File

@@ -11,7 +11,7 @@ import { ProsemirrorData } from "@shared/types";
import { parser, serializer, schema } from "@server/editor"; import { parser, serializer, schema } from "@server/editor";
import { addTags } from "@server/logging/tracer"; import { addTags } from "@server/logging/tracer";
import { trace } from "@server/logging/tracing"; import { trace } from "@server/logging/tracing";
import { Document, Revision } from "@server/models"; import { Collection, Document, Revision } from "@server/models";
import diff from "@server/utils/diff"; import diff from "@server/utils/diff";
import { ProsemirrorHelper } from "./ProsemirrorHelper"; import { ProsemirrorHelper } from "./ProsemirrorHelper";
import { TextHelper } from "./TextHelper"; import { TextHelper } from "./TextHelper";
@@ -43,7 +43,7 @@ export class DocumentHelper {
* @param document The document or revision to convert * @param document The document or revision to convert
* @returns The document content as a Prosemirror Node * @returns The document content as a Prosemirror Node
*/ */
static toProsemirror(document: Document | Revision) { static toProsemirror(document: Document | Revision | Collection) {
if ("content" in document && document.content) { if ("content" in document && document.content) {
return Node.fromJSON(schema, document.content); return Node.fromJSON(schema, document.content);
} }
@@ -52,7 +52,10 @@ export class DocumentHelper {
Y.applyUpdate(ydoc, document.state); Y.applyUpdate(ydoc, document.state);
return Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); return Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
} }
return parser.parse(document.text) || Node.fromJSON(schema, {});
const text =
document instanceof Collection ? document.description : document.text;
return parser.parse(text ?? "") || Node.fromJSON(schema, {});
} }
/** /**
@@ -64,7 +67,7 @@ export class DocumentHelper {
* @returns The document content as a plain JSON object * @returns The document content as a plain JSON object
*/ */
static async toJSON( static async toJSON(
document: Document | Revision, document: Document | Revision | Collection,
options?: { options?: {
/** The team context */ /** The team context */
teamId: string; teamId: string;
@@ -83,6 +86,8 @@ export class DocumentHelper {
const ydoc = new Y.Doc(); const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state); Y.applyUpdate(ydoc, document.state);
doc = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); doc = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
} else if (document instanceof Collection) {
doc = parser.parse(document.description ?? "");
} else { } else {
doc = parser.parse(document.text); doc = parser.parse(document.text);
} }
@@ -123,12 +128,12 @@ export class DocumentHelper {
} }
/** /**
* Returns the document as Markdown. This is a lossy conversion and should nly be used for export. * Returns the document as Markdown. This is a lossy conversion and should only be used for export.
* *
* @param document The document or revision to convert * @param document The document or revision to convert
* @returns The document title and content as a Markdown string * @returns The document title and content as a Markdown string
*/ */
static toMarkdown(document: Document | Revision) { static toMarkdown(document: Document | Revision | Collection) {
const text = serializer const text = serializer
.serialize(DocumentHelper.toProsemirror(document)) .serialize(DocumentHelper.toProsemirror(document))
.replace(/\n\\(\n|$)/g, "\n\n") .replace(/\n\\(\n|$)/g, "\n\n")
@@ -138,6 +143,10 @@ export class DocumentHelper {
.replace(//g, "'") .replace(//g, "'")
.trim(); .trim();
if (document instanceof Collection) {
return text;
}
const title = `${document.emoji ? document.emoji + " " : ""}${ const title = `${document.emoji ? document.emoji + " " : ""}${
document.title document.title
}`; }`;

View File

@@ -1,13 +1,21 @@
import { colorPalette } from "@shared/utils/collections"; import { colorPalette } from "@shared/utils/collections";
import Collection from "@server/models/Collection"; import Collection from "@server/models/Collection";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { APIContext } from "@server/types";
export default async function presentCollection(
ctx: APIContext | undefined,
collection: Collection
) {
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
export default function presentCollection(collection: Collection) {
return { return {
id: collection.id, id: collection.id,
url: collection.url, url: collection.url,
urlId: collection.urlId, urlId: collection.urlId,
name: collection.name, name: collection.name,
description: collection.description, data: asData ? await DocumentHelper.toJSON(collection) : undefined,
description: asData ? undefined : collection.description,
sort: collection.sort, sort: collection.sort,
icon: collection.icon, icon: collection.icon,
index: collection.index, index: collection.index,

View File

@@ -182,7 +182,7 @@ export default class WebsocketsProcessor {
? `team-${collection.teamId}` ? `team-${collection.teamId}`
: `user-${collection.createdById}` : `user-${collection.createdById}`
) )
.emit(event.name, presentCollection(collection)); .emit(event.name, await presentCollection(undefined, collection));
return socketio return socketio
.to( .to(
@@ -210,7 +210,7 @@ export default class WebsocketsProcessor {
? `collection-${event.collectionId}` ? `collection-${event.collectionId}`
: `team-${collection.teamId}` : `team-${collection.teamId}`
) )
.emit(event.name, presentCollection(collection)); .emit(event.name, await presentCollection(undefined, collection));
} }
case "collections.delete": { case "collections.delete": {

View File

@@ -1,7 +1,6 @@
import JSZip from "jszip"; import JSZip from "jszip";
import omit from "lodash/omit"; import omit from "lodash/omit";
import { NavigationNode } from "@shared/types"; import { NavigationNode } from "@shared/types";
import { parser } from "@server/editor";
import env from "@server/env"; import env from "@server/env";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
import { import {
@@ -63,10 +62,10 @@ export default class ExportJSONTask extends ExportTask {
) { ) {
const output: CollectionJSONExport = { const output: CollectionJSONExport = {
collection: { collection: {
...omit(presentCollection(collection), ["url"]), ...omit(await presentCollection(undefined, collection), [
description: collection.description "url",
? parser.parse(collection.description) "description",
: null, ]),
documentStructure: collection.documentStructure, documentStructure: collection.documentStructure,
}, },
documents: {}, documents: {},

View File

@@ -129,15 +129,14 @@ export default class ImportJSONTask extends ImportTask {
} }
const collectionId = uuidv4(); const collectionId = uuidv4();
const data = item.collection.description ?? item.collection.data;
output.collections.push({ output.collections.push({
...item.collection, ...item.collection,
description: description:
item.collection.description && data && typeof data === "object"
typeof item.collection.description === "object" ? serializer.serialize(Node.fromJSON(schema, data))
? serializer.serialize( : data,
Node.fromJSON(schema, item.collection.description)
)
: item.collection.description,
id: collectionId, id: collectionId,
externalId: item.collection.id, externalId: item.collection.id,
}); });

View File

@@ -1318,6 +1318,48 @@ describe("#collections.update", () => {
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
}); });
it("allows editing description", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const res = await server.post("/api/collections.update", {
body: {
token: admin.getJwtToken(),
id: collection.id,
description: "Test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.description).toBe("Test");
await collection.reload();
expect(collection.description).toBe("Test");
expect(collection.content).toBeTruthy();
});
it("allows editing data", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({ teamId: team.id });
const res = await server.post("/api/collections.update", {
body: {
token: admin.getJwtToken(),
id: collection.id,
data: {
content: [
{ content: [{ text: "Test", type: "text" }], type: "paragraph" },
],
type: "doc",
},
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.description).toBe("Test");
});
it("allows editing sort", async () => { it("allows editing sort", async () => {
const team = await buildTeam(); const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id }); const admin = await buildAdmin({ teamId: team.id });

View File

@@ -10,6 +10,7 @@ import {
import collectionDestroyer from "@server/commands/collectionDestroyer"; import collectionDestroyer from "@server/commands/collectionDestroyer";
import collectionExporter from "@server/commands/collectionExporter"; import collectionExporter from "@server/commands/collectionExporter";
import teamUpdater from "@server/commands/teamUpdater"; import teamUpdater from "@server/commands/teamUpdater";
import { parser } from "@server/editor";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter"; import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction"; import { transaction } from "@server/middlewares/transaction";
@@ -25,6 +26,7 @@ import {
Attachment, Attachment,
FileOperation, FileOperation,
} from "@server/models"; } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies"; import { authorize } from "@server/policies";
import { import {
presentCollection, presentCollection,
@@ -48,8 +50,10 @@ router.post(
"collections.create", "collections.create",
auth(), auth(),
validate(T.CollectionsCreateSchema), validate(T.CollectionsCreateSchema),
transaction(),
async (ctx: APIContext<T.CollectionsCreateReq>) => { async (ctx: APIContext<T.CollectionsCreateReq>) => {
const { name, color, description, permission, sharing, icon, sort } = const { transaction } = ctx.state;
const { name, color, description, data, permission, sharing, icon, sort } =
ctx.input.body; ctx.input.body;
let { index } = ctx.input.body; let { index } = ctx.input.body;
@@ -69,6 +73,7 @@ router.post(
Sequelize.literal('"collection"."index" collate "C"'), Sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"], ["updatedAt", "DESC"],
], ],
transaction,
}); });
index = fractionalIndex( index = fractionalIndex(
@@ -78,9 +83,10 @@ router.post(
} }
index = await removeIndexCollision(user.teamId, index); index = await removeIndexCollision(user.teamId, index);
const collection = await Collection.create({ const collection = Collection.build({
name, name,
description, content: data,
description: data ? undefined : description,
icon, icon,
color, color,
teamId: user.teamId, teamId: user.teamId,
@@ -90,24 +96,38 @@ router.post(
sort, sort,
index, index,
}); });
await Event.create({
name: "collections.create", if (data) {
collectionId: collection.id, collection.description = DocumentHelper.toMarkdown(collection);
teamId: collection.teamId, }
actorId: user.id,
data: { await collection.save({ transaction });
name,
await Event.create(
{
name: "collections.create",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: {
name,
},
ip: ctx.request.ip,
}, },
ip: ctx.request.ip, {
}); transaction,
}
);
// we must reload the collection to get memberships for policy presenter // we must reload the collection to get memberships for policy presenter
const reloaded = await Collection.scope({ const reloaded = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(collection.id); }).findByPk(collection.id, {
transaction,
});
invariant(reloaded, "collection not found"); invariant(reloaded, "collection not found");
ctx.body = { ctx.body = {
data: presentCollection(reloaded), data: await presentCollection(ctx, reloaded),
policies: presentPolicies(user, [reloaded]), policies: presentPolicies(user, [reloaded]),
}; };
} }
@@ -127,7 +147,7 @@ router.post(
authorize(user, "read", collection); authorize(user, "read", collection);
ctx.body = { ctx.body = {
data: presentCollection(collection), data: await presentCollection(ctx, collection),
policies: presentPolicies(user, [collection]), policies: presentPolicies(user, [collection]),
}; };
} }
@@ -636,8 +656,17 @@ router.post(
transaction(), transaction(),
async (ctx: APIContext<T.CollectionsUpdateReq>) => { async (ctx: APIContext<T.CollectionsUpdateReq>) => {
const { transaction } = ctx.state; const { transaction } = ctx.state;
const { id, name, description, icon, permission, color, sort, sharing } = const {
ctx.input.body; id,
name,
description,
data,
icon,
permission,
color,
sort,
sharing,
} = ctx.input.body;
const { user } = ctx.state.auth; const { user } = ctx.state.auth;
const collection = await Collection.scope({ const collection = await Collection.scope({
@@ -675,6 +704,14 @@ router.post(
if (description !== undefined) { if (description !== undefined) {
collection.description = description; collection.description = description;
collection.content = description
? parser.parse(description)?.toJSON()
: null;
}
if (data !== undefined) {
collection.content = data;
collection.description = DocumentHelper.toMarkdown(collection);
} }
if (icon !== undefined) { if (icon !== undefined) {
@@ -759,7 +796,7 @@ router.post(
} }
ctx.body = { ctx.body = {
data: presentCollection(collection), data: await presentCollection(ctx, collection),
policies: presentPolicies(user, [collection]), policies: presentPolicies(user, [collection]),
}; };
} }
@@ -811,7 +848,9 @@ router.post(
ctx.body = { ctx.body = {
pagination: { ...ctx.state.pagination, total }, pagination: { ...ctx.state.pagination, total },
data: collections.map(presentCollection), data: await Promise.all(
collections.map((collection) => presentCollection(ctx, collection))
),
policies: presentPolicies(user, collections), policies: presentPolicies(user, collections),
}; };
} }

View File

@@ -6,7 +6,7 @@ import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections"; import { colorPalette } from "@shared/utils/collections";
import { Collection } from "@server/models"; import { Collection } from "@server/models";
import { ValidateColor, ValidateIndex } from "@server/validation"; import { ValidateColor, ValidateIndex } from "@server/validation";
import { BaseSchema } from "../schema"; import { BaseSchema, ProsemirrorSchema } from "../schema";
function zodEnumFromObjectKeys< function zodEnumFromObjectKeys<
TI extends Record<string, any>, TI extends Record<string, any>,
@@ -16,6 +16,11 @@ function zodEnumFromObjectKeys<
return z.enum([firstKey, ...otherKeys]); return z.enum([firstKey, ...otherKeys]);
} }
const BaseIdSchema = z.object({
/** Id of the collection to be updated */
id: z.string(),
});
export const CollectionsCreateSchema = BaseSchema.extend({ export const CollectionsCreateSchema = BaseSchema.extend({
body: z.object({ body: z.object({
name: z.string(), name: z.string(),
@@ -24,6 +29,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.regex(ValidateColor.regex, { message: ValidateColor.message }) .regex(ValidateColor.regex, { message: ValidateColor.message })
.default(randomElement(colorPalette)), .default(randomElement(colorPalette)),
description: z.string().nullish(), description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
permission: z permission: z
.nativeEnum(CollectionPermission) .nativeEnum(CollectionPermission)
.nullish() .nullish()
@@ -49,17 +55,13 @@ export const CollectionsCreateSchema = BaseSchema.extend({
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>; export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
export const CollectionsInfoSchema = BaseSchema.extend({ export const CollectionsInfoSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema,
id: z.string().uuid(),
}),
}); });
export type CollectionsInfoReq = z.infer<typeof CollectionsInfoSchema>; export type CollectionsInfoReq = z.infer<typeof CollectionsInfoSchema>;
export const CollectionsDocumentsSchema = BaseSchema.extend({ export const CollectionsDocumentsSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema,
id: z.string().uuid(),
}),
}); });
export type CollectionsDocumentsReq = z.infer< export type CollectionsDocumentsReq = z.infer<
@@ -82,8 +84,7 @@ export const CollectionsImportSchema = BaseSchema.extend({
export type CollectionsImportReq = z.infer<typeof CollectionsImportSchema>; export type CollectionsImportReq = z.infer<typeof CollectionsImportSchema>;
export const CollectionsAddGroupSchema = BaseSchema.extend({ export const CollectionsAddGroupSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
groupId: z.string().uuid(), groupId: z.string().uuid(),
permission: z permission: z
.nativeEnum(CollectionPermission) .nativeEnum(CollectionPermission)
@@ -94,8 +95,7 @@ export const CollectionsAddGroupSchema = BaseSchema.extend({
export type CollectionsAddGroupsReq = z.infer<typeof CollectionsAddGroupSchema>; export type CollectionsAddGroupsReq = z.infer<typeof CollectionsAddGroupSchema>;
export const CollectionsRemoveGroupSchema = BaseSchema.extend({ export const CollectionsRemoveGroupSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
groupId: z.string().uuid(), groupId: z.string().uuid(),
}), }),
}); });
@@ -105,8 +105,7 @@ export type CollectionsRemoveGroupReq = z.infer<
>; >;
export const CollectionsGroupMembershipsSchema = BaseSchema.extend({ export const CollectionsGroupMembershipsSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
query: z.string().optional(), query: z.string().optional(),
permission: z.nativeEnum(CollectionPermission).optional(), permission: z.nativeEnum(CollectionPermission).optional(),
}), }),
@@ -117,8 +116,7 @@ export type CollectionsGroupMembershipsReq = z.infer<
>; >;
export const CollectionsAddUserSchema = BaseSchema.extend({ export const CollectionsAddUserSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
userId: z.string().uuid(), userId: z.string().uuid(),
permission: z.nativeEnum(CollectionPermission).optional(), permission: z.nativeEnum(CollectionPermission).optional(),
}), }),
@@ -127,8 +125,7 @@ export const CollectionsAddUserSchema = BaseSchema.extend({
export type CollectionsAddUserReq = z.infer<typeof CollectionsAddUserSchema>; export type CollectionsAddUserReq = z.infer<typeof CollectionsAddUserSchema>;
export const CollectionsRemoveUserSchema = BaseSchema.extend({ export const CollectionsRemoveUserSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
userId: z.string().uuid(), userId: z.string().uuid(),
}), }),
}); });
@@ -138,8 +135,7 @@ export type CollectionsRemoveUserReq = z.infer<
>; >;
export const CollectionsMembershipsSchema = BaseSchema.extend({ export const CollectionsMembershipsSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
query: z.string().optional(), query: z.string().optional(),
permission: z.nativeEnum(CollectionPermission).optional(), permission: z.nativeEnum(CollectionPermission).optional(),
}), }),
@@ -150,8 +146,7 @@ export type CollectionsMembershipsReq = z.infer<
>; >;
export const CollectionsExportSchema = BaseSchema.extend({ export const CollectionsExportSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
format: z format: z
.nativeEnum(FileOperationFormat) .nativeEnum(FileOperationFormat)
.default(FileOperationFormat.MarkdownZip), .default(FileOperationFormat.MarkdownZip),
@@ -175,10 +170,10 @@ export type CollectionsExportAllReq = z.infer<
>; >;
export const CollectionsUpdateSchema = BaseSchema.extend({ export const CollectionsUpdateSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
name: z.string().optional(), name: z.string().optional(),
description: z.string().nullish(), description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
icon: zodEnumFromObjectKeys(IconLibrary.mapping).nullish(), icon: zodEnumFromObjectKeys(IconLibrary.mapping).nullish(),
permission: z.nativeEnum(CollectionPermission).nullish(), permission: z.nativeEnum(CollectionPermission).nullish(),
color: z color: z
@@ -206,16 +201,13 @@ export const CollectionsListSchema = BaseSchema.extend({
export type CollectionsListReq = z.infer<typeof CollectionsListSchema>; export type CollectionsListReq = z.infer<typeof CollectionsListSchema>;
export const CollectionsDeleteSchema = BaseSchema.extend({ export const CollectionsDeleteSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema,
id: z.string().uuid(),
}),
}); });
export type CollectionsDeleteReq = z.infer<typeof CollectionsDeleteSchema>; export type CollectionsDeleteReq = z.infer<typeof CollectionsDeleteSchema>;
export const CollectionsMoveSchema = BaseSchema.extend({ export const CollectionsMoveSchema = BaseSchema.extend({
body: z.object({ body: BaseIdSchema.extend({
id: z.string().uuid(),
index: z index: z
.string() .string()
.regex(ValidateIndex.regex, { message: ValidateIndex.message }) .regex(ValidateIndex.regex, { message: ValidateIndex.message })

View File

@@ -1172,7 +1172,7 @@ router.post(
documents.map((document) => presentDocument(ctx, document)) documents.map((document) => presentDocument(ctx, document))
), ),
collections: await Promise.all( collections: await Promise.all(
collections.map((collection) => presentCollection(collection)) collections.map((collection) => presentCollection(ctx, collection))
), ),
}, },
policies: collectionChanged ? presentPolicies(user, documents) : [], policies: collectionChanged ? presentPolicies(user, documents) : [],

View File

@@ -10,6 +10,7 @@ import {
DocumentPermission, DocumentPermission,
JSONValue, JSONValue,
UnfurlResourceType, UnfurlResourceType,
ProsemirrorData,
} from "@shared/types"; } from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema"; import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner"; import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -494,7 +495,8 @@ export type CollectionJSONExport = {
id: string; id: string;
urlId: string; urlId: string;
name: string; name: string;
description: Record<string, any> | null; data?: ProsemirrorData | null;
description?: ProsemirrorData | null;
permission?: CollectionPermission | null; permission?: CollectionPermission | null;
color: string; color: string;
icon?: string | null; icon?: string | null;