@nextclaw/ui 0.9.14 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/dist/assets/ChannelsList-DhM0gvDV.js +1 -0
- package/dist/assets/ChatPage-4niJBFCu.js +41 -0
- package/dist/assets/DocBrowser-DpXDQNhb.js +1 -0
- package/dist/assets/LogoBadge-nqabOtgk.js +1 -0
- package/dist/assets/MarketplacePage-CrkTftqZ.js +49 -0
- package/dist/assets/McpMarketplacePage-DH1qKJqo.js +40 -0
- package/dist/assets/ModelConfig-CrrxPK_y.js +1 -0
- package/dist/assets/ProvidersList-BG36JlSJ.js +1 -0
- package/dist/assets/RemoteAccessPage-Dcj2Pzpt.js +1 -0
- package/dist/assets/RuntimeConfig-BrxgUzjJ.js +1 -0
- package/dist/assets/SearchConfig-D-NLwowp.js +1 -0
- package/dist/assets/SecretsConfig-DjNqBB05.js +3 -0
- package/dist/assets/SessionsConfig-DdlsWXQc.js +2 -0
- package/dist/assets/chat-message-B7THd1Mh.js +3 -0
- package/dist/assets/config-hints-CApS3K_7.js +1 -0
- package/dist/assets/config-layout-BHnOoweL.js +1 -0
- package/dist/assets/index-CgqD0Jfg.js +8 -0
- package/dist/assets/index-UC08nscf.css +1 -0
- package/dist/assets/label-B-TkPZRF.js +1 -0
- package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
- package/dist/assets/page-layout-BTVBRo6H.js +1 -0
- package/dist/assets/popover-DBZvpGcL.js +1 -0
- package/dist/assets/provider-models-BOeNnjk9.js +1 -0
- package/dist/assets/security-config-DotxwVFR.js +1 -0
- package/dist/assets/skeleton-DGtduHZV.js +1 -0
- package/dist/assets/status-dot-BCUTVN2R.js +1 -0
- package/dist/assets/switch-Bp2mda29.js +1 -0
- package/dist/assets/tabs-custom-BE8yZ2kE.js +1 -0
- package/dist/assets/useConfirmDialog-DCy-eYnV.js +1 -0
- package/dist/assets/vendor-DJt0Azq5.js +451 -0
- package/dist/index.html +18 -0
- package/dist/logo.svg +5 -0
- package/dist/logos/aihubmix.png +0 -0
- package/dist/logos/anthropic.svg +1 -0
- package/dist/logos/dashscope.png +0 -0
- package/dist/logos/deepseek.png +0 -0
- package/dist/logos/dingtalk.svg +1 -0
- package/dist/logos/discord.svg +1 -0
- package/dist/logos/email.svg +1 -0
- package/dist/logos/feishu.svg +12 -0
- package/dist/logos/gemini.svg +1 -0
- package/dist/logos/groq.svg +1 -0
- package/dist/logos/minimax.svg +1 -0
- package/dist/logos/mochat.svg +6 -0
- package/dist/logos/moonshot.png +0 -0
- package/dist/logos/openai.svg +1 -0
- package/dist/logos/openrouter.svg +1 -0
- package/dist/logos/qq.svg +1 -0
- package/dist/logos/slack.svg +1 -0
- package/dist/logos/telegram.svg +1 -0
- package/dist/logos/vllm.svg +1 -0
- package/dist/logos/wecom.svg +11 -0
- package/dist/logos/weixin.svg +5 -0
- package/dist/logos/whatsapp.svg +1 -0
- package/dist/logos/zhipu.svg +15 -0
- package/package.json +16 -17
- package/src/api/channel-auth.ts +35 -0
- package/src/api/channel-auth.types.ts +28 -0
- package/src/api/types.ts +7 -26
- package/src/components/chat/chat-stream/transport.ts +42 -2
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +0 -9
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +0 -10
- package/src/components/config/ChannelForm.tsx +41 -128
- package/src/components/config/ChannelsList.test.tsx +71 -10
- package/src/components/config/channel-form-fields-section.tsx +155 -0
- package/src/components/config/weixin-channel-auth-section.tsx +242 -0
- package/src/hooks/use-channel-auth.ts +16 -0
- package/src/lib/i18n.channel-auth.ts +37 -0
- package/src/lib/i18n.ts +2 -4
- package/src/transport/local.transport.ts +5 -7
- package/src/transport/remote.transport.ts +8 -7
- package/src/transport/sse-stream.test.ts +5 -19
- package/src/transport/sse-stream.ts +5 -60
- package/src/transport/transport.types.ts +0 -2
|
@@ -1,47 +1,25 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { useConfig, useConfigMeta, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
|
-
import { Input } from '@/components/ui/input';
|
|
5
|
-
import { Label } from '@/components/ui/label';
|
|
6
|
-
import { Switch } from '@/components/ui/switch';
|
|
7
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
|
-
import { TagInput } from '@/components/common/TagInput';
|
|
9
4
|
import { StatusDot } from '@/components/ui/status-dot';
|
|
10
5
|
import { LogoBadge } from '@/components/common/LogoBadge';
|
|
11
6
|
import { t } from '@/lib/i18n';
|
|
12
7
|
import { hintForPath } from '@/lib/config-hints';
|
|
13
8
|
import { cn } from '@/lib/utils';
|
|
14
9
|
import { toast } from 'sonner';
|
|
15
|
-
import {
|
|
10
|
+
import { BookOpen, ChevronDown } from 'lucide-react';
|
|
16
11
|
import type { ConfigActionManifest } from '@/api/types';
|
|
17
12
|
import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
|
|
18
13
|
import { getChannelLogo } from '@/lib/logos';
|
|
19
14
|
import { CONFIG_DETAIL_CARD_CLASS, CONFIG_EMPTY_DETAIL_CARD_CLASS } from './config-layout';
|
|
15
|
+
import { ChannelFormFieldsSection } from './channel-form-fields-section';
|
|
20
16
|
import { buildChannelFields } from './channel-form-fields';
|
|
17
|
+
import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
|
|
21
18
|
|
|
22
19
|
type ChannelFormProps = {
|
|
23
20
|
channelName?: string;
|
|
24
21
|
};
|
|
25
22
|
|
|
26
|
-
const getFieldIcon = (fieldName: string) => {
|
|
27
|
-
if (fieldName.includes('token') || fieldName.includes('secret') || fieldName.includes('password')) {
|
|
28
|
-
return <KeyRound className="h-3.5 w-3.5 text-gray-500" />;
|
|
29
|
-
}
|
|
30
|
-
if (fieldName.includes('url') || fieldName.includes('host')) {
|
|
31
|
-
return <Globe className="h-3.5 w-3.5 text-gray-500" />;
|
|
32
|
-
}
|
|
33
|
-
if (fieldName.includes('email') || fieldName.includes('mail')) {
|
|
34
|
-
return <Mail className="h-3.5 w-3.5 text-gray-500" />;
|
|
35
|
-
}
|
|
36
|
-
if (fieldName.includes('id') || fieldName.includes('from')) {
|
|
37
|
-
return <Hash className="h-3.5 w-3.5 text-gray-500" />;
|
|
38
|
-
}
|
|
39
|
-
if (fieldName === 'enabled' || fieldName === 'consentGranted') {
|
|
40
|
-
return <ToggleLeft className="h-3.5 w-3.5 text-gray-500" />;
|
|
41
|
-
}
|
|
42
|
-
return <Settings className="h-3.5 w-3.5 text-gray-500" />;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
23
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
24
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
25
|
}
|
|
@@ -93,6 +71,7 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
93
71
|
: channelName;
|
|
94
72
|
const channelMeta = meta?.channels.find((item) => item.name === channelName);
|
|
95
73
|
const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
|
|
74
|
+
const isWeixinChannel = channelName === 'weixin';
|
|
96
75
|
|
|
97
76
|
useEffect(() => {
|
|
98
77
|
if (channelConfig) {
|
|
@@ -251,111 +230,45 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
251
230
|
|
|
252
231
|
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
|
253
232
|
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-6 py-5">
|
|
254
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
>
|
|
267
|
-
{getFieldIcon(field.name)}
|
|
268
|
-
{label}
|
|
269
|
-
</Label>
|
|
270
|
-
|
|
271
|
-
{field.type === 'boolean' && (
|
|
272
|
-
<div className="flex items-center justify-between rounded-xl bg-gray-50 p-3">
|
|
273
|
-
<span className="text-sm text-gray-500">
|
|
274
|
-
{(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
|
|
275
|
-
</span>
|
|
276
|
-
<Switch
|
|
277
|
-
id={field.name}
|
|
278
|
-
checked={(formData[field.name] as boolean) || false}
|
|
279
|
-
onCheckedChange={(checked) => updateField(field.name, checked)}
|
|
280
|
-
className="data-[state=checked]:bg-emerald-500"
|
|
281
|
-
/>
|
|
233
|
+
{isWeixinChannel ? (
|
|
234
|
+
<>
|
|
235
|
+
<WeixinChannelAuthSection
|
|
236
|
+
channelConfig={channelConfig}
|
|
237
|
+
formData={formData}
|
|
238
|
+
disabled={updateChannel.isPending || Boolean(runningActionId)}
|
|
239
|
+
/>
|
|
240
|
+
<details className="group rounded-2xl border border-gray-200/80 bg-white">
|
|
241
|
+
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4 text-sm font-medium text-gray-900">
|
|
242
|
+
<div>
|
|
243
|
+
<p>{t('weixinAuthAdvancedTitle')}</p>
|
|
244
|
+
<p className="mt-1 text-xs font-normal text-gray-500">{t('weixinAuthAdvancedDescription')}</p>
|
|
282
245
|
</div>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)}
|
|
295
|
-
|
|
296
|
-
{field.type === 'password' && (
|
|
297
|
-
<Input
|
|
298
|
-
id={field.name}
|
|
299
|
-
type="password"
|
|
300
|
-
value={(formData[field.name] as string) || ''}
|
|
301
|
-
onChange={(e) => updateField(field.name, e.target.value)}
|
|
302
|
-
placeholder={placeholder ?? t('leaveBlankToKeepUnchanged')}
|
|
303
|
-
className="rounded-xl"
|
|
304
|
-
/>
|
|
305
|
-
)}
|
|
306
|
-
|
|
307
|
-
{field.type === 'number' && (
|
|
308
|
-
<Input
|
|
309
|
-
id={field.name}
|
|
310
|
-
type="number"
|
|
311
|
-
value={(formData[field.name] as number) || 0}
|
|
312
|
-
onChange={(e) => updateField(field.name, parseInt(e.target.value, 10) || 0)}
|
|
313
|
-
placeholder={placeholder}
|
|
314
|
-
className="rounded-xl"
|
|
315
|
-
/>
|
|
316
|
-
)}
|
|
317
|
-
|
|
318
|
-
{field.type === 'tags' && (
|
|
319
|
-
<TagInput
|
|
320
|
-
value={(formData[field.name] as string[]) || []}
|
|
321
|
-
onChange={(tags) => updateField(field.name, tags)}
|
|
246
|
+
<ChevronDown className="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180" />
|
|
247
|
+
</summary>
|
|
248
|
+
<div className="space-y-6 border-t border-gray-100 px-5 py-5">
|
|
249
|
+
<ChannelFormFieldsSection
|
|
250
|
+
channelName={channelName}
|
|
251
|
+
fields={fields}
|
|
252
|
+
formData={formData}
|
|
253
|
+
jsonDrafts={jsonDrafts}
|
|
254
|
+
setJsonDrafts={setJsonDrafts}
|
|
255
|
+
updateField={updateField}
|
|
256
|
+
uiHints={uiHints}
|
|
322
257
|
/>
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
</SelectItem>
|
|
338
|
-
))}
|
|
339
|
-
</SelectContent>
|
|
340
|
-
</Select>
|
|
341
|
-
)}
|
|
342
|
-
|
|
343
|
-
{field.type === 'json' && (
|
|
344
|
-
<textarea
|
|
345
|
-
id={field.name}
|
|
346
|
-
value={jsonDrafts[field.name] ?? '{}'}
|
|
347
|
-
onChange={(event) =>
|
|
348
|
-
setJsonDrafts((prev) => ({
|
|
349
|
-
...prev,
|
|
350
|
-
[field.name]: event.target.value
|
|
351
|
-
}))
|
|
352
|
-
}
|
|
353
|
-
className="min-h-[120px] w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-mono"
|
|
354
|
-
/>
|
|
355
|
-
)}
|
|
356
|
-
</div>
|
|
357
|
-
);
|
|
358
|
-
})}
|
|
258
|
+
</div>
|
|
259
|
+
</details>
|
|
260
|
+
</>
|
|
261
|
+
) : (
|
|
262
|
+
<ChannelFormFieldsSection
|
|
263
|
+
channelName={channelName}
|
|
264
|
+
fields={fields}
|
|
265
|
+
formData={formData}
|
|
266
|
+
jsonDrafts={jsonDrafts}
|
|
267
|
+
setJsonDrafts={setJsonDrafts}
|
|
268
|
+
updateField={updateField}
|
|
269
|
+
uiHints={uiHints}
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
359
272
|
</div>
|
|
360
273
|
|
|
361
274
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-6 py-4">
|
|
@@ -3,8 +3,10 @@ import userEvent from '@testing-library/user-event';
|
|
|
3
3
|
import { ChannelsList } from '@/components/config/ChannelsList';
|
|
4
4
|
|
|
5
5
|
const mocks = vi.hoisted(() => ({
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
updateChannelMutate: vi.fn(),
|
|
7
|
+
updateChannelMutateAsync: vi.fn(),
|
|
8
|
+
startChannelAuthMutateAsync: vi.fn(),
|
|
9
|
+
pollChannelAuthMutateAsync: vi.fn(),
|
|
8
10
|
configQuery: {
|
|
9
11
|
data: {
|
|
10
12
|
channels: {
|
|
@@ -51,13 +53,23 @@ const mocks = vi.hoisted(() => ({
|
|
|
51
53
|
}
|
|
52
54
|
}));
|
|
53
55
|
|
|
56
|
+
vi.mock('@tanstack/react-query', async () => {
|
|
57
|
+
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
|
|
58
|
+
return {
|
|
59
|
+
...actual,
|
|
60
|
+
useQueryClient: () => ({
|
|
61
|
+
invalidateQueries: vi.fn().mockResolvedValue(undefined)
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
54
66
|
vi.mock('@/hooks/useConfig', () => ({
|
|
55
67
|
useConfig: () => mocks.configQuery,
|
|
56
68
|
useConfigMeta: () => mocks.metaQuery,
|
|
57
69
|
useConfigSchema: () => mocks.schemaQuery,
|
|
58
70
|
useUpdateChannel: () => ({
|
|
59
|
-
mutate: mocks.
|
|
60
|
-
mutateAsync: mocks.
|
|
71
|
+
mutate: mocks.updateChannelMutate,
|
|
72
|
+
mutateAsync: mocks.updateChannelMutateAsync,
|
|
61
73
|
isPending: false
|
|
62
74
|
}),
|
|
63
75
|
useExecuteConfigAction: () => ({
|
|
@@ -66,27 +78,76 @@ vi.mock('@/hooks/useConfig', () => ({
|
|
|
66
78
|
})
|
|
67
79
|
}));
|
|
68
80
|
|
|
81
|
+
vi.mock('@/hooks/use-channel-auth', () => ({
|
|
82
|
+
useStartChannelAuth: () => ({
|
|
83
|
+
mutateAsync: mocks.startChannelAuthMutateAsync,
|
|
84
|
+
isPending: false
|
|
85
|
+
}),
|
|
86
|
+
usePollChannelAuth: () => ({
|
|
87
|
+
mutateAsync: mocks.pollChannelAuthMutateAsync,
|
|
88
|
+
isPending: false
|
|
89
|
+
})
|
|
90
|
+
}));
|
|
91
|
+
|
|
69
92
|
describe('ChannelsList', () => {
|
|
70
93
|
beforeEach(() => {
|
|
71
|
-
mocks.
|
|
72
|
-
mocks.
|
|
94
|
+
mocks.updateChannelMutate.mockReset();
|
|
95
|
+
mocks.updateChannelMutateAsync.mockReset();
|
|
96
|
+
mocks.startChannelAuthMutateAsync.mockReset();
|
|
97
|
+
mocks.pollChannelAuthMutateAsync.mockReset();
|
|
73
98
|
});
|
|
74
99
|
|
|
75
|
-
it('renders weixin and
|
|
100
|
+
it('renders weixin qr auth card and starts channel auth', async () => {
|
|
76
101
|
const user = userEvent.setup();
|
|
102
|
+
mocks.startChannelAuthMutateAsync.mockResolvedValue({
|
|
103
|
+
channel: 'weixin',
|
|
104
|
+
kind: 'qr_code',
|
|
105
|
+
sessionId: 'session-1',
|
|
106
|
+
qrCode: 'qr-token',
|
|
107
|
+
qrCodeUrl: 'https://example.com/weixin-qr.png',
|
|
108
|
+
expiresAt: '2026-03-23T10:00:00.000Z',
|
|
109
|
+
intervalMs: 60_000,
|
|
110
|
+
note: '请扫码'
|
|
111
|
+
});
|
|
77
112
|
|
|
78
113
|
render(<ChannelsList />);
|
|
79
114
|
|
|
80
115
|
await user.click(await screen.findByRole('button', { name: /All Channels/i }));
|
|
81
116
|
|
|
82
117
|
expect((await screen.findAllByText('Weixin')).length).toBeGreaterThan(0);
|
|
83
|
-
expect(await screen.
|
|
118
|
+
expect(await screen.findByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
|
|
119
|
+
expect(screen.getByText('Weixin now uses QR login as the primary setup flow.')).toBeTruthy();
|
|
120
|
+
|
|
121
|
+
await user.click(screen.getByRole('button', { name: 'Reconnect with QR' }));
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(mocks.startChannelAuthMutateAsync).toHaveBeenCalledWith({
|
|
125
|
+
channel: 'weixin',
|
|
126
|
+
data: expect.objectContaining({
|
|
127
|
+
accountId: '1344b2b24720@im.bot',
|
|
128
|
+
baseUrl: 'https://ilinkai.weixin.qq.com'
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('saves weixin advanced settings from the advanced section', async () => {
|
|
135
|
+
const user = userEvent.setup();
|
|
136
|
+
|
|
137
|
+
const { container } = render(<ChannelsList />);
|
|
138
|
+
|
|
139
|
+
await user.click(await screen.findByRole('button', { name: /All Channels/i }));
|
|
140
|
+
await user.click(await screen.findByText('Advanced settings'));
|
|
84
141
|
|
|
85
142
|
const timeoutInput = await screen.findByLabelText('Long Poll Timeout (ms)');
|
|
86
143
|
await user.clear(timeoutInput);
|
|
87
144
|
await user.type(timeoutInput, '45000');
|
|
88
145
|
|
|
89
|
-
const accountsJson =
|
|
146
|
+
const accountsJson = container.querySelector('textarea#accounts') as HTMLTextAreaElement | null;
|
|
147
|
+
expect(accountsJson).toBeTruthy();
|
|
148
|
+
if (!accountsJson) {
|
|
149
|
+
throw new Error('accounts textarea not found');
|
|
150
|
+
}
|
|
90
151
|
await user.clear(accountsJson);
|
|
91
152
|
fireEvent.change(accountsJson, {
|
|
92
153
|
target: {
|
|
@@ -106,7 +167,7 @@ describe('ChannelsList', () => {
|
|
|
106
167
|
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
107
168
|
|
|
108
169
|
await waitFor(() => {
|
|
109
|
-
expect(mocks.
|
|
170
|
+
expect(mocks.updateChannelMutate).toHaveBeenCalledWith({
|
|
110
171
|
channel: 'weixin',
|
|
111
172
|
data: expect.objectContaining({
|
|
112
173
|
enabled: false,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import { Input } from '@/components/ui/input';
|
|
3
|
+
import { Label } from '@/components/ui/label';
|
|
4
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
5
|
+
import { Switch } from '@/components/ui/switch';
|
|
6
|
+
import { TagInput } from '@/components/common/TagInput';
|
|
7
|
+
import { hintForPath } from '@/lib/config-hints';
|
|
8
|
+
import { t } from '@/lib/i18n';
|
|
9
|
+
import { Globe, Hash, KeyRound, Mail, Settings, ToggleLeft } from 'lucide-react';
|
|
10
|
+
import type { ConfigUiHints } from '@/api/types';
|
|
11
|
+
import type { ChannelField } from './channel-form-fields';
|
|
12
|
+
|
|
13
|
+
function getFieldIcon(fieldName: string) {
|
|
14
|
+
if (fieldName.includes('token') || fieldName.includes('secret') || fieldName.includes('password')) {
|
|
15
|
+
return <KeyRound className="h-3.5 w-3.5 text-gray-500" />;
|
|
16
|
+
}
|
|
17
|
+
if (fieldName.includes('url') || fieldName.includes('host')) {
|
|
18
|
+
return <Globe className="h-3.5 w-3.5 text-gray-500" />;
|
|
19
|
+
}
|
|
20
|
+
if (fieldName.includes('email') || fieldName.includes('mail')) {
|
|
21
|
+
return <Mail className="h-3.5 w-3.5 text-gray-500" />;
|
|
22
|
+
}
|
|
23
|
+
if (fieldName.includes('id') || fieldName.includes('from')) {
|
|
24
|
+
return <Hash className="h-3.5 w-3.5 text-gray-500" />;
|
|
25
|
+
}
|
|
26
|
+
if (fieldName === 'enabled' || fieldName === 'consentGranted') {
|
|
27
|
+
return <ToggleLeft className="h-3.5 w-3.5 text-gray-500" />;
|
|
28
|
+
}
|
|
29
|
+
return <Settings className="h-3.5 w-3.5 text-gray-500" />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ChannelFormFieldsSectionProps = {
|
|
33
|
+
channelName: string;
|
|
34
|
+
fields: ChannelField[];
|
|
35
|
+
formData: Record<string, unknown>;
|
|
36
|
+
jsonDrafts: Record<string, string>;
|
|
37
|
+
setJsonDrafts: Dispatch<SetStateAction<Record<string, string>>>;
|
|
38
|
+
updateField: (name: string, value: unknown) => void;
|
|
39
|
+
uiHints?: ConfigUiHints;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function ChannelFormFieldsSection({
|
|
43
|
+
channelName,
|
|
44
|
+
fields,
|
|
45
|
+
formData,
|
|
46
|
+
jsonDrafts,
|
|
47
|
+
setJsonDrafts,
|
|
48
|
+
updateField,
|
|
49
|
+
uiHints
|
|
50
|
+
}: ChannelFormFieldsSectionProps) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
{fields.map((field) => {
|
|
54
|
+
const hint = hintForPath(`channels.${channelName}.${field.name}`, uiHints);
|
|
55
|
+
const label = hint?.label ?? field.label;
|
|
56
|
+
const placeholder = hint?.placeholder;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div key={field.name} className="space-y-2.5">
|
|
60
|
+
<Label htmlFor={field.name} className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
|
61
|
+
{getFieldIcon(field.name)}
|
|
62
|
+
{label}
|
|
63
|
+
</Label>
|
|
64
|
+
|
|
65
|
+
{field.type === 'boolean' && (
|
|
66
|
+
<div className="flex items-center justify-between rounded-xl bg-gray-50 p-3">
|
|
67
|
+
<span className="text-sm text-gray-500">
|
|
68
|
+
{(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
|
|
69
|
+
</span>
|
|
70
|
+
<Switch
|
|
71
|
+
id={field.name}
|
|
72
|
+
checked={(formData[field.name] as boolean) || false}
|
|
73
|
+
onCheckedChange={(checked) => updateField(field.name, checked)}
|
|
74
|
+
className="data-[state=checked]:bg-emerald-500"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{(field.type === 'text' || field.type === 'email') && (
|
|
80
|
+
<Input
|
|
81
|
+
id={field.name}
|
|
82
|
+
type={field.type}
|
|
83
|
+
value={(formData[field.name] as string) || ''}
|
|
84
|
+
onChange={(event) => updateField(field.name, event.target.value)}
|
|
85
|
+
placeholder={placeholder}
|
|
86
|
+
className="rounded-xl"
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{field.type === 'password' && (
|
|
91
|
+
<Input
|
|
92
|
+
id={field.name}
|
|
93
|
+
type="password"
|
|
94
|
+
value={(formData[field.name] as string) || ''}
|
|
95
|
+
onChange={(event) => updateField(field.name, event.target.value)}
|
|
96
|
+
placeholder={placeholder ?? t('leaveBlankToKeepUnchanged')}
|
|
97
|
+
className="rounded-xl"
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{field.type === 'number' && (
|
|
102
|
+
<Input
|
|
103
|
+
id={field.name}
|
|
104
|
+
type="number"
|
|
105
|
+
value={(formData[field.name] as number) || 0}
|
|
106
|
+
onChange={(event) => updateField(field.name, parseInt(event.target.value, 10) || 0)}
|
|
107
|
+
placeholder={placeholder}
|
|
108
|
+
className="rounded-xl"
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{field.type === 'tags' && (
|
|
113
|
+
<TagInput
|
|
114
|
+
value={(formData[field.name] as string[]) || []}
|
|
115
|
+
onChange={(tags) => updateField(field.name, tags)}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{field.type === 'select' && (
|
|
120
|
+
<Select
|
|
121
|
+
value={(formData[field.name] as string) || ''}
|
|
122
|
+
onValueChange={(value) => updateField(field.name, value)}
|
|
123
|
+
>
|
|
124
|
+
<SelectTrigger className="rounded-xl">
|
|
125
|
+
<SelectValue />
|
|
126
|
+
</SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{(field.options ?? []).map((option) => (
|
|
129
|
+
<SelectItem key={option.value} value={option.value}>
|
|
130
|
+
{option.label}
|
|
131
|
+
</SelectItem>
|
|
132
|
+
))}
|
|
133
|
+
</SelectContent>
|
|
134
|
+
</Select>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{field.type === 'json' && (
|
|
138
|
+
<textarea
|
|
139
|
+
id={field.name}
|
|
140
|
+
value={jsonDrafts[field.name] ?? '{}'}
|
|
141
|
+
onChange={(event) =>
|
|
142
|
+
setJsonDrafts((prev) => ({
|
|
143
|
+
...prev,
|
|
144
|
+
[field.name]: event.target.value
|
|
145
|
+
}))
|
|
146
|
+
}
|
|
147
|
+
className="min-h-[120px] w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-mono"
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|