feat: Unified icon picker (#7038)
This commit is contained in:
@@ -12,7 +12,8 @@ type Props = Optional<
|
||||
| "title"
|
||||
| "text"
|
||||
| "content"
|
||||
| "emoji"
|
||||
| "icon"
|
||||
| "color"
|
||||
| "collectionId"
|
||||
| "parentDocumentId"
|
||||
| "importId"
|
||||
@@ -36,7 +37,8 @@ type Props = Optional<
|
||||
export default async function documentCreator({
|
||||
title = "",
|
||||
text = "",
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
state,
|
||||
id,
|
||||
urlId,
|
||||
@@ -96,9 +98,9 @@ export default async function documentCreator({
|
||||
importId,
|
||||
sourceMetadata,
|
||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||
emoji: templateDocument ? templateDocument.emoji : emoji,
|
||||
icon: templateDocument ? templateDocument.emoji : emoji,
|
||||
color: templateDocument ? templateDocument.color : null,
|
||||
emoji: templateDocument ? templateDocument.emoji : icon,
|
||||
icon: templateDocument ? templateDocument.emoji : icon,
|
||||
color: templateDocument ? templateDocument.color : color,
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.title : title,
|
||||
user
|
||||
|
||||
@@ -26,7 +26,8 @@ describe("documentDuplicator", () => {
|
||||
expect(response[0].title).toEqual(original.title);
|
||||
expect(response[0].text).toEqual(original.text);
|
||||
expect(response[0].emoji).toEqual(original.emoji);
|
||||
expect(response[0].icon).toEqual(original.emoji);
|
||||
expect(response[0].icon).toEqual(original.icon);
|
||||
expect(response[0].color).toEqual(original.color);
|
||||
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
@@ -35,7 +36,7 @@ describe("documentDuplicator", () => {
|
||||
const original = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
emoji: "👋",
|
||||
icon: "👋",
|
||||
});
|
||||
|
||||
const response = await sequelize.transaction((transaction) =>
|
||||
@@ -52,8 +53,9 @@ describe("documentDuplicator", () => {
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response[0].title).toEqual("New title");
|
||||
expect(response[0].text).toEqual(original.text);
|
||||
expect(response[0].emoji).toEqual(original.emoji);
|
||||
expect(response[0].icon).toEqual(original.emoji);
|
||||
expect(response[0].emoji).toEqual(original.icon);
|
||||
expect(response[0].icon).toEqual(original.icon);
|
||||
expect(response[0].color).toEqual(original.color);
|
||||
expect(response[0].publishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
@@ -62,7 +64,7 @@ describe("documentDuplicator", () => {
|
||||
const original = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
emoji: "👋",
|
||||
icon: "👋",
|
||||
});
|
||||
|
||||
await buildDocument({
|
||||
@@ -108,7 +110,8 @@ describe("documentDuplicator", () => {
|
||||
expect(response[0].title).toEqual(original.title);
|
||||
expect(response[0].text).toEqual(original.text);
|
||||
expect(response[0].emoji).toEqual(original.emoji);
|
||||
expect(response[0].icon).toEqual(original.emoji);
|
||||
expect(response[0].icon).toEqual(original.icon);
|
||||
expect(response[0].color).toEqual(original.color);
|
||||
expect(response[0].publishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,8 @@ export default async function documentDuplicator({
|
||||
|
||||
const duplicated = await documentCreator({
|
||||
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
|
||||
emoji: document.emoji,
|
||||
icon: document.icon ?? document.emoji,
|
||||
color: document.color,
|
||||
template: document.template,
|
||||
title: title ?? document.title,
|
||||
content: document.content,
|
||||
@@ -78,7 +79,8 @@ export default async function documentDuplicator({
|
||||
for (const childDocument of childDocuments) {
|
||||
const duplicatedChildDocument = await documentCreator({
|
||||
parentDocumentId: duplicated.id,
|
||||
emoji: childDocument.emoji,
|
||||
icon: childDocument.icon ?? childDocument.emoji,
|
||||
color: childDocument.color,
|
||||
title: childDocument.title,
|
||||
text: childDocument.text,
|
||||
...sharedProperties,
|
||||
|
||||
@@ -28,7 +28,7 @@ async function documentImporter({
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<{
|
||||
emoji?: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
title: string;
|
||||
state: Buffer;
|
||||
@@ -43,9 +43,9 @@ async function documentImporter({
|
||||
// find and extract emoji near the beginning of the document.
|
||||
const regex = emojiRegex();
|
||||
const matches = regex.exec(text.slice(0, 10));
|
||||
const emoji = matches ? matches[0] : undefined;
|
||||
if (emoji) {
|
||||
text = text.replace(emoji, "");
|
||||
const icon = matches ? matches[0] : undefined;
|
||||
if (icon) {
|
||||
text = text.replace(icon, "");
|
||||
}
|
||||
|
||||
// If the first line of the imported text looks like a markdown heading
|
||||
@@ -96,7 +96,7 @@ async function documentImporter({
|
||||
text,
|
||||
state,
|
||||
title,
|
||||
emoji,
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ type Props = {
|
||||
document: Document;
|
||||
/** The new title */
|
||||
title?: string;
|
||||
/** The document emoji */
|
||||
emoji?: string | null;
|
||||
/** The document icon */
|
||||
icon?: string | null;
|
||||
/** The document icon's color */
|
||||
color?: string | null;
|
||||
/** The new text content */
|
||||
text?: string;
|
||||
/** Whether the editing session is complete */
|
||||
@@ -46,7 +48,8 @@ export default async function documentUpdater({
|
||||
user,
|
||||
document,
|
||||
title,
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
text,
|
||||
editorVersion,
|
||||
templateId,
|
||||
@@ -65,9 +68,12 @@ export default async function documentUpdater({
|
||||
if (title !== undefined) {
|
||||
document.title = title.trim();
|
||||
}
|
||||
if (emoji !== undefined) {
|
||||
document.emoji = emoji;
|
||||
document.icon = emoji;
|
||||
if (icon !== undefined) {
|
||||
document.emoji = icon;
|
||||
document.icon = icon;
|
||||
}
|
||||
if (color !== undefined) {
|
||||
document.color = color;
|
||||
}
|
||||
if (editorVersion) {
|
||||
document.editorVersion = editorVersion;
|
||||
|
||||
@@ -183,6 +183,7 @@ class Collection extends ParanoidModel<
|
||||
@Column(DataType.JSONB)
|
||||
content: ProsemirrorData | null;
|
||||
|
||||
/** An icon (or) emoji to use as the collection icon. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -190,6 +191,7 @@ class Collection extends ParanoidModel<
|
||||
@Column
|
||||
icon: string | null;
|
||||
|
||||
/** The color of the icon. */
|
||||
@IsHexColor
|
||||
@Column
|
||||
color: string | null;
|
||||
@@ -270,10 +272,6 @@ class Collection extends ParanoidModel<
|
||||
|
||||
@BeforeSave
|
||||
static async onBeforeSave(model: Collection) {
|
||||
if (model.icon === "collection") {
|
||||
model.icon = null;
|
||||
}
|
||||
|
||||
if (!model.content) {
|
||||
model.content = await DocumentHelper.toJSON(model);
|
||||
}
|
||||
|
||||
@@ -255,14 +255,18 @@ class Document extends ParanoidModel<
|
||||
@Column
|
||||
editorVersion: string;
|
||||
|
||||
/** An emoji to use as the document icon. */
|
||||
/**
|
||||
* An emoji to use as the document icon,
|
||||
* This is used as fallback (for backward compat) when icon is not set.
|
||||
*/
|
||||
@Length({
|
||||
max: 1,
|
||||
msg: `Emoji must be a single character`,
|
||||
max: 50,
|
||||
msg: `Emoji must be 50 characters or less`,
|
||||
})
|
||||
@Column
|
||||
emoji: string | null;
|
||||
|
||||
/** An icon to use as the document icon. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -365,7 +369,11 @@ class Document extends ParanoidModel<
|
||||
model.archivedAt ||
|
||||
model.template ||
|
||||
!model.publishedAt ||
|
||||
!(model.changed("title") || model.changed("emoji")) ||
|
||||
!(
|
||||
model.changed("title") ||
|
||||
model.changed("icon") ||
|
||||
model.changed("color")
|
||||
) ||
|
||||
!model.collectionId
|
||||
) {
|
||||
return;
|
||||
@@ -721,6 +729,8 @@ class Document extends ParanoidModel<
|
||||
this.text = revision.text;
|
||||
this.title = revision.title;
|
||||
this.emoji = revision.emoji;
|
||||
this.icon = revision.icon;
|
||||
this.color = revision.color;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1083,6 +1093,8 @@ class Document extends ParanoidModel<
|
||||
title: this.title,
|
||||
url: this.url,
|
||||
emoji: isNil(this.emoji) ? undefined : this.emoji,
|
||||
icon: isNil(this.icon) ? undefined : this.icon,
|
||||
color: isNil(this.color) ? undefined : this.color,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -71,13 +71,18 @@ class Revision extends IdModel<
|
||||
@Column(DataType.JSONB)
|
||||
content: ProsemirrorData;
|
||||
|
||||
/**
|
||||
* An emoji to use as the document icon,
|
||||
* This is used as fallback (for backward compat) when icon is not set.
|
||||
*/
|
||||
@Length({
|
||||
max: 1,
|
||||
msg: `Emoji must be a single character`,
|
||||
max: 50,
|
||||
msg: `Emoji must be 50 characters or less`,
|
||||
})
|
||||
@Column
|
||||
emoji: string | null;
|
||||
|
||||
/** An icon to use as the document icon. */
|
||||
@Length({
|
||||
max: 50,
|
||||
msg: `icon must be 50 characters or less`,
|
||||
@@ -134,7 +139,7 @@ class Revision extends IdModel<
|
||||
title: document.title,
|
||||
text: document.text,
|
||||
emoji: document.emoji,
|
||||
icon: document.emoji,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
content: document.content,
|
||||
userId: document.lastModifiedById,
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Node } from "prosemirror-model";
|
||||
import * as Y from "yjs";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import { IconType, ProsemirrorData } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import { parser, serializer, schema } from "@server/editor";
|
||||
import { addTags } from "@server/logging/tracer";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
@@ -148,7 +149,10 @@ export class DocumentHelper {
|
||||
return text;
|
||||
}
|
||||
|
||||
const title = `${document.emoji ? document.emoji + " " : ""}${
|
||||
const icon = document.icon ?? document.emoji;
|
||||
const iconType = determineIconType(icon);
|
||||
|
||||
const title = `${iconType === IconType.Emoji ? icon + " " : ""}${
|
||||
document.title
|
||||
}`;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "@server/models/Collection";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -19,7 +18,7 @@ export default async function presentCollection(
|
||||
sort: collection.sort,
|
||||
icon: collection.icon,
|
||||
index: collection.index,
|
||||
color: collection.color || colorPalette[0],
|
||||
color: collection.color,
|
||||
permission: collection.permission,
|
||||
sharing: collection.sharing,
|
||||
createdAt: collection.createdAt,
|
||||
|
||||
@@ -49,6 +49,8 @@ async function presentDocument(
|
||||
: undefined,
|
||||
text: !asData || options?.includeText ? text : undefined,
|
||||
emoji: document.emoji,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
tasks: document.tasks,
|
||||
createdAt: document.createdAt,
|
||||
createdBy: undefined,
|
||||
|
||||
@@ -13,7 +13,8 @@ async function presentRevision(revision: Revision, diff?: string) {
|
||||
documentId: revision.documentId,
|
||||
title: strippedTitle,
|
||||
data: await DocumentHelper.toJSON(revision),
|
||||
emoji: revision.emoji ?? emoji,
|
||||
icon: revision.icon ?? revision.emoji ?? emoji,
|
||||
color: revision.color,
|
||||
html: diff,
|
||||
createdAt: revision.createdAt,
|
||||
createdBy: presentUser(revision.user),
|
||||
|
||||
@@ -43,7 +43,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
transaction,
|
||||
});
|
||||
|
||||
const { text, state, title, emoji } = await documentImporter({
|
||||
const { text, state, title, icon } = await documentImporter({
|
||||
user,
|
||||
fileName: sourceMetadata.fileName,
|
||||
mimeType: sourceMetadata.mimeType,
|
||||
@@ -55,7 +55,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
|
||||
return documentCreator({
|
||||
sourceMetadata,
|
||||
title,
|
||||
emoji,
|
||||
icon,
|
||||
text,
|
||||
state,
|
||||
publish,
|
||||
|
||||
@@ -124,7 +124,8 @@ export default class ExportJSONTask extends ExportTask {
|
||||
id: document.id,
|
||||
urlId: document.urlId,
|
||||
title: document.title,
|
||||
emoji: document.emoji,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
data: DocumentHelper.toProsemirror(document),
|
||||
createdById: document.createdById,
|
||||
createdByName: document.createdBy.name,
|
||||
|
||||
@@ -79,9 +79,9 @@ export default class ImportJSONTask extends ImportTask {
|
||||
// TODO: This is kind of temporary, we can import the document
|
||||
// structure directly in the future.
|
||||
text: serializer.serialize(Node.fromJSON(schema, node.data)),
|
||||
emoji: node.emoji,
|
||||
icon: node.emoji,
|
||||
color: null,
|
||||
emoji: node.icon ?? node.emoji,
|
||||
icon: node.icon ?? node.emoji,
|
||||
color: node.color,
|
||||
createdAt: node.createdAt ? new Date(node.createdAt) : undefined,
|
||||
updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined,
|
||||
publishedAt: node.publishedAt ? new Date(node.publishedAt) : null,
|
||||
|
||||
@@ -79,7 +79,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, emoji, text } = await documentImporter({
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: "text/markdown",
|
||||
fileName: child.name,
|
||||
content:
|
||||
@@ -115,8 +115,8 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
output.documents.push({
|
||||
id,
|
||||
title,
|
||||
emoji,
|
||||
icon: emoji,
|
||||
emoji: icon,
|
||||
icon,
|
||||
text,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
|
||||
@@ -96,7 +96,7 @@ export default class ImportNotionTask extends ImportTask {
|
||||
|
||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||
|
||||
const { title, emoji, text } = await documentImporter({
|
||||
const { title, icon, text } = await documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
@@ -130,8 +130,8 @@ export default class ImportNotionTask extends ImportTask {
|
||||
output.documents.push({
|
||||
id,
|
||||
title,
|
||||
emoji,
|
||||
icon: emoji,
|
||||
emoji: icon,
|
||||
icon,
|
||||
text,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
|
||||
@@ -38,7 +38,7 @@ export type StructuredImportData = {
|
||||
collections: {
|
||||
id: string;
|
||||
urlId?: string;
|
||||
color?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
sort?: CollectionSort;
|
||||
permission?: CollectionPermission | null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { Document, UserMembership, GroupPermission } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
@@ -182,6 +181,23 @@ describe("#collections.move", () => {
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow setting an emoji as icon", 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.move", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
index: "P",
|
||||
icon: "😁",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return error when icon is not valid", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
@@ -1150,7 +1166,6 @@ describe("#collections.create", () => {
|
||||
expect(body.data.name).toBe("Test");
|
||||
expect(body.data.sort.field).toBe("index");
|
||||
expect(body.data.sort.direction).toBe("asc");
|
||||
expect(colorPalette.includes(body.data.color)).toBeTruthy();
|
||||
expect(body.policies.length).toBe(1);
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { z } from "zod";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { Collection } from "@server/models";
|
||||
import { zodEnumFromObjectKeys } from "@server/utils/zod";
|
||||
import { ValidateColor, ValidateIndex } from "@server/validation";
|
||||
import { BaseSchema, ProsemirrorSchema } from "../schema";
|
||||
|
||||
function zodEnumFromObjectKeys<
|
||||
TI extends Record<string, any>,
|
||||
R extends string = TI extends Record<infer R, any> ? R : never
|
||||
>(input: TI): z.ZodEnum<[R, ...R[]]> {
|
||||
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
|
||||
return z.enum([firstKey, ...otherKeys]);
|
||||
}
|
||||
|
||||
const BaseIdSchema = z.object({
|
||||
/** Id of the collection to be updated */
|
||||
id: z.string(),
|
||||
@@ -27,7 +19,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.default(randomElement(colorPalette)),
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema.nullish(),
|
||||
permission: z
|
||||
@@ -35,7 +27,12 @@ export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().default(true),
|
||||
icon: zodEnumFromObjectKeys(IconLibrary.mapping).optional(),
|
||||
icon: z
|
||||
.union([
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
])
|
||||
.optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
@@ -174,7 +171,12 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
||||
name: z.string().optional(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema.nullish(),
|
||||
icon: zodEnumFromObjectKeys(IconLibrary.mapping).nullish(),
|
||||
icon: z
|
||||
.union([
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
])
|
||||
.nullish(),
|
||||
permission: z.nativeEnum(CollectionPermission).nullish(),
|
||||
color: z
|
||||
.string()
|
||||
|
||||
@@ -2786,7 +2786,7 @@ describe("#documents.create", () => {
|
||||
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
|
||||
});
|
||||
|
||||
it("should create as a new document", async () => {
|
||||
it("should create as a new document with emoji", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
@@ -2809,6 +2809,34 @@ describe("#documents.create", () => {
|
||||
expect(newDocument!.parentDocumentId).toBe(null);
|
||||
expect(newDocument!.collectionId).toBe(collection.id);
|
||||
expect(newDocument!.emoji).toBe("🚢");
|
||||
expect(newDocument!.icon).toBe("🚢");
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should create as a new document with icon", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
icon: "🚢",
|
||||
title: "new document",
|
||||
text: "hello",
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const newDocument = await Document.findByPk(body.data.id);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(newDocument!.parentDocumentId).toBe(null);
|
||||
expect(newDocument!.collectionId).toBe(collection.id);
|
||||
expect(newDocument!.emoji).toBe("🚢");
|
||||
expect(newDocument!.icon).toBe("🚢");
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -3094,7 +3122,7 @@ describe("#documents.update", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should fail to update an invalid emoji value", async () => {
|
||||
it("should fail to update an invalid icon value", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
@@ -3105,13 +3133,13 @@ describe("#documents.update", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
emoji: ":)",
|
||||
icon: ":)",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
|
||||
expect(body.message).toBe("emoji: Invalid");
|
||||
expect(body.message).toBe("icon: Invalid");
|
||||
});
|
||||
|
||||
it("should successfully update the emoji", async () => {
|
||||
@@ -3124,12 +3152,34 @@ describe("#documents.update", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
emoji: "😂",
|
||||
emoji: "🚢",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.emoji).toBe("😂");
|
||||
expect(body.data.emoji).toBe("🚢");
|
||||
expect(body.data.icon).toBe("🚢");
|
||||
expect(body.data.color).toBeNull;
|
||||
});
|
||||
|
||||
it("should successfully update the icon", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/documents.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
icon: "beaker",
|
||||
color: "#FFDDEE",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.icon).toBe("beaker");
|
||||
expect(body.data.color).toBe("#FFDDEE");
|
||||
});
|
||||
|
||||
it("should not add template to collection structure when publishing", async () => {
|
||||
|
||||
@@ -944,6 +944,8 @@ router.post(
|
||||
createdById: user.id,
|
||||
template: true,
|
||||
emoji: original.emoji,
|
||||
icon: original.icon,
|
||||
color: original.color,
|
||||
title: original.title,
|
||||
text: original.text,
|
||||
content: original.content,
|
||||
@@ -1041,6 +1043,7 @@ router.post(
|
||||
document,
|
||||
user,
|
||||
...input,
|
||||
icon: input.icon ?? input.emoji,
|
||||
publish,
|
||||
collectionId,
|
||||
insightsEnabled,
|
||||
@@ -1382,6 +1385,8 @@ router.post(
|
||||
title,
|
||||
text,
|
||||
emoji,
|
||||
icon,
|
||||
color,
|
||||
publish,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
@@ -1445,7 +1450,8 @@ router.post(
|
||||
const document = await documentCreator({
|
||||
title,
|
||||
text,
|
||||
emoji,
|
||||
icon: icon ?? emoji,
|
||||
color,
|
||||
createdAt,
|
||||
publish,
|
||||
collectionId: collection?.id,
|
||||
|
||||
@@ -4,8 +4,11 @@ import isEmpty from "lodash/isEmpty";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { z } from "zod";
|
||||
import { DocumentPermission, StatusFilter } from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { zodEnumFromObjectKeys } from "@server/utils/zod";
|
||||
import { ValidateColor } from "@server/validation";
|
||||
|
||||
const DocumentsSortParamsSchema = z.object({
|
||||
/** Specifies the attributes by which documents will be sorted in the list */
|
||||
@@ -223,6 +226,20 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
||||
/** Emoji displayed alongside doc title */
|
||||
emoji: z.string().regex(emojiRegex()).nullish(),
|
||||
|
||||
/** Icon displayed alongside doc title */
|
||||
icon: z
|
||||
.union([
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
])
|
||||
.nullish(),
|
||||
|
||||
/** Icon color */
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
|
||||
/** Boolean to denote if the doc should occupy full width */
|
||||
fullWidth: z.boolean().optional(),
|
||||
|
||||
@@ -319,7 +336,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
text: z.string().default(""),
|
||||
|
||||
/** Emoji displayed alongside doc title */
|
||||
emoji: z.string().regex(emojiRegex()).optional(),
|
||||
emoji: z.string().regex(emojiRegex()).nullish(),
|
||||
|
||||
/** Icon displayed alongside doc title */
|
||||
icon: z
|
||||
.union([
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
])
|
||||
.optional(),
|
||||
|
||||
/** Icon color */
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
|
||||
/** Boolean to denote if the doc should be published */
|
||||
publish: z.boolean().optional(),
|
||||
|
||||
@@ -52,7 +52,7 @@ export default async function main(exit = false, limit = 1000) {
|
||||
try {
|
||||
const { emoji, strippedTitle } = parseTitle(document.title);
|
||||
if (emoji) {
|
||||
document.emoji = emoji;
|
||||
document.icon = emoji;
|
||||
document.title = strippedTitle;
|
||||
|
||||
if (document.changed()) {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function main(exit = false, limit = 1000) {
|
||||
try {
|
||||
const { emoji, strippedTitle } = parseTitle(revision.title);
|
||||
if (emoji) {
|
||||
revision.emoji = emoji;
|
||||
revision.icon = emoji;
|
||||
revision.title = strippedTitle;
|
||||
|
||||
if (revision.changed()) {
|
||||
|
||||
@@ -468,7 +468,13 @@ export type DocumentJSONExport = {
|
||||
id: string;
|
||||
urlId: string;
|
||||
title: string;
|
||||
emoji: string | null;
|
||||
/**
|
||||
* For backward compatibility, maintain the `emoji` field.
|
||||
* Future exports will use the `icon` field.
|
||||
* */
|
||||
emoji?: string | null;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
data: Record<string, any>;
|
||||
createdById: string;
|
||||
createdByName: string;
|
||||
@@ -498,7 +504,7 @@ export type CollectionJSONExport = {
|
||||
data?: ProsemirrorData | null;
|
||||
description?: ProsemirrorData | null;
|
||||
permission?: CollectionPermission | null;
|
||||
color: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
sort: CollectionSort;
|
||||
documentStructure: NavigationNode[] | null;
|
||||
|
||||
9
server/utils/zod.ts
Normal file
9
server/utils/zod.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export function zodEnumFromObjectKeys<
|
||||
TI extends Record<string, any>,
|
||||
R extends string = TI extends Record<infer R, any> ? R : never
|
||||
>(input: TI): z.ZodEnum<[R, ...R[]]> {
|
||||
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
|
||||
return z.enum([firstKey, ...otherKeys]);
|
||||
}
|
||||
Reference in New Issue
Block a user