Skip to content

Commit

Permalink
feat(next): update ticket title and content (#957)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdjdd authored Jan 8, 2024
1 parent 4bb9225 commit 08b9882
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 35 deletions.
1 change: 1 addition & 0 deletions next/api/src/response/ticket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class TicketResponse extends BaseTicketResponse {
return {
...super.toJSON(options),
metaData: this.ticket.metaData,
content: this.ticket.content,
contentSafeHTML: sanitize(this.ticket.contentHTML),
};
}
Expand Down
14 changes: 14 additions & 0 deletions next/api/src/router/ticket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,8 @@ const updateTicketSchema = yup.object({
privateTags: yup.array(ticketTagSchema.required()),
evaluation: ticketEvaluationSchema.default(undefined),
language: yup.string().oneOf(allowedTicketLanguages).nullable(),
title: yup.string(),
content: yup.string(),
});

router.patch('/:id', async (ctx) => {
Expand Down Expand Up @@ -748,6 +750,18 @@ router.patch('/:id', async (ctx) => {
updater.setLanguage(data.language);
}

if (data.title || data.content) {
if (!isCustomerService || currentUser.id !== ticket.authorId ) {
ctx.throw(403, 'Update title or content is not allowed');
}
if (data.title) {
updater.setTitle(data.title);
}
if (data.content) {
updater.setContent(data.content);
}
}

await updater.update(currentUser);

ctx.body = {};
Expand Down
10 changes: 10 additions & 0 deletions next/api/src/ticket/TicketUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { systemUser, TinyUserInfo, User } from '@/model/User';
import { TinyReplyInfo } from '@/model/Reply';
import { TicketLog } from '@/model/TicketLog';
import { searchTicketService } from '@/service/search-ticket';
import htmlify from '@/utils/htmlify';

export interface UpdateOptions {
useMasterKey?: boolean;
Expand Down Expand Up @@ -185,6 +186,15 @@ export class TicketUpdater {
}
}

setTitle(title: string) {
this.data.title = title;
}

setContent(content: string) {
this.data.content = content;
this.data.contentHTML = htmlify(content);
}

operate(action: OperateAction): this {
if (this.data.status) {
throw new Error('Cannot operate ticket after change status');
Expand Down
14 changes: 7 additions & 7 deletions next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { SubscribeButton } from './components/SubscribeButton';
import { PrivateSelect } from './components/PrivateSelect';
import { CategoryCascader } from './components/CategoryCascader';
import { LeanCloudApp } from './components/LeanCloudApp';
import { ReplyCard } from './components/ReplyCard';
import { TicketCard } from './components/TicketCard';
import { useMixedTicket } from './mixed-ticket';
import { TicketField_v1, useTicketFields_v1 } from './api1';
import { CustomFields } from './components/CustomFields';
Expand All @@ -63,6 +63,8 @@ export function TicketDetail() {
const { id } = useParams() as { id: string };
const navigate = useNavigate();

const user = useCurrentUser();

const { ticket, update, updating, refetch } = useMixedTicket(id);
useTitle(ticket ? ticket.title : 'Loading', {
restoreOnUnmount: true,
Expand Down Expand Up @@ -134,12 +136,10 @@ export function TicketDetail() {
<Col className="p-4" span={24} md={12}>
<Timeline
header={
<ReplyCard
id={ticket.id}
author={ticket.author ? <UserLabel user={ticket.author} /> : 'unknown'}
createTime={ticket.createdAt}
content={ticket.contentSafeHTML}
files={ticket.files}
<TicketCard
ticket={ticket}
updateable={user?.id === ticket.authorId}
onUpdate={update}
/>
}
replies={replies}
Expand Down
18 changes: 17 additions & 1 deletion next/web/src/App/Admin/Tickets/Ticket/Timeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import cx from 'classnames';
import { ReplySchema, useDeleteReply, useUpdateReply } from '@/api/reply';
import { OpsLog as OpsLogSchema } from '@/api/ticket';
import { UserLabel } from '@/App/Admin/components';
import { ReplyCard } from '../components/ReplyCard';
import { ReplyCard, ReplyCardProps } from '../components/ReplyCard';
import { OpsLog } from '../components/OpsLog';
import { EditReplyModal, EditReplyModalRef } from '../components/EditReplyModal';
import { ReplyRevisionsModal, ReplyRevisionsModalRef } from '../components/ReplyRevisionsModal';
Expand Down Expand Up @@ -60,6 +60,21 @@ export function Timeline({ header, replies, opsLogs, onRefetchReplies }: Timelin
},
});

const createMenuItems = (reply: ReplySchema) => {
if (!reply.isCustomerService) return;
const menuItems: ReplyCardProps['menuItems'] = [];
if (reply.deletedAt) {
menuItems.push({ label: '修改记录', key: 'revisions' });
} else {
menuItems.push({ label: '编辑', key: 'edit' });
if (reply.edited) {
menuItems.push({ label: '修改记录', key: 'revisions' });
}
menuItems.push({ type: 'divider' }, { label: '删除', key: 'delete', danger: true });
}
return menuItems;
};

const handleClickMenu = (reply: ReplySchema, key: string) => {
switch (key) {
case 'edit':
Expand Down Expand Up @@ -101,6 +116,7 @@ export function Timeline({ header, replies, opsLogs, onRefetchReplies }: Timelin
isInternal={timeline.data.internal}
edited={timeline.data.edited}
deleted={!!timeline.data.deletedAt}
menuItems={createMenuItems(timeline.data)}
onClickMenu={(key) => handleClickMenu(timeline.data, key)}
/>
);
Expand Down
36 changes: 15 additions & 21 deletions next/web/src/App/Admin/Tickets/Ticket/components/ReplyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const BasicReplyCard = forwardRef<HTMLDivElement, BasicReplyCardProps>(
}
);

interface ReplyCardProps {
export interface ReplyCardProps {
id: string;
author: ReactNode;
createTime: string;
Expand All @@ -137,6 +137,7 @@ interface ReplyCardProps {
isInternal?: boolean;
edited?: boolean;
deleted?: boolean;
menuItems?: ItemType[];
onClickMenu?: (key: string) => void;
}

Expand All @@ -150,29 +151,11 @@ export function ReplyCard({
isInternal,
edited,
deleted,
menuItems,
onClickMenu,
}: ReplyCardProps) {
const [translation, toggleTranslation] = useToggle(false);

const menuItems = useMemo(() => {
const items: ItemType[] = [
{ label: '复制链接', key: 'copyLink' },
{ label: '翻译', key: 'translate' },
];
if (isAgent) {
if (!deleted) {
items.push({ type: 'divider' }, { label: '编辑', key: 'edit' });
if (edited) {
items.push({ label: '修改记录', key: 'revisions' });
}
items.push({ type: 'divider' }, { label: '删除', key: 'delete', danger: true });
} else if (edited) {
items.push({ label: '修改记录', key: 'revisions' });
}
}
return items;
}, [isAgent, edited, deleted]);

const handleClickMenu = ({ key }: { key: string }) => {
if (key === 'translate') {
toggleTranslation();
Expand Down Expand Up @@ -222,7 +205,18 @@ export function ReplyCard({
</div>
}
tags={tags}
menu={collapsed ? undefined : { items: menuItems, onClick: handleClickMenu }}
menu={
collapsed
? undefined
: {
items: [
{ label: '复制链接', key: 'copyLink' },
{ label: '翻译', key: 'translate' },
...(menuItems?.length ? [{ type: 'divider' } as const, ...menuItems] : []),
],
onClick: handleClickMenu,
}
}
files={files}
active={active}
deleted={deleted}
Expand Down
93 changes: 93 additions & 0 deletions next/web/src/App/Admin/Tickets/Ticket/components/TicketCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { useToggle } from 'react-use';
import { Input, Modal } from 'antd';

import { UserLabel, UserLabelProps } from '@/App/Admin/components/UserLabel';
import { ReplyCard, ReplyCardProps } from './ReplyCard';
import { MarkdownEditor } from './ReplyEditor';

interface UpdateData {
title: string;
content: string;
}

export interface TicketCardProps {
ticket: {
id: string;
author?: UserLabelProps['user'];
authorId: string;
createdAt: string;
title: string;
content: string;
contentSafeHTML: string;
files?: ReplyCardProps['files'];
};
updateable?: boolean;
onUpdate?: (data: UpdateData) => void | Promise<void>;
}

export function TicketCard({ ticket, updateable, onUpdate }: TicketCardProps) {
const [editModalOpen, toggleEditModal] = useToggle(false);
const [tempTitle, setTempTitle] = useState('');
const [tempContent, setTempContent] = useState('');
const [isUpdating, setIsUpdating] = useState(false);

const handleEdit = () => {
setTempTitle(ticket.title);
setTempContent(ticket.content);
toggleEditModal();
};

const handleUpdate = async () => {
if (isUpdating || !onUpdate) {
return;
}
setIsUpdating(true);
try {
await onUpdate({ title: tempTitle, content: tempContent });
toggleEditModal(false);
} finally {
setIsUpdating(false);
}
};

return (
<>
<ReplyCard
id={ticket.id}
author={ticket.author ? <UserLabel user={ticket.author} /> : 'unknown'}
createTime={ticket.createdAt}
content={ticket.contentSafeHTML}
files={ticket.files}
menuItems={
updateable
? [
{
key: 'edit',
label: '编辑',
onClick: handleEdit,
},
]
: undefined
}
/>

<Modal
open={editModalOpen}
width={650}
title="编辑标题和内容"
onCancel={toggleEditModal}
onOk={handleUpdate}
confirmLoading={isUpdating}
>
<Input
placeholder="标题"
value={tempTitle}
onChange={(e) => setTempTitle(e.target.value)}
style={{ marginBottom: 20 }}
/>
<MarkdownEditor value={tempContent} onChange={setTempContent} onSubmit={handleUpdate} />
</Modal>
</>
);
}
20 changes: 14 additions & 6 deletions next/web/src/App/Admin/Tickets/Ticket/mixed-ticket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ interface MixedTicket {
nid: TicketDetailSchema['nid'];
categoryId: TicketDetailSchema['categoryId'];
author: TicketDetailSchema['author'];
authorId: TicketDetailSchema['authorId'];
groupId: TicketDetailSchema['groupId'];
assigneeId: TicketDetailSchema['assigneeId'];
status: TicketDetailSchema['status'];
title: TicketDetailSchema['title'];
content: TicketDetailSchema['content'];
contentSafeHTML: TicketDetailSchema['contentSafeHTML'];
files: TicketDetailSchema['files'];
language: TicketDetailSchema['language'];
Expand All @@ -32,6 +34,8 @@ interface MixedTicket {
}

interface MixedUpdateData {
title?: UpdateTicketData['title'];
content?: UpdateTicketData['content'];
categoryId?: UpdateTicketData['categoryId'];
groupId?: UpdateTicketData['groupId'];
assigneeId?: UpdateTicketData['assigneeId'];
Expand All @@ -46,7 +50,7 @@ interface MixedUpdateData {

interface UseMixedTicketResult {
ticket?: MixedTicket;
update: (data: MixedUpdateData) => void;
update: (data: MixedUpdateData) => Promise<void>;
updating: boolean;
refetch: () => void;
}
Expand All @@ -59,15 +63,15 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult {
enabled: !!ticket,
});

const { mutate: updateTicket, isLoading: ticketUpdating } = useUpdateTicket({
const { mutateAsync: updateTicket, isLoading: ticketUpdating } = useUpdateTicket({
onSuccess: (_, [_id, data]) => {
refetch();
if (data.tags || data.privateTags) {
refetch_v1();
}
},
});
const { mutate: updateTicket_v1, isLoading: ticketUpdating_v1 } = useUpdateTicket_v1({
const { mutateAsync: updateTicket_v1, isLoading: ticketUpdating_v1 } = useUpdateTicket_v1({
onSuccess: () => {
refetch_v1();
},
Expand All @@ -80,10 +84,12 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult {
nid: ticket.nid,
categoryId: ticket.categoryId,
author: ticket.author,
authorId: ticket.authorId,
groupId: ticket.groupId,
assigneeId: ticket.assigneeId,
status: ticket.status,
title: ticket.title,
content: ticket.content,
contentSafeHTML: ticket.contentSafeHTML,
files: ticket.files,
language: ticket.language,
Expand All @@ -100,11 +106,13 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult {
}
}, [ticket, ticket_v1]);

const update = (data: MixedUpdateData) => {
const update = async (data: MixedUpdateData) => {
if (!ticket) {
return;
}
const updateData = pick(data, [
'title',
'content',
'categoryId',
'groupId',
'assigneeId',
Expand All @@ -114,10 +122,10 @@ export function useMixedTicket(ticketId: string): UseMixedTicketResult {
]);
const updateData_v1 = pick(data, ['private', 'subscribed']);
if (!isEmpty(updateData)) {
updateTicket([ticket.id, updateData]);
await updateTicket([ticket.id, updateData]);
}
if (!isEmpty(updateData_v1)) {
updateTicket_v1([ticket.id, updateData_v1]);
await updateTicket_v1([ticket.id, updateData_v1]);
}
};

Expand Down
Loading

0 comments on commit 08b9882

Please sign in to comment.