Skip to content

Commit c0ed21f

Browse files
committed
Optimize file service design/user exp, add validate button & frontend mask
1 parent 8275da0 commit c0ed21f

4 files changed

Lines changed: 261 additions & 25 deletions

File tree

src/BE/web/Controllers/Admin/FileServices/FileServiceController.cs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,68 @@
22
using Chats.DB.Enums;
33
using Chats.BE.Controllers.Admin.Common;
44
using Chats.BE.Controllers.Admin.FileServices.Dtos;
5+
using Chats.BE.Services.FileServices;
56
using Microsoft.AspNetCore.Mvc;
67
using Microsoft.EntityFrameworkCore;
8+
using System.Text;
79

810
namespace Chats.BE.Controllers.Admin.FileServices;
911

1012
[Route("api/admin/file-service"), AuthorizeAdmin]
11-
public class FileServiceController(ChatsDB db) : ControllerBase
13+
public class FileServiceController(ChatsDB db, IFileServiceFactory fileServiceFactory) : ControllerBase
1214
{
15+
[HttpPost("validate")]
16+
public async Task<IActionResult> ValidateFileService(
17+
[FromBody] FileServiceUpdateRequest req,
18+
[FromServices] ILogger<FileServiceController> logger,
19+
CancellationToken cancellationToken)
20+
{
21+
try
22+
{
23+
// Requirement: put (upload) an object using the configured file service, then try delete it.
24+
// Upload success => validate success. Delete result is not important.
25+
FileService temp = new()
26+
{
27+
// not persisted, only used to create service implementation
28+
FileServiceTypeId = (byte)req.FileServiceTypeId,
29+
Name = req.Name,
30+
Configs = req.Configs,
31+
IsDefault = false,
32+
CreatedAt = DateTime.UtcNow,
33+
UpdatedAt = DateTime.UtcNow,
34+
};
35+
36+
IFileService fileService = fileServiceFactory.Create(temp);
37+
38+
string now = DateTime.UtcNow.ToString("O");
39+
byte[] bytes = Encoding.UTF8.GetBytes($"validate at {now}");
40+
await using MemoryStream ms = new(bytes);
41+
string fileName = $"file-service-validate-{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}.txt";
42+
string storageKey = await fileService.Upload(new FileUploadRequest
43+
{
44+
FileName = fileName,
45+
ContentType = "text/plain",
46+
Stream = ms,
47+
}, cancellationToken);
48+
49+
try
50+
{
51+
_ = await fileService.Delete(storageKey, cancellationToken);
52+
}
53+
catch
54+
{
55+
// ignore delete failure
56+
}
57+
58+
return Ok();
59+
}
60+
catch (Exception ex)
61+
{
62+
logger.LogError(ex, "Error validating file service");
63+
return BadRequest(ex.Message);
64+
}
65+
}
66+
1367
[HttpGet]
1468
public async Task<ActionResult<FileServiceSimpleDto[]>> ListFileServices(bool select, CancellationToken cancellationToken)
1569
{
@@ -28,7 +82,7 @@ public async Task<ActionResult<FileServiceSimpleDto[]>> ListFileServices(bool se
2882
else
2983
{
3084
// full mode, return all fields
31-
FileServiceDto[] data = db.FileServices
85+
FileServiceDto[] data = await db.FileServices
3286
.Select(x => new FileServiceDto
3387
{
3488
Id = x.Id,
@@ -40,9 +94,7 @@ public async Task<ActionResult<FileServiceSimpleDto[]>> ListFileServices(bool se
4094
FileCount = x.Files.Count,
4195
UpdatedAt = x.UpdatedAt,
4296
})
43-
.AsEnumerable()
44-
.Select(x => x.WithMaskedKeys())
45-
.ToArray();
97+
.ToArrayAsync(cancellationToken);
4698
return Ok(data);
4799
}
48100
}

src/FE/apis/adminApis.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,14 @@ export const deleteFileService = (id: number) => {
319319
return fetchService.delete(`/api/admin/file-service/${id}`);
320320
};
321321

322+
export const validateFileService = (params: PostFileServicesParams) => {
323+
const fetchService = createFetchClient();
324+
return fetchService.post<void>(
325+
'/api/admin/file-service/validate',
326+
{ body: params, suppressDefaultToast: true },
327+
);
328+
};
329+
322330
export const getFileServiceTypeInitialConfig = (fileServiceTypeId: number) => {
323331
const fetchService = createFetchClient();
324332
return fetchService.get<string>(

src/FE/components/admin/Files/FileServiceModal.tsx

Lines changed: 188 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import React, { useEffect } from 'react';
1+
import React, { useEffect, useRef, useState } from 'react';
22
import { useForm } from 'react-hook-form';
33
import toast from 'react-hot-toast';
44

55
import useTranslation from '@/hooks/useTranslation';
66

7+
import { IconInfo } from '@/components/Icons';
8+
import Tips from '@/components/Tips/Tips';
9+
710
import {
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';
2232
import FormInput from '@/components/ui/form/input';
2333
import FormSelect from '@/components/ui/form/select';
2434
import FormSwitch from '@/components/ui/form/switch';
25-
import FormTextarea from '@/components/ui/form/textarea';
2635
import { FormFieldType, IFormFieldOption } from '@/components/ui/form/type';
36+
import { Textarea } from '@/components/ui/textarea';
2737

2838
import {
2939
deleteFileService,
3040
getFileServiceTypeInitialConfig,
3141
postFileService,
3242
putFileService,
43+
validateFileService,
3344
} from '@/apis/adminApis';
3445
import { zodResolver } from '@hookform/resolvers/zod';
3546
import { z } from 'zod';
@@ -45,6 +56,54 @@ interface IProps {
4556
const 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

Comments
 (0)