Compare commits

...

7 Commits

Author SHA1 Message Date
codegen-sh[bot] e6d552b92d Add emoji settings page with table management (#9323)
* Add emoji settings page with table management

- Add EmojisTable component with sortable columns for emoji, name, creator, and creation date
- Add EmojiMenu component for emoji management actions (delete)
- Add EmojiUploadDialog with drag-and-drop file upload and name validation
- Add emoji actions for create and delete operations
- Integrate emoji settings page into workspace settings navigation
- Follow existing Table component patterns and styling
- Include proper permissions checking and error handling

* fixes

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-06-14 12:09:44 -04:00
codegen-sh[bot] cc1ec6cb71 Add frontend EmojiModel and EmojisStore (#9321)
* Add frontend EmojiModel and EmojisStore

- Create Emoji model with properties matching server presenter
- Add EmojisStore with team-scoped emoji filtering
- Register EmojisStore in RootStore
- Follow existing patterns for model and store implementation

* Remove teamId and teamEmojis, add @Relation decorator

- Remove teamId field from Emoji model (not needed in team context)
- Remove teamEmojis computed property from EmojisStore
- Add @Relation decorator to createdBy property
- Update emojisByName to use orderedData directly

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-27 08:57:14 -04:00
Tom Moor 3ad0e58d35 refactor 2025-05-24 19:14:22 -04:00
Tom Moor 4c75f31b2a Add emojis.info, minor fixes 2025-05-24 19:06:34 -04:00
codegen-sh[bot] 72e4fca1c4 test: Add comprehensive test coverage for emoji API routes
- Add buildEmoji factory method to test/factories.ts
- Create emojis.test.ts with full test coverage for all endpoints
- Test authentication, authorization, validation, and business logic
- Follow existing test patterns and handle all edge cases
2025-05-24 17:20:16 +00:00
codegen-sh[bot] 495b0e1a56 refactor: Improve emoji backend implementation
- Move max length values to EmojiValidation class in shared/validations
- Remove teamId override ability from emojis.list for better security
- Use createWithCtx and destroyWithCtx methods for automatic transaction handling
- Consolidate emoji delete policy into single rule using 'or' utility
- Clean up unused imports

These changes improve security, maintainability, and consistency with existing patterns.
2025-05-24 16:43:14 +00:00
codegen-sh[bot] 03de720f6f feat: Add custom emoji backend implementation
- Add Emoji model with team and user associations
- Create migration for emojis table with proper indexes
- Implement API routes: emojis.list, emojis.create, emojis.delete
- Add emoji presenter for consistent API responses
- Implement emoji policies for team-based authorization
- Register emoji routes and exports in main API

Addresses issue #9278
2025-05-24 16:27:03 +00:00
26 changed files with 1485 additions and 1 deletions
+37
View File
@@ -0,0 +1,37 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import Emoji from "~/models/Emoji";
import { EmojiUploadDialog } from "~/components/EmojiDialogs";
import { createAction } from "~/actions";
import { TeamSection } from "~/actions/sections";
export const createEmoji = createAction({
name: ({ t }) => `${t("Add emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Add custom emoji"),
content: <EmojiUploadDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const deleteEmojiActionFactory = (emoji: Emoji) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete emoji",
section: TeamSection,
dangerous: true,
visible: () => {
const can = stores.policies.abilities(emoji.id);
return can.delete;
},
perform: async () => {
await stores.emojis.delete(emoji);
},
});
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
type Props = {
onSubmit: () => void;
};
export function EmojiUploadDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const onDrop = React.useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: [".png", ".jpg", ".jpeg", ".gif", ".webp"],
maxFiles: 1,
maxSize: 1024 * 1024, // 1MB
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const attachment = await uploadFile(file, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
await emojis.create({
name: name.trim(),
url: attachment.url,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} catch (error) {
toast.error(t("Failed to create emoji"));
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Remove special characters and spaces, convert to lowercase
const value = event.target.value
.toLowerCase()
.replace(/[^a-z0-9_]/g, "")
.slice(0, 32);
setName(value);
};
const isValid = name.trim().length > 0 && file;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Emoji name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text>{file.name}</Text>
<Text type="secondary">{t("Click or drag to replace")}</Text>
</>
) : (
<>
<Text>
{isDragActive
? t("Drop the image here")
: t("Click or drag an image here")}
</Text>
<Text type="secondary">
{t("PNG, JPG, GIF, or WebP up to 1MB")}
</Text>
</>
)}
</Flex>
</DropZone>
{name && (
<Text type="secondary">
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("divider")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: ${s("accent")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+11
View File
@@ -15,6 +15,7 @@ import {
Icon,
PlusIcon,
InternetIcon,
SmileyIcon,
} from "outline-icons";
import { ComponentProps } from "react";
import * as React from "react";
@@ -34,6 +35,7 @@ const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Emojis = lazy(() => import("~/scenes/Settings/Emojis"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
@@ -163,6 +165,15 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: ShapesIcon,
},
{
name: t("Emojis"),
path: settingsPath("emojis"),
component: Emojis.Component,
preload: Emojis.preload,
enabled: can.update,
group: t("Workspace"),
icon: SmileyIcon,
},
{
name: t("API Keys"),
path: settingsPath("api-keys"),
+54
View File
@@ -0,0 +1,54 @@
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Emoji from "~/models/Emoji";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
type Props = {
emoji: Emoji;
};
function EmojiMenu({ emoji }: Props) {
const { emojis } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
const can = usePolicy(emoji);
const handleDelete = React.useCallback(async () => {
await emojis.delete(emoji);
}, [emojis, emoji]);
if (!can.delete) {
return null;
}
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Emoji options")}>
<Template
{...menu}
items={[
{
type: "button",
title: t("Delete"),
icon: <TrashIcon />,
dangerous: true,
onClick: handleDelete,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(EmojiMenu);
+31
View File
@@ -0,0 +1,31 @@
import { observable } from "mobx";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Emoji extends Model {
static modelName = "Emoji";
/** The name of the emoji */
@Field
@observable
name: string;
/** The URL of the emoji image */
@Field
@observable
url: string;
/** The user who created this emoji */
@Relation(() => User)
@observable
createdBy?: User;
/** The ID of the user who created this emoji */
@Field
@observable
createdById: string;
}
export default Emoji;
+171
View File
@@ -0,0 +1,171 @@
import { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { PlusIcon, SmileyIcon } from "outline-icons";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import EmojisStore from "~/stores/EmojisStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createEmoji } from "~/actions/definitions/emojis";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { EmojisTable } from "./components/EmojisTable";
import { StickyFilters } from "./components/StickyFilters";
function Emojis() {
const location = useLocation();
const history = useHistory();
const team = useCurrentTeam();
const context = useActionContext();
const { emojis } = useStores();
const { t } = useTranslation();
const params = useQuery();
const can = usePolicy(team);
const [query, setQuery] = useState("");
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "name",
direction: (params.get("direction") || "asc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const { data, error, loading, next } = useTableRequest({
data: getFilteredEmojis({
emojis,
query: reqParams.query,
}),
sort,
reqFn: emojis.fetchPage,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
useEffect(() => {
if (error) {
toast.error(t("Could not load emojis"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
title={t("Emojis")}
icon={<SmileyIcon />}
actions={
<>
{can.createEmoji && (
<Action>
<Button
type="button"
data-on="click"
data-event-category="emoji"
data-event-action="create"
action={createEmoji}
context={context}
icon={<PlusIcon />}
>
{t("Add emoji")}
</Button>
</Action>
)}
</>
}
wide
>
<Heading>{t("Emojis")}</Heading>
<Text as="p" type="secondary">
{t(
"Custom emojis can be used throughout your workspace in documents, comments, and reactions."
)}
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<EmojisTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
}
function getFilteredEmojis({
emojis,
query,
}: {
emojis: EmojisStore;
query?: string;
}) {
let filteredEmojis = emojis.orderedData;
if (query) {
filteredEmojis = filteredEmojis.filter((emoji) =>
emoji.name.toLowerCase().includes(query.toLowerCase())
);
}
return filteredEmojis;
}
export default observer(Emojis);
@@ -0,0 +1,113 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import Emoji from "~/models/Emoji";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { HEADER_HEIGHT } from "~/components/Header";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import EmojiMenu from "~/menus/EmojiMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Emoji>, "columns" | "rowHeight"> & {
canManage: boolean;
};
export const EmojisTable = observer(function EmojisTable({
canManage,
...rest
}: Props) {
const { t } = useTranslation();
const columns = React.useMemo(
(): TableColumn<Emoji>[] =>
compact([
{
type: "data",
id: "emoji",
header: t("Emoji"),
accessor: (emoji) => emoji.url,
component: (emoji) => (
<EmojiPreview>
<EmojiImage src={emoji.url} alt={emoji.name} />
<span>:{emoji.name}:</span>
</EmojiPreview>
),
width: "1fr",
},
{
type: "data",
id: "createdBy",
header: t("Added by"),
accessor: (emoji) => emoji.createdBy,
sortable: false,
component: (emoji) => (
<Flex align="center" gap={8}>
{emoji.createdBy && (
<>
<Avatar model={emoji.createdBy} size={AvatarSize.Small} />
{emoji.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
header: t("Date added"),
accessor: (emoji) => emoji.createdAt,
component: (emoji) =>
emoji.createdAt ? (
<Time dateTime={emoji.createdAt} addSuffix />
) : null,
width: "1fr",
},
canManage
? {
type: "action",
id: "action",
component: (emoji) => <EmojiMenu emoji={emoji} />,
width: "50px",
}
: undefined,
]),
[t, canManage]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
});
const EmojiPreview = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-family: monospace;
font-size: 14px;
color: ${s("textSecondary")};
`;
const EmojiImage = styled.img`
width: 24px;
height: 24px;
object-fit: contain;
`;
+25
View File
@@ -0,0 +1,25 @@
import { computed } from "mobx";
import Emoji from "~/models/Emoji";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class EmojisStore extends Store<Emoji> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
RPCAction.Delete,
];
constructor(rootStore: RootStore) {
super(rootStore, Emoji);
}
@computed
get emojisByName() {
return this.orderedData.reduce((acc, emoji) => {
acc[emoji.name] = emoji;
return acc;
}, {} as Record<string, Emoji>);
}
}
+3
View File
@@ -9,6 +9,7 @@ import CommentsStore from "./CommentsStore";
import DialogsStore from "./DialogsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
import EmojisStore from "./EmojisStore";
import EventsStore from "./EventsStore";
import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
@@ -44,6 +45,7 @@ export default class RootStore {
comments: CommentsStore;
dialogs: DialogsStore;
documents: DocumentsStore;
emojis: EmojisStore;
events: EventsStore;
groups: GroupsStore;
groupUsers: GroupUsersStore;
@@ -77,6 +79,7 @@ export default class RootStore {
this.registerStore(GroupMembershipsStore);
this.registerStore(CommentsStore);
this.registerStore(DocumentsStore);
this.registerStore(EmojisStore);
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
this.registerStore(GroupUsersStore);
@@ -0,0 +1,63 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable("emojis", {
id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false
},
name: {
type: Sequelize.STRING,
allowNull: false
},
url: {
type: Sequelize.STRING,
allowNull: false
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "teams",
key: "id"
},
onDelete: "CASCADE"
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
key: "id"
},
onDelete: "CASCADE"
},
createdAt: {
type: Sequelize.DATE,
allowNull: false
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false
}
}, { transaction });
await queryInterface.addIndex("emojis", ["teamId"], { transaction });
await queryInterface.addIndex("emojis", ["createdById"], { transaction });
await queryInterface.addIndex("emojis", ["teamId", "name"], {
unique: true,
transaction
});
});
},
async down (queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("emojis", { transaction });
});
}
};
+84
View File
@@ -0,0 +1,84 @@
import {
InferAttributes,
InferCreationAttributes,
type SaveOptions,
} from "sequelize";
import {
BeforeCreate,
BelongsTo,
Column,
DataType,
ForeignKey,
Index,
Table,
} from "sequelize-typescript";
import { EmojiValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";
@Table({ tableName: "emojis", modelName: "emoji" })
@Fix
class Emoji extends IdModel<
InferAttributes<Emoji>,
Partial<InferCreationAttributes<Emoji>>
> {
@Length({
max: EmojiValidation.maxNameLength,
msg: `name must be ${EmojiValidation.maxNameLength} characters or less`,
})
@Column(DataType.STRING)
name: string;
@IsUrlOrRelativePath
@Length({
max: EmojiValidation.maxUrlLength,
msg: `url must be ${EmojiValidation.maxUrlLength} characters or less`,
})
@Column(DataType.STRING)
url: string;
// associations
@BelongsTo(() => Team)
team: Team;
@ForeignKey(() => Team)
@Index
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => User)
createdBy: User;
@ForeignKey(() => User)
@Index
@Column(DataType.UUID)
createdById: string;
// hooks
@BeforeCreate
static async checkUniqueName(
model: Emoji,
options: SaveOptions<Emoji>
): Promise<void> {
const existingEmoji = await this.findOne({
where: {
name: model.name,
teamId: model.teamId,
},
transaction: options.transaction,
});
if (existingEmoji) {
throw ValidationError(`Emoji with name "${model.name}" already exists.`);
}
}
}
export default Emoji;
+2
View File
@@ -16,6 +16,8 @@ export { default as Comment } from "./Comment";
export { default as Document } from "./Document";
export { default as Emoji } from "./Emoji";
export { default as Event } from "./Event";
export { default as FileOperation } from "./FileOperation";
+11
View File
@@ -0,0 +1,11 @@
import { User, Emoji, Team } from "@server/models";
import { allow } from "./cancan";
import { isOwner, isTeamAdmin, isTeamModel, or } from "./utils";
allow(User, "createEmoji", Team, isTeamModel);
allow(User, "read", Emoji, isTeamModel);
allow(User, "delete", Emoji, (actor, emoji) =>
or(isOwner(actor, emoji), isTeamAdmin(actor, emoji))
);
+1
View File
@@ -8,6 +8,7 @@ import "./authenticationProvider";
import "./collection";
import "./comment";
import "./document";
import "./emoji";
import "./fileOperation";
import "./import";
import "./integration";
+3
View File
@@ -53,6 +53,9 @@ export function isOwner(
if ("userId" in model) {
return actor.id === model.userId;
}
if ("createdById" in model) {
return actor.id === model.createdById;
}
return false;
}
+15
View File
@@ -0,0 +1,15 @@
import { Emoji } from "@server/models";
import presentUser from "./user";
export default function present(emoji: Emoji) {
return {
id: emoji.id,
name: emoji.name,
url: emoji.url,
teamId: emoji.teamId,
createdBy: emoji.createdBy ? presentUser(emoji.createdBy) : undefined,
createdById: emoji.createdById,
createdAt: emoji.createdAt,
updatedAt: emoji.updatedAt,
};
}
+2
View File
@@ -5,6 +5,7 @@ import presentAvailableTeam from "./availableTeam";
import presentCollection from "./collection";
import presentComment from "./comment";
import presentDocument from "./document";
import presentEmoji from "./emoji";
import presentEvent from "./event";
import presentFileOperation from "./fileOperation";
import presentGroup from "./group";
@@ -36,6 +37,7 @@ export {
presentCollection,
presentComment,
presentDocument,
presentEmoji,
presentEvent,
presentFileOperation,
presentGroup,
@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#emojis.create should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#emojis.delete should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#emojis.info should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#emojis.list should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
+414
View File
@@ -0,0 +1,414 @@
import { v4 } from "uuid";
import { Emoji } from "@server/models";
import {
buildAdmin,
buildEmoji,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#emojis.info", () => {
it("should return emoji info by name", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
name: "testemoji",
});
const res = await server.post("/api/emojis.info", {
body: {
token: user.getJwtToken(),
name: emoji.name,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(emoji.id);
expect(body.data.name).toEqual(emoji.name);
expect(body.data.url).toEqual(emoji.url);
});
it("should require authentication", async () => {
const res = await server.post("/api/emojis.info");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return emoji info", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/emojis.info", {
body: {
token: user.getJwtToken(),
id: emoji.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(emoji.id);
expect(body.data.name).toEqual(emoji.name);
expect(body.data.url).toEqual(emoji.url);
});
it("should not return emoji from another team", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const otherTeam = await buildTeam();
const otherUser = await buildUser({ teamId: otherTeam.id });
const emoji = await buildEmoji({
teamId: otherTeam.id,
createdById: otherUser.id,
});
const res = await server.post("/api/emojis.info", {
body: {
token: user.getJwtToken(),
id: emoji.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#emojis.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/emojis.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return team emojis", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildEmoji({
teamId: team.id,
createdById: user.id,
name: "awesome",
});
await buildEmoji({
teamId: team.id,
createdById: user.id,
name: "cool",
});
// Create emoji in another team that should not be returned
const otherTeam = await buildTeam();
const otherUser = await buildUser({ teamId: otherTeam.id });
await buildEmoji({
teamId: otherTeam.id,
createdById: otherUser.id,
name: "hidden",
});
const res = await server.post("/api/emojis.list", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(2);
expect(body.data.map((e: Emoji) => e.name).sort()).toEqual([
"awesome",
"cool",
]);
expect(body.data[0].createdBy).toBeDefined();
expect(body.policies).toBeDefined();
});
it("should support pagination", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
// Create multiple emojis
for (let i = 0; i < 15; i++) {
await buildEmoji({
teamId: team.id,
createdById: user.id,
name: `emoji${i}`,
});
}
const res = await server.post("/api/emojis.list", {
body: {
token: user.getJwtToken(),
limit: 10,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(10);
expect(body.pagination.total).toEqual(15);
});
});
describe("#emojis.create", () => {
it("should require authentication", async () => {
const res = await server.post("/api/emojis.create");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should create emoji", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/emojis.create", {
body: {
token: user.getJwtToken(),
name: "awesome",
url: "https://example.com/awesome.png",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("awesome");
expect(body.data.url).toEqual("https://example.com/awesome.png");
expect(body.data.createdBy.id).toEqual(user.id);
expect(body.policies).toBeDefined();
// Verify emoji was created in database
const emoji = await Emoji.findByPk(body.data.id);
expect(emoji).toBeTruthy();
expect(emoji!.teamId).toEqual(team.id);
expect(emoji!.createdById).toEqual(user.id);
});
it("should not allow duplicate emoji names in same team", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildEmoji({
teamId: team.id,
createdById: user.id,
name: "awesome",
});
const res = await server.post("/api/emojis.create", {
body: {
token: user.getJwtToken(),
name: "awesome",
url: "https://example.com/awesome2.png",
},
});
expect(res.status).toEqual(400);
const body = await res.json();
expect(body.message).toContain("already exists");
});
it("should allow same emoji name in different teams", async () => {
const team1 = await buildTeam();
const user1 = await buildUser({ teamId: team1.id });
const team2 = await buildTeam();
const user2 = await buildUser({ teamId: team2.id });
await buildEmoji({
teamId: team1.id,
createdById: user1.id,
name: "awesome",
});
const res = await server.post("/api/emojis.create", {
body: {
token: user2.getJwtToken(),
name: "awesome",
url: "https://example.com/awesome2.png",
},
});
expect(res.status).toEqual(200);
const body = await res.json();
expect(body).toMatchObject({
data: expect.objectContaining({
name: "awesome",
}),
});
});
it("should validate emoji name length", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/emojis.create", {
body: {
token: user.getJwtToken(),
name: "a".repeat(51), // Exceeds max length of 50
url: "https://example.com/awesome.png",
},
});
expect(res.status).toEqual(400);
});
it("should validate emoji URL length", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/emojis.create", {
body: {
token: user.getJwtToken(),
name: "awesome",
url: "https://example.com/" + "a".repeat(500), // Exceeds max length of 500
},
});
expect(res.status).toEqual(400);
});
it("should validate URL format", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/emojis.create", {
body: {
token: user.getJwtToken(),
name: "awesome",
url: "not-a-valid-url",
},
});
expect(res.status).toEqual(400);
});
});
describe("#emojis.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/emojis.delete");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should allow emoji creator to delete", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/emojis.delete", {
body: {
token: user.getJwtToken(),
id: emoji.id,
},
});
expect(res.status).toEqual(200);
const deleteBody = await res.json();
expect(deleteBody).toMatchObject({
success: true,
});
// Verify emoji was deleted
const deletedEmoji = await Emoji.findByPk(emoji.id);
expect(deletedEmoji).toBeNull();
});
it("should allow team admin to delete any emoji", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/emojis.delete", {
body: {
token: admin.getJwtToken(),
id: emoji.id,
},
});
expect(res.status).toEqual(200);
const adminDeleteBody = await res.json();
expect(adminDeleteBody).toMatchObject({
success: true,
});
// Verify emoji was deleted
const deletedEmoji = await Emoji.findByPk(emoji.id);
expect(deletedEmoji).toBeNull();
});
it("should not allow non-creator non-admin to delete", async () => {
const team = await buildTeam();
const creator = await buildUser({ teamId: team.id });
const otherUser = await buildUser({ teamId: team.id });
const emoji = await buildEmoji({
teamId: team.id,
createdById: creator.id,
});
const res = await server.post("/api/emojis.delete", {
body: {
token: otherUser.getJwtToken(),
id: emoji.id,
},
});
expect(res.status).toEqual(403);
// Verify emoji was not deleted
const stillExists = await Emoji.findByPk(emoji.id);
expect(stillExists).toBeTruthy();
});
it("should not allow deleting emoji from another team", async () => {
const team1 = await buildTeam();
const user1 = await buildUser({ teamId: team1.id });
const team2 = await buildTeam();
const user2 = await buildUser({ teamId: team2.id });
const emoji = await buildEmoji({
teamId: team2.id,
createdById: user2.id,
});
const res = await server.post("/api/emojis.delete", {
body: {
token: user1.getJwtToken(),
id: emoji.id,
},
});
expect(res.status).toEqual(403);
// Verify emoji was not deleted
const stillExists = await Emoji.findByPk(emoji.id);
expect(stillExists).toBeTruthy();
});
it("should return 404 for non-existent emoji", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/emojis.delete", {
body: {
token: user.getJwtToken(),
id: v4(), // Non-existent UUID
},
});
expect(res.status).toEqual(404);
});
});
+157
View File
@@ -0,0 +1,157 @@
import Router from "koa-router";
import { WhereOptions, Op } from "sequelize";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Emoji, User } from "@server/models";
import { authorize } from "@server/policies";
import { presentEmoji, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"emojis.info",
auth(),
validate(T.EmojisInfoSchema),
async (ctx: APIContext<T.EmojisInfoReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const include = [
{
model: User,
as: "createdBy",
paranoid: false,
},
];
let emoji;
if (id) {
emoji = await Emoji.findByPk(id, {
rejectOnEmpty: true,
include,
});
} else if (name) {
emoji = await Emoji.findOne({
where: {
name,
teamId: user.teamId,
},
include,
rejectOnEmpty: true,
});
}
authorize(user, "read", emoji);
ctx.body = {
data: presentEmoji(emoji),
policies: presentPolicies(user, [emoji]),
};
}
);
router.post(
"emojis.list",
auth(),
pagination(),
validate(T.EmojisListSchema),
async (ctx: APIContext<T.EmojisListReq>) => {
const { user } = ctx.state.auth;
const { query } = ctx.input.body;
let where: WhereOptions<Emoji> = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
name: {
[Op.iLike]: `${query}%`,
},
};
}
const [emojis, total] = await Promise.all([
Emoji.findAll({
where,
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
],
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Emoji.count({
where,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: emojis.map(presentEmoji),
policies: presentPolicies(user, emojis),
};
}
);
router.post(
"emojis.create",
rateLimiter(RateLimiterStrategy.TenPerMinute),
auth(),
validate(T.EmojisCreateSchema),
transaction(),
async (ctx: APIContext<T.EmojisCreateReq>) => {
const { name, url } = ctx.input.body;
const { user } = ctx.state.auth;
const emoji = await Emoji.createWithCtx(ctx, {
name,
url,
teamId: user.teamId,
createdById: user.id,
});
emoji.createdBy = user;
ctx.body = {
data: presentEmoji(emoji!),
policies: presentPolicies(user, [emoji!]),
};
}
);
router.post(
"emojis.delete",
auth(),
validate(T.EmojisDeleteSchema),
transaction(),
async (ctx: APIContext<T.EmojisDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const emoji = await Emoji.findByPk(id, {
transaction: ctx.state.transaction,
rejectOnEmpty: true,
});
authorize(user, "delete", emoji);
await emoji.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;
+1
View File
@@ -0,0 +1 @@
export { default } from "./emojis";
+45
View File
@@ -0,0 +1,45 @@
export const EmojisInfoSchema = BaseSchema.extend({
body: z
.object({
/** ID of the emoji to fetch */
id: z.string().uuid().optional(),
/** Name of the emoji to fetch */
name: z.string().min(1).max(EmojiValidation.maxNameLength).optional(),
})
.refine((data) => data.id || data.name, {
message: "Either id or name is required",
}),
});
export type EmojisInfoReq = z.infer<typeof EmojisInfoSchema>;
import { z } from "zod";
import { EmojiValidation } from "@shared/validations";
import { BaseSchema } from "../schema";
export const EmojisListSchema = BaseSchema.extend({
body: z.object({
query: z.string().optional(),
}),
});
export const EmojisCreateSchema = BaseSchema.extend({
body: z.object({
/** Name/shortcode for the emoji (e.g., "awesome") */
name: z.string().min(1).max(EmojiValidation.maxNameLength),
/** URL to the emoji image */
url: z.string().max(EmojiValidation.maxUrlLength),
}),
});
export const EmojisDeleteSchema = BaseSchema.extend({
body: z.object({
/** ID of the emoji to delete */
id: z.string().uuid(),
}),
});
export type EmojisListReq = z.infer<typeof EmojisListSchema>;
export type EmojisCreateReq = z.infer<typeof EmojisCreateSchema>;
export type EmojisDeleteReq = z.infer<typeof EmojisDeleteSchema>;
+2
View File
@@ -17,6 +17,7 @@ import comments from "./comments/comments";
import cron from "./cron";
import developer from "./developer";
import documents from "./documents";
import emojis from "./emojis";
import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groupMemberships from "./groupMemberships";
@@ -80,6 +81,7 @@ router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", documents.routes());
router.use("/", emojis.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
router.use("/", views.routes());
+21
View File
@@ -47,6 +47,7 @@ import {
OAuthClient,
AuthenticationProvider,
OAuthAuthentication,
Emoji,
} from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { hash } from "@server/utils/crypto";
@@ -829,3 +830,23 @@ export function buildCommentMark(overrides: {
attrs: overrides,
};
}
export async function buildEmoji(overrides: Partial<Emoji> = {}) {
if (!overrides.teamId) {
const team = await buildTeam();
overrides.teamId = team.id;
}
if (!overrides.createdById) {
const user = await buildUser({
teamId: overrides.teamId,
});
overrides.createdById = user.id;
}
return Emoji.create({
name: faker.lorem.word(),
url: faker.image.url(),
...overrides,
});
}
+19 -1
View File
@@ -100,6 +100,8 @@
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Add emoji": "Add emoji",
"Add custom emoji": "Add custom emoji",
"Home": "Home",
"Drafts": "Drafts",
"Search": "Search",
@@ -229,6 +231,18 @@
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
"Please enter a name for the emoji": "Please enter a name for the emoji",
"Please select an image file": "Please select an image file",
"Emoji created successfully": "Emoji created successfully",
"Failed to create emoji": "Failed to create emoji",
"Uploading": "Uploading",
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.": "Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores.",
"Emoji name": "Emoji name",
"Click or drag to replace": "Click or drag to replace",
"Drop the image here": "Drop the image here",
"Click or drag an image here": "Click or drag an image here",
"PNG, JPG, GIF, or WebP up to 1MB": "PNG, JPG, GIF, or WebP up to 1MB",
"This emoji will be available as": "This emoji will be available as",
"Module failed to load": "Module failed to load",
"Loading Failed": "Loading Failed",
"Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.": "Sorry, part of the application failed to load. This may be because it was updated since you opened the tab or because of a failed network request. Please try reloading.",
@@ -540,6 +554,7 @@
"Subscription inherited from collection": "Subscription inherited from collection",
"Apply template": "Apply template",
"Enable embeds": "Enable embeds",
"Emoji options": "Emoji options",
"Export options": "Export options",
"Group members": "Group members",
"Edit group": "Edit group",
@@ -931,6 +946,8 @@
"File not supported please upload a valid ZIP file": "File not supported please upload a valid ZIP file",
"Set the default permission level for collections created from the import": "Set the default permission level for collections created from the import",
"Start import": "Start import",
"Added by": "Added by",
"Date added": "Date added",
"Processing": "Processing",
"Expired": "Expired",
"Completed": "Completed",
@@ -964,7 +981,6 @@
"Date created": "Date created",
"Crop Image": "Crop Image",
"Crop image": "Crop image",
"Uploading": "Uploading",
"How does this work?": "How does this work?",
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -1019,6 +1035,8 @@
"This is the screen that workspace members will first see when they sign in.": "This is the screen that workspace members will first see when they sign in.",
"Danger": "Danger",
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
"Could not load emojis": "Could not load emojis",
"Custom emojis can be used throughout your workspace in documents, comments, and reactions.": "Custom emojis can be used throughout your workspace in documents, comments, and reactions.",
"Export data": "Export data",
"A full export might take some time, consider exporting a single document or collection. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.": "A full export might take some time, consider exporting a single document or collection. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.",
"Recent exports": "Recent exports",
+8
View File
@@ -54,6 +54,14 @@ export const DocumentValidation = {
maxRecommendedLength: 250000,
};
export const EmojiValidation = {
/** The maximum length of the emoji name */
maxNameLength: 25,
/** The maximum length of the emoji URL */
maxUrlLength: 500,
};
export const ImportValidation = {
/** The maximum length of the import name */
maxNameLength: 100,