From c7fa313df8965a5d2665772a98d7671d5dd30b6a Mon Sep 17 00:00:00 2001 From: "haitao(lj)" Date: Wed, 9 Nov 2022 11:49:44 +0800 Subject: [PATCH 01/43] refactor(ts): remove downlevelIteration flag --- ui/src/pages/Questions/Ask/index.tsx | 2 +- ui/tsconfig.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index 3ed72f84..f8f1ea1e 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -123,7 +123,7 @@ const Ask = () => { isInvalid: true, errorMsg: t('form.fields.title.msg.empty'), }; - } else if ([...title.value].length > 150) { + } else if (Array.from(title.value).length > 150) { bol = false; formData.title = { value: title.value, diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 000cc227..3cfbea32 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -7,7 +7,6 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, - "downlevelIteration": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", From 8637698d3edde6636266fbca43f945b1a66d24f9 Mon Sep 17 00:00:00 2001 From: "haitao(lj)" Date: Wed, 9 Nov 2022 16:13:49 +0800 Subject: [PATCH 02/43] refactor(utils/common): remove unnecessary method getQueryString --- ui/src/pages/Users/ActiveEmail/index.tsx | 5 +++-- ui/src/pages/Users/Login/index.tsx | 7 ++++--- ui/src/pages/Users/PasswordReset/index.tsx | 7 +++---- ui/src/utils/common.ts | 8 -------- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/ui/src/pages/Users/ActiveEmail/index.tsx b/ui/src/pages/Users/ActiveEmail/index.tsx index ac2223a7..177395d7 100644 --- a/ui/src/pages/Users/ActiveEmail/index.tsx +++ b/ui/src/pages/Users/ActiveEmail/index.tsx @@ -1,16 +1,17 @@ import { FC, memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { loggedUserInfoStore } from '@/stores'; -import { getQueryString } from '@/utils'; import { activateAccount } from '@/services'; import { PageTitle } from '@/components'; const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); + const [searchParams] = useSearchParams(); const updateUser = loggedUserInfoStore((state) => state.update); useEffect(() => { - const code = getQueryString('code'); + const code = searchParams.get('code'); if (code) { activateAccount(encodeURIComponent(code)).then((res) => { diff --git a/ui/src/pages/Users/Login/index.tsx b/ui/src/pages/Users/Login/index.tsx index 7a499e64..503b9723 100644 --- a/ui/src/pages/Users/Login/index.tsx +++ b/ui/src/pages/Users/Login/index.tsx @@ -1,6 +1,6 @@ import React, { FormEvent, useState, useEffect } from 'react'; import { Container, Form, Button, Col } from 'react-bootstrap'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import type { @@ -10,7 +10,7 @@ import type { } from '@/common/interface'; import { PageTitle, Unactivate } from '@/components'; import { loggedUserInfoStore } from '@/stores'; -import { getQueryString, guard, floppyNavigation } from '@/utils'; +import { guard, floppyNavigation } from '@/utils'; import { login, checkImgCode } from '@/services'; import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants'; import { RouteAlias } from '@/router/alias'; @@ -20,6 +20,7 @@ import Storage from '@/utils/storage'; const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'login' }); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [refresh, setRefresh] = useState(0); const updateUser = loggedUserInfoStore((state) => state.update); const storeUser = loggedUserInfoStore((state) => state.user); @@ -154,7 +155,7 @@ const Index: React.FC = () => { }, [refresh]); useEffect(() => { - const isInactive = getQueryString('status'); + const isInactive = searchParams.get('status'); if ((storeUser.id && storeUser.mail_status === 2) || isInactive) { setStep(2); diff --git a/ui/src/pages/Users/PasswordReset/index.tsx b/ui/src/pages/Users/PasswordReset/index.tsx index ab00de30..fc95731b 100644 --- a/ui/src/pages/Users/PasswordReset/index.tsx +++ b/ui/src/pages/Users/PasswordReset/index.tsx @@ -1,17 +1,16 @@ import React, { FormEvent, useState } from 'react'; import { Container, Col, Form, Button } from 'react-bootstrap'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { loggedUserInfoStore } from '@/stores'; -import { getQueryString } from '@/utils'; import type { FormDataType } from '@/common/interface'; import { replacementPassword } from '@/services'; import { PageTitle } from '@/components'; const Index: React.FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'password_reset' }); - + const [searchParams] = useSearchParams(); const [step, setStep] = useState(1); const clearUser = loggedUserInfoStore((state) => state.clear); const [formData, setFormData] = useState({ @@ -91,7 +90,7 @@ const Index: React.FC = () => { if (checkValidated() === false) { return; } - const code = getQueryString('code'); + const code = searchParams.get('code'); if (!code) { console.error('code is required'); return; diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts index 31bb0a40..16650e0c 100644 --- a/ui/src/utils/common.ts +++ b/ui/src/utils/common.ts @@ -1,12 +1,5 @@ import i18next from 'i18next'; -function getQueryString(name: string): string { - const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`); - const r = window.location.search.substr(1).match(reg); - if (r != null) return unescape(r[2]); - return ''; -} - function thousandthDivision(num) { const reg = /\d{1,3}(?=(\d{3})+$)/g; return `${num}`.replace(reg, '$&,'); @@ -87,7 +80,6 @@ function formatUptime(value) { } export { - getQueryString, thousandthDivision, formatCount, scrollTop, From ec0611d287d43a20fcfa4cda0cd2ced292f6481c Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 10 Nov 2022 11:13:51 +0800 Subject: [PATCH 03/43] refactor: update en_US.yaml --- i18n/en_US.yaml | 110 +++++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 53 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 9f2a06ab..fb71db24 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -220,7 +220,7 @@ ui: show_more: Show more suspended: title: Your Account has been Suspended - until_time: 'Your account was suspended until {{ time }}.' + until_time: "Your account was suspended until {{ time }}." forever: This user was suspended forever. end: You don't meet a community guideline. editor: @@ -405,12 +405,12 @@ ui: btn_cancel: Cancel dates: long_date: MMM D - long_date_with_year: 'MMM D, YYYY' - long_date_with_time: 'MMM D, YYYY [at] HH:mm' + long_date_with_year: "MMM D, YYYY" + long_date_with_time: "MMM D, YYYY [at] HH:mm" now: now - x_seconds_ago: '{{count}}s ago' - x_minutes_ago: '{{count}}m ago' - x_hours_ago: '{{count}}h ago' + x_seconds_ago: "{{count}}s ago" + x_minutes_ago: "{{count}}m ago" + x_hours_ago: "{{count}}h ago" hour: hour day: day comment: @@ -492,7 +492,7 @@ ui: add_btn: Add tag create_btn: Create new tag search_tag: Search tag - hint: 'Describe what your question is about, at least one tag is required.' + hint: "Describe what your question is about, at least one tag is required." no_result: No tags matched header: nav: @@ -521,7 +521,7 @@ ui: first: >- You're almost done! We sent an activation mail to {{mail}}. Please follow the instructions in the mail to activate your account. - info: 'If it doesn''t arrive, check your spam folder.' + info: "If it doesn't arrive, check your spam folder." another: >- We sent another activation email to you at {{mail}}. It might take a few minutes for it to arrive; be sure to check your spam folder. @@ -619,15 +619,15 @@ ui: label: About Me (optional) website: label: Website (optional) - placeholder: 'https://example.com' + placeholder: "https://example.com" msg: Website incorrect format location: label: Location (optional) - placeholder: 'City, Country' + placeholder: "City, Country" notification: email: label: Email Notifications - radio: 'Answers to your questions, comments, and more' + radio: "Answers to your questions, comments, and more" account: change_email_btn: Change email change_pass_btn: Change password @@ -717,7 +717,7 @@ ui: options: Options follow: Follow following: Following - counts: '{{count}} Results' + counts: "{{count}} Results" more: More sort_btns: relevance: Relevance @@ -726,12 +726,12 @@ ui: score: Score tips: title: Advanced Search Tips - tag: '<1>[tag] search withing a tag' - user: '<1>user:username search by author' - answer: '<1>answers:0 unanswered questions' - score: '<1>score:3 posts with a 3+ score' - question: '<1>is:question search questions' - is_answer: '<1>is:answer search answers' + tag: "<1>[tag] search withing a tag" + user: "<1>user:username search by author" + answer: "<1>answers:0 unanswered questions" + score: "<1>score:3 posts with a 3+ score" + question: "<1>is:question search questions" + is_answer: "<1>is:answer search answers" empty: We couldn't find anything.
Try different or less specific keywords. share: name: Share @@ -761,8 +761,8 @@ ui: follow_tag_tip: Follow tags to curate your list of questions. hot_questions: Hot Questions all_questions: All Questions - x_questions: '{{ count }} Questions' - x_answers: '{{ count }} answers' + x_questions: "{{ count }} Questions" + x_answers: "{{ count }} answers" questions: Questions answers: Answers newest: Newest @@ -789,12 +789,12 @@ ui: newest: Newest score: Score edit_profile: Edit Profile - visited_x_days: 'Visited {{ count }} days' + visited_x_days: "Visited {{ count }} days" viewed: Viewed joined: Joined last_login: Seen about_me: About Me - about_me_empty: '// Hello, World !' + about_me_empty: "// Hello, World !" top_answers: Top Answers top_questions: Top Questions stats: Stats @@ -829,7 +829,7 @@ ui: msg: Password cannot be empty. db_host: label: Database Host - placeholder: 'db:3306' + placeholder: "db:3306" msg: Database Host cannot be empty. db_name: label: Database Name @@ -845,7 +845,7 @@ ui: description: >- You can create the <1>config.yaml file manually in the <1>/var/wwww/xxx/ directory and paste the following text into it. - info: 'After you’ve done that, click “Next” button.' + info: "After you’ve done that, click “Next” button." site_information: Site Information admin_account: Admin Account site_name: @@ -882,7 +882,7 @@ ui: ready_description: >- If you ever feel like changing more settings, visit <1>admin section; find it in the site menu. - good_luck: 'Have fun, and good luck!' + good_luck: "Have fun, and good luck!" warn_title: Warning warn_description: >- The file <1>config.yaml already exists. If you need to reset any of the @@ -893,13 +893,13 @@ ui: You appear to have already installed. To reinstall please clear your old database tables first. page_404: - description: 'Unfortunately, this page doesn''t exist.' + description: "Unfortunately, this page doesn't exist." back_home: Back to homepage page_50X: description: The server encountered an error and could not complete your request. back_home: Back to homepage page_maintenance: - description: 'We are under maintenance, we’ll be back soon.' + description: "We are under maintenance, we’ll be back soon." admin: admin_header: title: Admin @@ -918,21 +918,21 @@ ui: title: Dashboard welcome: Welcome to Answer Admin! site_statistics: Site Statistics - questions: 'Questions:' - answers: 'Answers:' - comments: 'Comments:' - votes: 'Votes:' - active_users: 'Active users:' - flags: 'Flags:' + questions: "Questions:" + answers: "Answers:" + comments: "Comments:" + votes: "Votes:" + active_users: "Active users:" + flags: "Flags:" site_health_status: Site Health Status - version: 'Version:' - https: 'HTTPS:' - uploading_files: 'Uploading files:' - smtp: 'SMTP:' - timezone: 'Timezone:' + version: "Version:" + https: "HTTPS:" + uploading_files: "Uploading files:" + smtp: "SMTP:" + timezone: "Timezone:" system_info: System Info - storage_used: 'Storage used:' - uptime: 'Uptime:' + storage_used: "Storage used:" + uptime: "Uptime:" answer_links: Answer Links documents: Documents feedback: Feedback @@ -941,8 +941,8 @@ ui: update_to: Update to latest: Latest check_failed: Check failed - 'yes': 'Yes' - 'no': 'No' + "yes": "Yes" + "no": "No" not_allowed: Not allowed allowed: Allowed enabled: Enabled @@ -964,7 +964,7 @@ ui: suspended_name: suspended suspended_description: A suspended user can't log in. deleted_name: deleted - deleted_description: 'Delete profile, authentication associations.' + deleted_description: "Delete profile, authentication associations." inactive_name: inactive inactive_description: An inactive user must re-validate their email. confirm_title: Delete this user @@ -973,11 +973,11 @@ ui: msg: empty: Please select a reason. status_modal: - title: 'Change {{ type }} status to...' + title: "Change {{ type }} status to..." normal_name: normal normal_description: A normal post available to everyone. closed_name: closed - closed_description: 'A closed question can''t answer, but still can edit, vote and comment.' + closed_description: "A closed question can't answer, but still can edit, vote and comment." deleted_name: deleted deleted_description: All reputation gained and lost will be restored. btn_cancel: Cancel @@ -1000,7 +1000,7 @@ ui: deleted: Deleted normal: Normal filter: - placeholder: 'Filter by name, user:id' + placeholder: "Filter by name, user:id" questions: page_title: Questions normal: Normal @@ -1014,7 +1014,7 @@ ui: action: Action change: Change filter: - placeholder: 'Filter by title, question:id' + placeholder: "Filter by title, question:id" answers: page_title: Answers normal: Normal @@ -1026,13 +1026,13 @@ ui: action: Action change: Change filter: - placeholder: 'Filter by title, answer:id' + placeholder: "Filter by title, answer:id" general: page_title: General name: label: Site Name msg: Site name cannot be empty. - text: 'The name of this site, as used in the title tag.' + text: "The name of this site, as used in the title tag." site_url: label: Site URL msg: Site url cannot be empty. @@ -1041,11 +1041,11 @@ ui: short_description: label: Short Site Description (optional) msg: Short site description cannot be empty. - text: 'Short description, as used in the title tag on homepage.' + text: "Short description, as used in the title tag on homepage." description: label: Site Description (optional) msg: Site description cannot be empty. - text: 'Describe this site in one sentence, as used in the meta description tag.' + text: "Describe this site in one sentence, as used in the meta description tag." contact_email: label: Contact Email msg: Contact email cannot be empty. @@ -1106,5 +1106,9 @@ ui: smtp_authentication: label: SMTP Authentication msg: SMTP authentication cannot be empty. - 'yes': 'Yes' - 'no': 'No' + "yes": "Yes" + "no": "No" + form: + empty: cannot be empty + invalid: is invalid + btn_submit: Save From 26f606e285a098464489e7fe474300afad530eef Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 10 Nov 2022 11:14:43 +0800 Subject: [PATCH 04/43] feat(ui): add json schema form --- ui/src/components/SchemaForm/index.tsx | 265 +++++++++++++++++++++++++ ui/src/components/index.ts | 5 +- 2 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/SchemaForm/index.tsx diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx new file mode 100644 index 00000000..4c7a01d0 --- /dev/null +++ b/ui/src/components/SchemaForm/index.tsx @@ -0,0 +1,265 @@ +import { FC } from 'react'; +import { Form, Button, Stack } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import type * as Type from '@/common/interface'; + +export interface JSONSchema { + title: string; + description?: string; + required?: string[]; + properties: { + [key: string]: { + type: 'string' | 'boolean'; + title: string; + description?: string; + enum?: Array; + enumNames?: string[]; + default?: string | boolean; + }; + }; +} +export interface UISchema { + [key: string]: { + 'ui:widget'?: + | 'textarea' + | 'text' + | 'checkbox' + | 'radio' + | 'select' + | 'switch'; + 'ui:options'?: { + rows?: number; + placeholder?: string; + type?: + | 'color' + | 'date' + | 'datetime-local' + | 'email' + | 'month' + | 'number' + | 'password' + | 'range' + | 'search' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week'; + empty?: string; + invalid?: string; + validator?: (value) => boolean; + }; + }; +} + +interface IProps { + schema: JSONSchema; + uiSchema?: UISchema; + formData?: Type.FormDataType; + onChange?: (data: Type.FormDataType) => void; + onSubmit: (e: React.FormEvent) => void; +} + +const SchemaForm: FC = ({ + schema, + uiSchema = {}, + formData = {}, + onChange, + onSubmit, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'form', + }); + const { properties } = schema; + const keys = Object.keys(properties); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const data = { ...formData, [name]: { ...formData[name], value } }; + if (onChange instanceof Function) { + onChange(data); + } + }; + const requiredValidator = () => { + const required = schema.required || []; + const errors: string[] = []; + required.forEach((key) => { + if (!formData[key] || !formData[key].value) { + errors.push(key); + } + }); + return errors; + }; + const syncValidator = () => { + const errors: string[] = []; + keys.forEach((key) => { + const { validator } = uiSchema[key]?.['ui:options'] || {}; + if (validator instanceof Function) { + const value = formData[key]?.value; + if (!validator(value)) { + errors.push(key); + } + } + }); + return errors; + }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const errors = requiredValidator(); + if (errors.length > 0) { + formData = errors.reduce((acc, cur) => { + acc[cur] = { + ...formData[cur], + isInvalid: true, + errorMsg: + uiSchema[cur]['ui:options']?.empty || + `${schema.properties[cur].title} ${t('form.empty')}`, + }; + return acc; + }, formData); + if (onChange instanceof Function) { + onChange(formData); + } + return; + } + const syncErrors = syncValidator(); + if (syncErrors.length > 0) { + formData = syncErrors.reduce((acc, cur) => { + acc[cur] = { + ...formData[cur], + isInvalid: true, + errorMsg: + uiSchema[cur]['ui:options']?.invalid || + `${schema.properties[cur].title} ${t('form.invalid')}`, + }; + return acc; + }, formData); + if (onChange instanceof Function) { + onChange(formData); + } + return; + } + Object.keys(formData).forEach((key) => { + formData[key].isInvalid = false; + formData[key].errorMsg = ''; + }); + if (onChange instanceof Function) { + onChange(formData); + } + onSubmit(e); + }; + return ( +
+ {keys.map((key) => { + const { title, description } = properties[key]; + const { 'ui:widget': widget = 'input', 'ui:options': options = {} } = + uiSchema[key] || {}; + if (widget === 'select') { + return ( + + {title} + + {properties[key].enum?.map((item, index) => { + return ( + + ); + })} + + + {formData[key]?.errorMsg} + + {description} + + ); + } + if (widget === 'checkbox' || widget === 'radio') { + return ( + + {title} + + {properties[key].enum?.map((item, index) => { + return ( + + ); + })} + + {description} + + ); + } + + if (widget === 'switch') { + return ( + + {title} + + {description} + + ); + } + + const as = widget === 'textarea' ? 'textarea' : 'input'; + return ( + + {title} + + + {formData[key]?.errorMsg} + + + {description} + + ); + })} + +
+ ); +}; +export const initFormData = (schema: JSONSchema): Type.FormDataType => { + const formData: Type.FormDataType = {}; + Object.keys(schema.properties).forEach((key) => { + formData[key] = { + value: '', + isInvalid: false, + errorMsg: '', + }; + }); + return formData; +}; + +export default SchemaForm; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 25bdefa7..4ff5e71c 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -25,6 +25,7 @@ import Empty from './Empty'; import BaseUserCard from './BaseUserCard'; import FollowingTags from './FollowingTags'; import QueryGroup from './QueryGroup'; +import SchemaForm, { JSONSchema, UISchema, initFormData } from './SchemaForm'; export { Avatar, @@ -55,5 +56,7 @@ export { FollowingTags, htmlRender, QueryGroup, + SchemaForm, + initFormData, }; -export type { EditorRef }; +export type { EditorRef, JSONSchema, UISchema }; From c9c13a1331b1015c51b4a30543f6156f4c4a0243 Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 10 Nov 2022 11:15:34 +0800 Subject: [PATCH 05/43] refactor(ui): use schemaForm --- ui/src/pages/Admin/General/index.tsx | 235 ++++++------------ ui/src/pages/Admin/Smtp/index.tsx | 341 ++++++++------------------- 2 files changed, 166 insertions(+), 410 deletions(-) diff --git a/ui/src/pages/Admin/General/index.tsx b/ui/src/pages/Admin/General/index.tsx index 2ea2f485..41ec1a14 100644 --- a/ui/src/pages/Admin/General/index.tsx +++ b/ui/src/pages/Admin/General/index.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useState } from 'react'; -import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; import type * as Type from '@/common/interface'; import { useToast } from '@/hooks'; import { siteInfoStore } from '@/stores'; @@ -17,91 +17,70 @@ const General: FC = () => { const updateSiteInfo = siteInfoStore((state) => state.update); const { data: setting } = useGeneralSetting(); - const [formData, setFormData] = useState({ - name: { - value: '', - isInvalid: false, - errorMsg: '', + const schema: JSONSchema = { + title: t('title'), + description: t('description'), + required: ['name', 'site_url', 'contact_email'], + properties: { + name: { + type: 'string', + title: t('name.label'), + description: t('name.text'), + }, + site_url: { + type: 'string', + title: t('site_url.label'), + description: t('site_url.text'), + }, + short_description: { + type: 'string', + title: t('short_description.label'), + description: t('short_description.text'), + }, + description: { + type: 'string', + title: t('description.label'), + description: t('description.text'), + }, + contact_email: { + type: 'string', + title: t('contact_email.label'), + description: t('contact_email.text'), + }, }, + }; + const uiSchema: UISchema = { site_url: { - value: '', - isInvalid: false, - errorMsg: '', - }, - short_description: { - value: '', - isInvalid: false, - errorMsg: '', - }, - description: { - value: '', - isInvalid: false, - errorMsg: '', + 'ui:options': { + invalid: t('site_url.validate'), + validator: (value) => { + if (!/^(https?):\/\/([\w.]+\/?)\S*$/.test(value)) { + return false; + } + return true; + }, + }, }, contact_email: { - value: '', - isInvalid: false, - errorMsg: '', + 'ui:options': { + invalid: t('contact_email.validate'), + validator: (value) => { + if ( + !/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(value) + ) { + return false; + } + return true; + }, + }, }, - }); - const checkValidated = (): boolean => { - let ret = true; - const { name, site_url, contact_email } = formData; - if (!name.value) { - ret = false; - formData.name = { - value: '', - isInvalid: true, - errorMsg: t('name.msg'), - }; - } - if (!site_url.value) { - ret = false; - formData.site_url = { - value: '', - isInvalid: true, - errorMsg: t('site_url.msg'), - }; - } else if (!/^(https?):\/\/([\w.]+\/?)\S*$/.test(site_url.value)) { - ret = false; - formData.site_url = { - value: formData.site_url.value, - isInvalid: true, - errorMsg: t('site_url.validate'), - }; - } - - if (!contact_email.value) { - ret = false; - formData.contact_email = { - value: '', - isInvalid: true, - errorMsg: t('contact_email.msg'), - }; - } else if ( - !/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test( - contact_email.value, - ) - ) { - ret = false; - formData.contact_email = { - value: formData.contact_email.value, - isInvalid: true, - errorMsg: t('contact_email.validate'), - }; - } - setFormData({ - ...formData, - }); - return ret; }; + const [formData, setFormData] = useState(initFormData(schema)); const onSubmit = (evt) => { evt.preventDefault(); evt.stopPropagation(); - if (checkValidated() === false) { - return; - } + const reqParams: Type.AdminSettingsGeneral = { name: formData.name.value, description: formData.description.value, @@ -126,19 +105,7 @@ const General: FC = () => { setFormData({ ...formData }); }); }; - const onFieldChange = (fieldName, fieldValue) => { - if (!formData[fieldName]) { - return; - } - const fieldData: Type.FormDataType = { - [fieldName]: { - value: fieldValue, - isInvalid: false, - errorMsg: '', - }, - }; - setFormData({ ...formData, ...fieldData }); - }; + useEffect(() => { if (!setting) { return; @@ -149,87 +116,21 @@ const General: FC = () => { }); setFormData({ ...formData, ...formMeta }); }, [setting]); + + const handleOnChange = (data) => { + setFormData(data); + }; + return ( <>

{t('page_title')}

-
- - {t('name.label')} - onFieldChange('name', evt.target.value)} - /> - {t('name.text')} - - {formData.name.errorMsg} - - - - {t('site_url.label')} - onFieldChange('site_url', evt.target.value)} - /> - {t('site_url.text')} - - {formData.site_url.errorMsg} - - - - {t('short_description.label')} - - onFieldChange('short_description', evt.target.value) - } - /> - {t('short_description.text')} - - {formData.short_description.errorMsg} - - - - {t('description.label')} - onFieldChange('description', evt.target.value)} - /> - {t('description.text')} - - {formData.description.errorMsg} - - - - {t('contact_email.label')} - onFieldChange('contact_email', evt.target.value)} - /> - {t('contact_email.text')} - - {formData.contact_email.errorMsg} - - - - -
+ ); }; diff --git a/ui/src/pages/Admin/Smtp/index.tsx b/ui/src/pages/Admin/Smtp/index.tsx index 33de30ab..0c3892bd 100644 --- a/ui/src/pages/Admin/Smtp/index.tsx +++ b/ui/src/pages/Admin/Smtp/index.tsx @@ -1,11 +1,12 @@ import React, { FC, useEffect, useState } from 'react'; -import { Form, Button, Stack } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import type * as Type from '@/common/interface'; import { useToast } from '@/hooks'; import { useSmtpSetting, updateSmtpSetting } from '@/services'; import pattern from '@/common/pattern'; +import { SchemaForm, JSONSchema, UISchema } from '@/components'; +import { initFormData } from '../../../components/SchemaForm/index'; const Smtp: FC = () => { const { t } = useTranslation('translation', { @@ -13,90 +14,103 @@ const Smtp: FC = () => { }); const Toast = useToast(); const { data: setting } = useSmtpSetting(); - const [formData, setFormData] = useState({ - from_email: { - value: '', - isInvalid: false, - errorMsg: '', - }, - from_name: { - value: '', - isInvalid: false, - errorMsg: '', - }, - smtp_host: { - value: '', - isInvalid: false, - errorMsg: '', + const schema: JSONSchema = { + title: t('title'), + description: t('description'), + properties: { + from_email: { + type: 'string', + title: t('from_email.label'), + description: t('from_email.text'), + }, + from_name: { + type: 'string', + title: t('from_name.label'), + description: t('from_name.text'), + }, + smtp_host: { + type: 'string', + title: t('smtp_host.label'), + description: t('smtp_host.text'), + }, + encryption: { + type: 'boolean', + title: t('encryption.label'), + description: t('encryption.text'), + enum: [true, false], + enumNames: ['SSL', ''], + }, + smtp_port: { + type: 'string', + title: t('smtp_port.label'), + description: t('smtp_port.text'), + }, + smtp_authentication: { + type: 'boolean', + title: t('smtp_authentication.label'), + enum: [true, false], + enumNames: [t('smtp_authentication.yes'), t('smtp_authentication.no')], + }, + smtp_username: { + type: 'string', + title: t('smtp_username.label'), + description: t('smtp_username.text'), + }, + smtp_password: { + type: 'string', + title: t('smtp_password.label'), + description: t('smtp_password.text'), + }, + test_email_recipient: { + type: 'string', + title: t('test_email_recipient.label'), + description: t('test_email_recipient.text'), + }, }, + }; + const uiSchema: UISchema = { encryption: { - value: '', - isInvalid: false, - errorMsg: '', - }, - smtp_port: { - value: '', - isInvalid: false, - errorMsg: '', - }, - smtp_authentication: { - value: 'yes', - isInvalid: false, - errorMsg: '', - }, - smtp_username: { - value: '', - isInvalid: false, - errorMsg: '', + 'ui:widget': 'radio', }, smtp_password: { - value: '', - isInvalid: false, - errorMsg: '', + 'ui:options': { + type: 'password', + }, + }, + smtp_authentication: { + 'ui:widget': 'radio', + }, + smtp_port: { + 'ui:options': { + invalid: t('smtp_port.msg'), + validator: (value) => { + if (!/^[1-9][0-9]*$/.test(value) || Number(value) > 65535) { + return false; + } + return true; + }, + }, }, test_email_recipient: { - value: '', - isInvalid: false, - errorMsg: '', + 'ui:options': { + invalid: t('test_email_recipient.msg'), + validator: (value) => { + if (value && !pattern.email.test(value)) { + return false; + } + return true; + }, + }, }, - }); - const checkValidated = (): boolean => { - let ret = true; - const { smtp_port, test_email_recipient } = formData; - if ( - !/^[1-9][0-9]*$/.test(smtp_port.value) || - Number(smtp_port.value) > 65535 - ) { - ret = false; - formData.smtp_port = { - value: smtp_port.value, - isInvalid: true, - errorMsg: t('smtp_port.msg'), - }; - } - if ( - test_email_recipient.value && - !pattern.email.test(test_email_recipient.value) - ) { - ret = false; - formData.test_email_recipient = { - value: test_email_recipient.value, - isInvalid: true, - errorMsg: t('test_email_recipient.msg'), - }; - } - setFormData({ - ...formData, - }); - return ret; }; + const [formData, setFormData] = useState( + initFormData(schema), + ); const onSubmit = (evt) => { evt.preventDefault(); evt.stopPropagation(); - if (!checkValidated()) { - return; - } + const reqParams: Type.AdminSettingsSmtp = { from_email: formData.from_email.value, from_name: formData.from_name.value, @@ -124,19 +138,7 @@ const Smtp: FC = () => { setFormData({ ...formData }); }); }; - const onFieldChange = (fieldName, fieldValue) => { - if (!formData[fieldName]) { - return; - } - const fieldData: Type.FormDataType = { - [fieldName]: { - value: fieldValue, - isInvalid: false, - errorMsg: '', - }, - }; - setFormData({ ...formData, ...fieldData }); - }; + useEffect(() => { if (!setting) { return; @@ -152,166 +154,19 @@ const Smtp: FC = () => { setFormData(formState); }, [setting]); + const handleOnChange = (data) => { + setFormData(data); + }; return ( <>

{t('page_title')}

-
- - {t('from_email.label')} - onFieldChange('from_email', evt.target.value)} - /> - {t('from_email.text')} - - {formData.from_email.errorMsg} - - - - {t('from_name.label')} - onFieldChange('from_name', evt.target.value)} - /> - {t('from_name.text')} - - {formData.from_name.errorMsg} - - - - {t('smtp_host.label')} - onFieldChange('smtp_host', evt.target.value)} - /> - {t('smtp_host.text')} - - {formData.smtp_host.errorMsg} - - - - {t('encryption.label')} - - onFieldChange('encryption', 'SSL')} - type="radio" - /> - onFieldChange('encryption', '')} - type="radio" - /> - - {t('encryption.text')} - - {formData.encryption.errorMsg} - - - - {t('smtp_port.label')} - onFieldChange('smtp_port', evt.target.value)} - /> - {t('smtp_port.text')} - - {formData.smtp_port.errorMsg} - - - - {t('smtp_authentication.label')} - - onFieldChange('smtp_authentication', true)} - type="radio" - /> - onFieldChange('smtp_authentication', false)} - type="radio" - /> - - - {formData.smtp_authentication.errorMsg} - - - - {t('smtp_username.label')} - onFieldChange('smtp_username', evt.target.value)} - /> - - {formData.smtp_username.errorMsg} - - - - {t('smtp_password.label')} - onFieldChange('smtp_password', evt.target.value)} - /> - - {formData.smtp_password.errorMsg} - - - - {t('test_email_recipient.label')} - - onFieldChange('test_email_recipient', evt.target.value) - } - /> - {t('test_email_recipient.text')} - - {formData.test_email_recipient.errorMsg} - - - - -
+ ); }; From acd263a9db3fba1c183dc9a4f5976ad43b04df22 Mon Sep 17 00:00:00 2001 From: shuai Date: Thu, 10 Nov 2022 12:19:34 +0800 Subject: [PATCH 06/43] feat: add brand upload component --- i18n/en_US.yaml | 3 ++ ui/src/common/constants.ts | 7 +++- ui/src/components/BrandUpload/index.tsx | 39 +++++++++++++++++++ ui/src/components/UploadImg/index.tsx | 8 ++-- ui/src/components/index.ts | 2 + ui/src/i18n/locales/en_US.yaml | 3 ++ ui/src/index.scss | 10 +++++ ui/src/pages/Admin/Branding/index.tsx | 37 ++++++++++++++++++ ui/src/pages/Admin/Interface/index.tsx | 4 +- ui/src/pages/Admin/index.tsx | 7 +++- ui/src/pages/Users/Settings/Profile/index.tsx | 6 ++- ui/src/router/routes.ts | 4 ++ 12 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 ui/src/components/BrandUpload/index.tsx create mode 100644 ui/src/pages/Admin/Branding/index.tsx diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index fa6a1de3..0ade02a1 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -915,6 +915,7 @@ ui: general: General interface: Interface smtp: SMTP + branding: Branding dashboard: title: Dashboard welcome: Welcome to Answer Admin! @@ -1109,3 +1110,5 @@ ui: msg: SMTP authentication cannot be empty. 'yes': 'Yes' 'no': 'No' + branding: + page_title: Branding diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 1bf1f355..3ed5e37c 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -54,7 +54,12 @@ export const ADMIN_NAV_MENUS = [ }, { name: 'settings', - child: [{ name: 'general' }, { name: 'interface' }, { name: 'smtp' }], + child: [ + { name: 'general' }, + { name: 'interface' }, + { name: 'branding' }, + { name: 'smtp' }, + ], }, ]; diff --git a/ui/src/components/BrandUpload/index.tsx b/ui/src/components/BrandUpload/index.tsx new file mode 100644 index 00000000..477efea0 --- /dev/null +++ b/ui/src/components/BrandUpload/index.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react'; +import { ButtonGroup, Button } from 'react-bootstrap'; + +import { Icon, UploadImg } from '@/components'; + +interface Props { + type: string; + imgPath: string; + uploadCallback: (data: FormData) => Promise; + deleteCallback: (type: string) => void; +} + +const Index: FC = ({ + type, + imgPath, + uploadCallback, + deleteCallback, +}) => { + return ( +
+
+ +
+ + + + + + + +
+ ); +}; + +export default Index; diff --git a/ui/src/components/UploadImg/index.tsx b/ui/src/components/UploadImg/index.tsx index 3054fac4..90cca3e4 100644 --- a/ui/src/components/UploadImg/index.tsx +++ b/ui/src/components/UploadImg/index.tsx @@ -3,10 +3,12 @@ import { useTranslation } from 'react-i18next'; interface IProps { type: string; + className?: string; + children?: React.ReactNode; upload: (data: FormData) => Promise; } -const Index: React.FC = ({ type, upload }) => { +const Index: React.FC = ({ type, upload, children, className }) => { const { t } = useTranslation(); const [status, setStatus] = useState(false); @@ -35,8 +37,8 @@ const Index: React.FC = ({ type, upload }) => { }; return ( -