1- import React , { useEffect } from 'react' ;
1+ import React , { useEffect , useRef , useState } from 'react' ;
22import { useForm } from 'react-hook-form' ;
33import toast from 'react-hot-toast' ;
44
55import useTranslation from '@/hooks/useTranslation' ;
66
7+ import { IconInfo } from '@/components/Icons' ;
8+ import Tips from '@/components/Tips/Tips' ;
9+
710import {
811 GetFileServicesResult ,
912 PostFileServicesParams ,
@@ -18,18 +21,26 @@ import {
1821 DialogHeader ,
1922 DialogTitle ,
2023} from '@/components/ui/dialog' ;
21- import { Form , FormField } from '@/components/ui/form' ;
24+ import {
25+ Form ,
26+ FormControl ,
27+ FormField ,
28+ FormItem ,
29+ FormLabel ,
30+ FormMessage ,
31+ } from '@/components/ui/form' ;
2232import FormInput from '@/components/ui/form/input' ;
2333import FormSelect from '@/components/ui/form/select' ;
2434import FormSwitch from '@/components/ui/form/switch' ;
25- import FormTextarea from '@/components/ui/form/textarea' ;
2635import { FormFieldType , IFormFieldOption } from '@/components/ui/form/type' ;
36+ import { Textarea } from '@/components/ui/textarea' ;
2737
2838import {
2939 deleteFileService ,
3040 getFileServiceTypeInitialConfig ,
3141 postFileService ,
3242 putFileService ,
43+ validateFileService ,
3344} from '@/apis/adminApis' ;
3445import { zodResolver } from '@hookform/resolvers/zod' ;
3546import { z } from 'zod' ;
@@ -45,6 +56,54 @@ interface IProps {
4556const FileServiceModal = ( props : IProps ) => {
4657 const { t } = useTranslation ( ) ;
4758 const { selected, isOpen, onClose, onSuccessful } = props ;
59+ const [ validating , setValidating ] = useState ( false ) ;
60+ const [ validateError , setValidateError ] = useState < string | null > ( null ) ;
61+
62+ const [ isEditingConfigs , setIsEditingConfigs ] = useState ( false ) ;
63+ const configsTextareaRef = useRef < HTMLTextAreaElement > ( null ) ;
64+
65+ const toMaskedNull = ( value : unknown ) => {
66+ if ( value === null || value === undefined ) return value ;
67+ if ( typeof value !== 'string' ) return value ;
68+ return value . length > 7 ? value . slice ( 0 , 5 ) + '****' + value . slice ( - 2 ) : value ;
69+ } ;
70+
71+ const maskJsonValues = ( input : string ) => {
72+ try {
73+ const parsed = JSON . parse ( input ) ;
74+ const maskDeep = ( node : any ) : any => {
75+ if ( node === null || node === undefined ) return node ;
76+ if ( typeof node === 'string' ) return toMaskedNull ( node ) ;
77+ if ( Array . isArray ( node ) ) return node . map ( maskDeep ) ;
78+ if ( typeof node === 'object' ) {
79+ const out : Record < string , any > = { } ;
80+ for ( const [ k , v ] of Object . entries ( node ) ) out [ k ] = maskDeep ( v ) ;
81+ return out ;
82+ }
83+ return node ;
84+ } ;
85+
86+ return JSON . stringify ( maskDeep ( parsed ) , null , 2 ) ;
87+ } catch {
88+ return input ;
89+ }
90+ } ;
91+
92+ const getErrorMessage = ( err : any ) => {
93+ if ( ! err ) return '' ;
94+ if ( typeof err === 'string' ) return err ;
95+ if ( err instanceof Error ) return err . message ;
96+ if ( typeof err === 'object' ) {
97+ const msg = ( err as any ) . message || ( err as any ) . errMessage ;
98+ if ( typeof msg === 'string' ) return msg ;
99+ try {
100+ return JSON . stringify ( err ) ;
101+ } catch {
102+ return String ( err ) ;
103+ }
104+ }
105+ return String ( err ) ;
106+ } ;
48107 const formFields : IFormFieldOption [ ] = [
49108 {
50109 name : 'fileServiceTypeId' ,
@@ -81,9 +140,54 @@ const FileServiceModal = (props: IProps) => {
81140 name : 'configs' ,
82141 label : t ( 'Service Configs' ) ,
83142 defaultValue : '' ,
84- render : ( options : IFormFieldOption , field : FormFieldType ) => (
85- < FormTextarea rows = { 6 } options = { options } field = { field } />
86- ) ,
143+ render : ( options : IFormFieldOption , field : FormFieldType ) => {
144+ const { ref : rhfRef , ...fieldRest } = field ;
145+ const v = typeof field . value === 'string' ? field . value : '' ;
146+ const displayValue = maskJsonValues ( v ) ;
147+
148+ return (
149+ < FormItem className = "py-1" >
150+ < FormLabel > { options . label } </ FormLabel >
151+ < FormControl >
152+ { isEditingConfigs ? (
153+ < Textarea
154+ rows = { 6 }
155+ placeholder = { options ?. placeholder }
156+ className = "font-mono"
157+ { ...fieldRest }
158+ ref = { ( el ) => {
159+ configsTextareaRef . current = el ;
160+ if ( typeof rhfRef === 'function' ) rhfRef ( el ) ;
161+ else if ( rhfRef ) ( rhfRef as any ) . current = el ;
162+ } }
163+ onBlur = { ( e ) => {
164+ field . onBlur ( ) ;
165+ setIsEditingConfigs ( false ) ;
166+ } }
167+ />
168+ ) : (
169+ < div
170+ className = "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm cursor-text"
171+ style = { {
172+ whiteSpace : 'pre-wrap' ,
173+ wordBreak : 'break-word' ,
174+ } }
175+ onClick = { ( ) => setIsEditingConfigs ( true ) }
176+ >
177+ { displayValue ? (
178+ displayValue
179+ ) : (
180+ < span className = "text-muted-foreground" >
181+ { options ?. placeholder || '' }
182+ </ span >
183+ ) }
184+ </ div >
185+ ) }
186+ </ FormControl >
187+ < FormMessage />
188+ </ FormItem >
189+ ) ;
190+ } ,
87191 } ,
88192 ] ;
89193
@@ -108,16 +212,12 @@ const FileServiceModal = (props: IProps) => {
108212 onSuccessful ( ) ;
109213 toast . success ( t ( 'Deleted successful' ) ) ;
110214 } catch ( err : any ) {
111- try {
112- const resp = await err . json ( ) ;
113- toast . error ( t ( resp . message ) ) ;
114- } catch {
115- toast . error (
116- t (
117- 'Operation failed, Please try again later, or contact technical personnel' ,
118- ) ,
119- ) ;
120- }
215+ const msg = getErrorMessage ( err ) ;
216+ toast . error (
217+ msg
218+ ? msg
219+ : t ( 'Operation failed, Please try again later, or contact technical personnel' ) ,
220+ ) ;
121221 }
122222 }
123223
@@ -141,6 +241,30 @@ const FileServiceModal = (props: IProps) => {
141241 } ) ;
142242 }
143243
244+ async function onValidate ( ) {
245+ try {
246+ setValidating ( true ) ;
247+ setValidateError ( null ) ;
248+ const ok = await form . trigger ( ) ;
249+ if ( ! ok ) return ;
250+
251+ const values = form . getValues ( ) ;
252+ await validateFileService ( {
253+ fileServiceTypeId : parseInt ( values . fileServiceTypeId ! ) ,
254+ name : values . name ! ,
255+ isDefault : values . isDefault ,
256+ configs : values . configs ! ,
257+ } ) ;
258+ setValidateError ( null ) ;
259+ toast . success ( t ( 'Verified Successfully' ) ) ;
260+ } catch ( err : any ) {
261+ const msg = getErrorMessage ( err ) ;
262+ setValidateError ( msg || t ( 'Verified Failed' ) ) ;
263+ } finally {
264+ setValidating ( false ) ;
265+ }
266+ }
267+
144268 const formatConfigs = ( config : string ) => {
145269 try {
146270 const parsed = JSON . parse ( config ) ;
@@ -154,6 +278,8 @@ const FileServiceModal = (props: IProps) => {
154278 if ( isOpen ) {
155279 form . reset ( ) ;
156280 form . formState . isValid ;
281+ setValidateError ( null ) ;
282+ setIsEditingConfigs ( false ) ;
157283 if ( selected ) {
158284 form . setValue ( 'name' , selected . name ) ;
159285 form . setValue (
@@ -166,6 +292,13 @@ const FileServiceModal = (props: IProps) => {
166292 }
167293 } , [ isOpen ] ) ;
168294
295+ useEffect ( ( ) => {
296+ if ( isEditingConfigs ) {
297+ // next tick focus
298+ setTimeout ( ( ) => configsTextareaRef . current ?. focus ( ) , 0 ) ;
299+ }
300+ } , [ isEditingConfigs ] ) ;
301+
169302 useEffect ( ( ) => {
170303 const subscription = form . watch ( ( value , { name, type } ) => {
171304 if ( name === 'fileServiceTypeId' && type === 'change' ) {
@@ -197,13 +330,49 @@ const FileServiceModal = (props: IProps) => {
197330 render = { ( { field } ) => item . render ( item , field ) }
198331 />
199332 ) ) }
200- < DialogFooter className = "pt-4" >
201- { selected && (
333+ < DialogFooter className = "pt-4 sm:justify-between " >
334+ { selected ? (
202335 < Button type = "button" variant = "destructive" onClick = { onDelete } >
203336 { t ( 'Delete' ) }
204337 </ Button >
338+ ) : (
339+ < div />
205340 ) }
206- < Button type = "submit" > { t ( 'Save' ) } </ Button >
341+ < div className = "flex gap-2 justify-end" >
342+ < div className = "flex items-center gap-1" >
343+ < Button
344+ type = "button"
345+ variant = "secondary"
346+ onClick = { onValidate }
347+ disabled = { validating }
348+ >
349+ { validating ? t ( 'Validating...' ) : t ( 'Validate' ) }
350+ </ Button >
351+
352+ { validateError && (
353+ < Tips
354+ className = "h-8"
355+ side = "bottom"
356+ trigger = {
357+ < Button
358+ variant = "ghost"
359+ className = "p-0.5 m-0 h-8 w-8"
360+ >
361+ < IconInfo stroke = "#FFD738" size = { 16 } />
362+ </ Button >
363+ }
364+ content = {
365+ < div className = "w-80" >
366+ < div className = "font-mono text-xs whitespace-pre-wrap break-all" >
367+ { validateError }
368+ </ div >
369+ </ div >
370+ }
371+ />
372+ ) }
373+ </ div >
374+ < Button type = "submit" > { t ( 'Save' ) } </ Button >
375+ </ div >
207376 </ DialogFooter >
208377 </ form >
209378 </ Form >
0 commit comments