mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d552b92d | |||
| cc1ec6cb71 | |||
| 3ad0e58d35 | |||
| 4c75f31b2a | |||
| 72e4fca1c4 | |||
| 495b0e1a56 | |||
| 03de720f6f |
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
@@ -8,6 +8,7 @@ import "./authenticationProvider";
|
||||
import "./collection";
|
||||
import "./comment";
|
||||
import "./document";
|
||||
import "./emoji";
|
||||
import "./fileOperation";
|
||||
import "./import";
|
||||
import "./integration";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./emojis";
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 it’s 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 it’s complete.",
|
||||
"Recent exports": "Recent exports",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user