diff --git a/next/web/package-lock.json b/next/web/package-lock.json index e579a8719..c65109794 100644 --- a/next/web/package-lock.json +++ b/next/web/package-lock.json @@ -21,7 +21,9 @@ "antd": "^4.24.8", "axios": "^0.24.0", "classnames": "^2.3.1", + "dompurify": "^3.0.6", "github-markdown-css": "^5.1.0", + "handlebars": "^4.7.8", "highlight.js": "^11.6.0", "immer": "^9.0.12", "lodash-es": "^4.17.21", @@ -45,6 +47,7 @@ }, "devDependencies": { "@hookform/devtools": "^4.0.2", + "@types/dompurify": "^3.0.4", "@types/lodash-es": "^4.17.5", "@types/react": "^17.0.38", "@types/react-beautiful-dnd": "^13.1.2", @@ -2537,6 +2540,11 @@ "prosemirror-view": "^1.18.7" } }, + "node_modules/@toast-ui/editor/node_modules/dompurify": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz", + "integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==" + }, "node_modules/@toast-ui/react-editor": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@toast-ui/react-editor/-/react-editor-3.1.2.tgz", @@ -2620,6 +2628,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.4.tgz", + "integrity": "sha512-1Jk8S/IRzNSbwQRbuGuLFHviwxQ8pX81ZEW3INY9432Cwb4VedkBYan8gSIXVLOLHBtimOmUTEYphjRVmo+30g==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "0.0.41", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.41.tgz", @@ -2773,6 +2790,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.5.tgz", + "integrity": "sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==", + "dev": true + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -4822,9 +4845,9 @@ "integrity": "sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg==" }, "node_modules/dompurify": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.4.tgz", - "integrity": "sha512-6BVcgOAVFXjI0JTjEvZy901Rghm+7fDQOrNIcxB4+gdhj6Kwp6T9VBhBY/AbagKHJocRkDYGd6wvI+p4/10xtQ==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.6.tgz", + "integrity": "sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==" }, "node_modules/dotignore": { "version": "0.1.2", @@ -5969,6 +5992,51 @@ "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", "integrity": "sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE=" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/handlebars/node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/handlebars/node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -8093,6 +8161,11 @@ "ms": "^2.1.1" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_modules/node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", @@ -12762,6 +12835,13 @@ "prosemirror-model": "^1.14.1", "prosemirror-state": "^1.3.4", "prosemirror-view": "^1.18.7" + }, + "dependencies": { + "dompurify": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.7.tgz", + "integrity": "sha512-kxxKlPEDa6Nc5WJi+qRgPbOAbgTpSULL+vI3NUXsZMlkJxTqYI9wg5ZTay2sFrdZRWHPWNi+EdAhcJf81WtoMQ==" + } } }, "@toast-ui/react-editor": { @@ -12832,6 +12912,15 @@ "@types/ms": "*" } }, + "@types/dompurify": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.4.tgz", + "integrity": "sha512-1Jk8S/IRzNSbwQRbuGuLFHviwxQ8pX81ZEW3INY9432Cwb4VedkBYan8gSIXVLOLHBtimOmUTEYphjRVmo+30g==", + "dev": true, + "requires": { + "@types/trusted-types": "*" + } + }, "@types/estree": { "version": "0.0.41", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.41.tgz", @@ -12985,6 +13074,12 @@ "@types/node": "*" } }, + "@types/trusted-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.5.tgz", + "integrity": "sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==", + "dev": true + }, "@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -14497,9 +14592,9 @@ "integrity": "sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg==" }, "dompurify": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.4.tgz", - "integrity": "sha512-6BVcgOAVFXjI0JTjEvZy901Rghm+7fDQOrNIcxB4+gdhj6Kwp6T9VBhBY/AbagKHJocRkDYGd6wvI+p4/10xtQ==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.0.6.tgz", + "integrity": "sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==" }, "dotignore": { "version": "0.1.2", @@ -15376,6 +15471,36 @@ "resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz", "integrity": "sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE=" }, + "handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + } + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -16872,6 +16997,11 @@ } } }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node-releases": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", diff --git a/next/web/package.json b/next/web/package.json index 042ff4bda..9ef4aab49 100644 --- a/next/web/package.json +++ b/next/web/package.json @@ -24,7 +24,9 @@ "antd": "^4.24.8", "axios": "^0.24.0", "classnames": "^2.3.1", + "dompurify": "^3.0.6", "github-markdown-css": "^5.1.0", + "handlebars": "^4.7.8", "highlight.js": "^11.6.0", "immer": "^9.0.12", "lodash-es": "^4.17.21", @@ -48,6 +50,7 @@ }, "devDependencies": { "@hookform/devtools": "^4.0.2", + "@types/dompurify": "^3.0.4", "@types/lodash-es": "^4.17.5", "@types/react": "^17.0.38", "@types/react-beautiful-dnd": "^13.1.2", diff --git a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx index 4a799ab3b..d5923e3bc 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/TicketDetail.tsx @@ -117,7 +117,11 @@ export function TicketDetail() { disabled={updating} /> - + @@ -314,15 +318,17 @@ function transformField(field: TicketField_v1) { type: field.type, label: field.variants[0]?.title || 'unknown', options: field.variants[0]?.options?.map(([value, label]) => ({ label, value })), + previewTemplate: field.preview_template, }; } interface CustomFieldsSectionProps { ticketId: string; categoryId: string; + author?: UserSchema; } -function CustomFieldsSection({ ticketId, categoryId }: CustomFieldsSectionProps) { +function CustomFieldsSection({ ticketId, categoryId, author }: CustomFieldsSectionProps) { const { data: formFieldIds, isLoading: loadingFormFieldIds } = useFormFieldIds(categoryId); const { data: fieldValues, isLoading: loadingFieldValues } = useTicketFieldValues(ticketId); @@ -382,6 +388,7 @@ function CustomFieldsSection({ ticketId, categoryId }: CustomFieldsSectionProps) values={fieldValueMap} updating={updating} onChange={handleUpdate} + user={author} /> )} @@ -393,6 +400,7 @@ function CustomFieldsSection({ ticketId, categoryId }: CustomFieldsSectionProps) values={fieldValueMap} updating={updating} onChange={handleUpdate} + user={author} /> )} diff --git a/next/web/src/App/Admin/Tickets/Ticket/api1.tsx b/next/web/src/App/Admin/Tickets/Ticket/api1.tsx index 84427bc6d..65941e5d1 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/api1.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/api1.tsx @@ -51,6 +51,7 @@ export interface TicketField_v1 { title: string; options?: [string, string][]; }[]; + preview_template?: string; } export function useTicketFields_v1( diff --git a/next/web/src/App/Admin/Tickets/Ticket/components/CustomFields.tsx b/next/web/src/App/Admin/Tickets/Ticket/components/CustomFields.tsx index 8ede69c19..e40495bb3 100644 --- a/next/web/src/App/Admin/Tickets/Ticket/components/CustomFields.tsx +++ b/next/web/src/App/Admin/Tickets/Ticket/components/CustomFields.tsx @@ -1,14 +1,19 @@ -import { JSXElementConstructor, useState } from 'react'; +import { JSXElementConstructor, useMemo, useState } from 'react'; import { isEmpty } from 'lodash-es'; +import Handlebars from 'handlebars'; +import DOMPurify from 'dompurify'; import { Button, Input, Select } from '@/components/antd'; import { FormField } from './FormField'; +import { ErrorBoundary } from 'react-error-boundary'; +import { UserSchema } from '@/api/user'; interface CustomField { id: string; type: string; label: string; options?: { label: string; value: string }[]; + previewTemplate?: string; } interface FileFieldValue { @@ -48,9 +53,17 @@ interface CustomFieldsProps { disabled?: boolean; updating?: boolean; onChange: (values: Record) => void; + user?: UserSchema; } -export function CustomFields({ fields, values, disabled, updating, onChange }: CustomFieldsProps) { +export function CustomFields({ + fields, + values, + disabled, + updating, + onChange, + user, +}: CustomFieldsProps) { const [tempValues, setTempValues] = useState>({}); return ( @@ -60,15 +73,58 @@ export function CustomFields({ fields, values, disabled, updating, onChange }: C if (!Component) { return null; } + + const contentNode = ( + setTempValues({ ...tempValues, [field.id]: value })} + /> + ); + + let previewNode; + const { previewTemplate, id } = field; + const value = values[id]?.value; + const DEFAULT_CONTENT_PLACEHOLDER = '#DEFAULT#'; + if (previewTemplate) { + const showPreviewAnyway = previewTemplate.startsWith('!'); + if (showPreviewAnyway || value !== undefined) { + const template = showPreviewAnyway ? previewTemplate.slice(1) : previewTemplate; + previewNode = ( + ( +
Render preview template error: {error.message}
+ )} + > + {/* {template.startsWith(DEFAULT_CONTENT_PLACEHOLDER) && defaultContent} */} + + {/* {template.endsWith(DEFAULT_CONTENT_PLACEHOLDER) && defaultContent} */} +
+ ); + } + } + return ( - setTempValues({ ...tempValues, [field.id]: value })} - /> + {previewNode ? ( + <> + {previewNode} +
+ 编辑 + {contentNode} +
+ + ) : ( + contentNode + )}
); })} @@ -149,3 +205,27 @@ function withProps

, K extends keyof P>( ): JSXElementConstructor> { return (props: any) => ; } + +function CustomFieldPreview({ + template, + value, + user, +}: { + template: string; + value: string; + user?: UserSchema; +}) { + const tpl = useMemo(() => (template ? Handlebars.compile(template) : undefined), [template]); + const previewHTML = useMemo(() => { + let parsedValue = value; + try { + parsedValue = JSON.parse(value); + } catch (error) { + // ignore the error + } + return tpl + ? DOMPurify.sanitize(tpl({ value: parsedValue, user }), { ADD_TAGS: ['iframe'] }) + : undefined; + }, [tpl, value, user]); + return

; +} diff --git a/next/web/src/api/ticket.ts b/next/web/src/api/ticket.ts index 6b3d5ea4d..d5f792ab9 100644 --- a/next/web/src/api/ticket.ts +++ b/next/web/src/api/ticket.ts @@ -1,4 +1,10 @@ -import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from 'react-query'; +import { + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, + useQueryClient, +} from 'react-query'; import { castArray, isEmpty } from 'lodash-es'; import { http } from '@/leancloud'; @@ -410,8 +416,12 @@ async function updateTicketFieldValues(ticketId: string, data: UpdateTicketField export function useUpdateTicketFieldValues( options?: UseMutationOptions> ) { + const queryClient = useQueryClient(); return useMutation({ mutationFn: (vars) => updateTicketFieldValues(...vars), + onSuccess: (_, [ticketId, data]) => { + queryClient.invalidateQueries(['ticketFieldValues', ticketId]); + }, ...options, }); }