feat: Unified icon picker (#7038)

This commit is contained in:
Hemachandar
2024-06-23 19:01:18 +05:30
committed by GitHub
parent 56d90e6bc3
commit 6fd3a0fa8a
83 changed files with 2302 additions and 852 deletions

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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,
};
};

View File

@@ -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,

View File

@@ -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
}`;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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()

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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
View 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]);
}