@nextclaw/ui 0.12.9 → 0.12.10
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 +61 -0
- package/dist/assets/ChannelsList-M9FTK1Ak.js +8 -0
- package/dist/assets/DocBrowser-CH7-GxlL.js +1 -0
- package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-DMfr0Oow.js} +1 -1
- package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BXydqby-.js} +1 -1
- package/dist/assets/{LogoBadge-ByNLYg65.js → LogoBadge-hO7tY7hE.js} +1 -1
- package/dist/assets/ModelConfig-CNIgLf0e.js +1 -0
- package/dist/assets/{ProviderScopedModelInput-Da7khnBA.js → ProviderScopedModelInput-B3HWP4oz.js} +1 -1
- package/dist/assets/ProvidersList-CHjMnRhX.js +1 -0
- package/dist/assets/RuntimeConfig-psp8nMSG.js +1 -0
- package/dist/assets/SearchConfig-CSoKip1f.js +1 -0
- package/dist/assets/{SecretsConfig-D281Rotl.js → SecretsConfig-MEt6MjuD.js} +2 -2
- package/dist/assets/SessionsConfig-DifCiXwR.js +2 -0
- package/dist/assets/{app-query-client-VnFElj4E.js → app-query-client-9jNewezV.js} +1 -1
- package/dist/assets/{book-open-BdcxxoQu.js → book-open-DzdUViDm.js} +1 -1
- package/dist/assets/chat-page-CLp0UV0Y.js +58 -0
- package/dist/assets/chat-session-display-DsYHx0RZ.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-DK5HPmIK.js → chunk-JZWAC4HX-C5dEc8hV.js} +1 -1
- package/dist/assets/{client-_i4MU2bB.js → client-C-8fH7-c.js} +1 -1
- package/dist/assets/{config-DtIQwrHF.js → config-CBScxsdV.js} +1 -1
- package/dist/assets/config-split-page-BUout_Ak.js +1 -0
- package/dist/assets/{createLucideIcon-BSeTgkZW.js → createLucideIcon-dy5ie7Ox.js} +1 -1
- package/dist/assets/desktop-update-config-2BS6BMkW.js +1 -0
- package/dist/assets/{dist-ccBFUi-o.js → dist-BruyLa92.js} +1 -1
- package/dist/assets/{dist-6TrrnPCR.js → dist-Cy7_j6hA.js} +1 -1
- package/dist/assets/{download-BhDxnyvU.js → download-BD0ETkB-.js} +1 -1
- package/dist/assets/{external-link-BgErLCNT.js → external-link-kZSAO8nT.js} +1 -1
- package/dist/assets/{hash-Bl7dr_UG.js → hash-BHJC2Ovu.js} +1 -1
- package/dist/assets/{i18n-eDHeDY0n.js → i18n-CpTZLchQ.js} +1 -1
- package/dist/assets/index-mW8W2FUu.css +1 -0
- package/dist/assets/index-zDZfXoI4.js +6 -0
- package/dist/assets/{infiniteQueryBehavior-ZDS92Qpp.js → infiniteQueryBehavior-CyER9hv0.js} +1 -1
- package/dist/assets/loader-circle-Bc2gCU33.js +1 -0
- package/dist/assets/{logos-x89HbrZ4.js → logos-B7gRObP8.js} +1 -1
- package/dist/assets/marketplace-page-3qVMnF3d.js +1 -0
- package/dist/assets/marketplace-page-BhFIeQzI.js +49 -0
- package/dist/assets/mcp-marketplace-page-DYfteJ1D.js +40 -0
- package/dist/assets/{page-layout-vZnghcFy.js → page-layout-0UcO9H9Z.js} +1 -1
- package/dist/assets/play-CKDjSQFL.js +1 -0
- package/dist/assets/plus-CG0QrVY_.js +1 -0
- package/dist/assets/{refresh-ccw-DT98i__E.js → refresh-ccw-COVhNHtN.js} +1 -1
- package/dist/assets/{refresh-cw-C47QSEwg.js → refresh-cw-Bcv40SXy.js} +1 -1
- package/dist/assets/remote-access-page-CWHG-sug.js +1 -0
- package/dist/assets/{rotate-cw-JtFzpNn6.js → rotate-cw-oHMKJMC8.js} +1 -1
- package/dist/assets/{save-3S6-H3Xw.js → save-EqJPOF0G.js} +1 -1
- package/dist/assets/search-BCAlB8nz.js +1 -0
- package/dist/assets/security-config-Slh0Mayz.js +1 -0
- package/dist/assets/select-CVz0t7MF.js +41 -0
- package/dist/assets/setting-row-CbVHAuQt.js +1 -0
- package/dist/assets/skeleton-D5rdKvzy.js +1 -0
- package/dist/assets/{status-dot-vbanNPFU.js → status-dot-DpPtVzQT.js} +1 -1
- package/dist/assets/{switch-BsLtHOH-.js → switch-CM29eCAR.js} +1 -1
- package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-YcZUWn3o.js} +1 -1
- package/dist/assets/tag-chip-DMXdnLcj.js +1 -0
- package/dist/assets/{trash-2-G48scll7.js → trash-2-mJT6oWa2.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-DkNhD-42.js → use-infinite-scroll-loader-DJ1L81Dz.js} +1 -1
- package/dist/assets/{useConfirmDialog-BkvTN-vd.js → useConfirmDialog-BsVuqu1x.js} +1 -1
- package/dist/assets/{useMutation-CBWjE2uj.js → useMutation-CNcz2fgt.js} +1 -1
- package/dist/assets/x-Czwxm82I.js +1 -0
- package/dist/index.html +22 -22
- package/dist/runtime-icons/claude.ico +0 -0
- package/dist/runtime-icons/codex-openai.svg +6 -0
- package/dist/runtime-icons/hermes-agent.png +0 -0
- package/package.json +6 -6
- package/public/runtime-icons/claude.ico +0 -0
- package/public/runtime-icons/codex-openai.svg +6 -0
- package/public/runtime-icons/hermes-agent.png +0 -0
- package/src/account/components/account-panel.tsx +217 -97
- package/src/account/managers/account.manager.ts +3 -2
- package/src/api/chat-session-type.types.ts +7 -0
- package/src/api/runtime-control.types.ts +8 -0
- package/src/api/types.ts +8 -0
- package/src/app.tsx +221 -57
- package/src/components/agents/agent-dialogs.tsx +499 -0
- package/src/components/agents/agents-page.test.tsx +238 -0
- package/src/components/agents/agents-page.tsx +435 -0
- package/src/components/chat/ChatSidebar.tsx +11 -35
- package/src/components/chat/chat-conversation-panel.test.tsx +20 -0
- package/src/components/chat/chat-conversation-panel.tsx +83 -13
- package/src/components/chat/chat-page-shell.tsx +19 -13
- package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
- package/src/components/chat/chat-session-type-option-item.tsx +68 -0
- package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
- package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
- package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
- package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
- package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
- package/src/components/chat/ncp/ncp-chat-page.tsx +2 -0
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
- package/src/components/chat/stores/chat-input.store.ts +2 -1
- package/src/components/chat/stores/chat-thread.store.ts +3 -1
- package/src/components/chat/useChatSessionTypeState.ts +10 -1
- package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
- package/src/components/common/BrandHeader.tsx +3 -1
- package/src/components/common/session-context-icon.tsx +15 -2
- package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
- package/src/components/config/ChannelForm.test.tsx +89 -3
- package/src/components/config/ChannelForm.tsx +157 -188
- package/src/components/config/ChannelsList.test.tsx +163 -119
- package/src/components/config/ChannelsList.tsx +90 -101
- package/src/components/config/ProviderForm.tsx +108 -146
- package/src/components/config/ProvidersList.tsx +100 -123
- package/src/components/config/SearchConfig.tsx +423 -393
- package/src/components/config/channel-form-fields-section.tsx +70 -37
- package/src/components/config/config-split-page.tsx +109 -0
- package/src/components/config/provider-enabled-field.tsx +17 -10
- package/src/components/config/runtime-control-card.test.tsx +56 -0
- package/src/components/config/runtime-control-card.tsx +25 -0
- package/src/components/config/runtime-presence-card.tsx +93 -79
- package/src/components/layout/AppLayout.tsx +25 -37
- package/src/components/layout/app-layout.test.tsx +46 -14
- package/src/components/layout/runtime-status-entry.test.tsx +157 -0
- package/src/components/layout/runtime-status-entry.tsx +143 -0
- package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
- package/src/components/marketplace/marketplace-list-card.tsx +288 -0
- package/src/components/marketplace/marketplace-page-data.ts +129 -0
- package/src/components/marketplace/marketplace-page.test.tsx +339 -0
- package/src/components/marketplace/marketplace-page.tsx +596 -0
- package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
- package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
- package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
- package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
- package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
- package/src/components/remote/remote-access-page.test.tsx +105 -0
- package/src/components/remote/remote-access-page.tsx +248 -0
- package/src/components/ui/notice-card.tsx +129 -0
- package/src/components/ui/setting-row.tsx +51 -0
- package/src/components/ui/tag-chip.tsx +39 -0
- package/src/components/ui/textarea.tsx +19 -0
- package/src/hooks/useConfig.ts +2 -1
- package/src/index.css +24 -0
- package/src/lib/app-resource-uri.test.ts +20 -0
- package/src/lib/app-resource-uri.ts +29 -0
- package/src/lib/i18n.remote.ts +1 -1
- package/src/lib/i18n.runtime-control.ts +31 -0
- package/src/lib/i18n.ts +5 -8
- package/src/lib/session-context.utils.test.ts +71 -0
- package/src/lib/session-context.utils.ts +28 -3
- package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
- package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
- package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
- package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
- package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
- package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
- package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
- package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
- package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
- package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
- package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
- package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
- package/dist/assets/SessionsConfig-ChHQ7M5c.js +0 -2
- package/dist/assets/chat-page-Doe0yTtB.js +0 -58
- package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
- package/dist/assets/config-layout-CHs0mAaR.js +0 -1
- package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
- package/dist/assets/index-CF9xve0E.js +0 -6
- package/dist/assets/index-FgA52VBt.css +0 -1
- package/dist/assets/loader-circle-ACM1s51e.js +0 -1
- package/dist/assets/play-CFUwCA2E.js +0 -1
- package/dist/assets/plus-rYsv72JG.js +0 -1
- package/dist/assets/popover-Bg1VoTZ6.js +0 -1
- package/dist/assets/search-3kFR_zh9.js +0 -1
- package/dist/assets/security-config-BWaiARNk.js +0 -1
- package/dist/assets/select-DJ2MUjBB.js +0 -41
- package/dist/assets/skeleton-ByQepn0M.js +0 -1
- package/dist/assets/x-ByDbItbq.js +0 -1
- package/src/components/agents/AgentDialogs.tsx +0 -400
- package/src/components/agents/AgentsPage.test.tsx +0 -217
- package/src/components/agents/AgentsPage.tsx +0 -352
- package/src/components/config/config-layout.ts +0 -10
- package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
- package/src/components/marketplace/MarketplacePage.tsx +0 -827
- package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
- package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
- package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
- package/src/components/remote/RemoteAccessPage.tsx +0 -144
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { ChannelForm } from './ChannelForm';
|
|
3
3
|
|
|
4
|
+
const updateChannelMutate = vi.fn();
|
|
5
|
+
const updateChannelMutateAsync = vi.fn();
|
|
6
|
+
let subscribeHandler:
|
|
7
|
+
| ((event: {
|
|
8
|
+
type: 'channel.config.apply-status';
|
|
9
|
+
payload: {
|
|
10
|
+
channel: string;
|
|
11
|
+
status: 'started' | 'succeeded' | 'failed';
|
|
12
|
+
message?: string;
|
|
13
|
+
};
|
|
14
|
+
}) => void)
|
|
15
|
+
| null = null;
|
|
16
|
+
|
|
4
17
|
vi.mock('@/hooks/useConfig', () => ({
|
|
5
18
|
useConfig: () => ({
|
|
6
19
|
data: {
|
|
@@ -29,8 +42,8 @@ vi.mock('@/hooks/useConfig', () => ({
|
|
|
29
42
|
}
|
|
30
43
|
}),
|
|
31
44
|
useUpdateChannel: () => ({
|
|
32
|
-
mutate:
|
|
33
|
-
mutateAsync:
|
|
45
|
+
mutate: updateChannelMutate,
|
|
46
|
+
mutateAsync: updateChannelMutateAsync,
|
|
34
47
|
isPending: false
|
|
35
48
|
}),
|
|
36
49
|
useExecuteConfigAction: () => ({
|
|
@@ -39,6 +52,27 @@ vi.mock('@/hooks/useConfig', () => ({
|
|
|
39
52
|
})
|
|
40
53
|
}));
|
|
41
54
|
|
|
55
|
+
vi.mock('@/transport', () => ({
|
|
56
|
+
appClient: {
|
|
57
|
+
subscribe: (handler: typeof subscribeHandler) => {
|
|
58
|
+
subscribeHandler = handler;
|
|
59
|
+
return () => {
|
|
60
|
+
subscribeHandler = null;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock('./weixin-channel-auth-section', () => ({
|
|
67
|
+
WeixinChannelAuthSection: () => null
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
updateChannelMutate.mockReset();
|
|
72
|
+
updateChannelMutateAsync.mockReset();
|
|
73
|
+
subscribeHandler = null;
|
|
74
|
+
});
|
|
75
|
+
|
|
42
76
|
describe('ChannelForm', () => {
|
|
43
77
|
it('renders the empty selection state without entering a render loop', async () => {
|
|
44
78
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
@@ -57,4 +91,56 @@ describe('ChannelForm', () => {
|
|
|
57
91
|
|
|
58
92
|
consoleErrorSpy.mockRestore();
|
|
59
93
|
});
|
|
94
|
+
|
|
95
|
+
it('submits channel updates without waiting for background apply', () => {
|
|
96
|
+
render(<ChannelForm channelName="weixin" />);
|
|
97
|
+
|
|
98
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
99
|
+
|
|
100
|
+
expect(updateChannelMutate).toHaveBeenCalledWith({
|
|
101
|
+
channel: 'weixin',
|
|
102
|
+
data: {
|
|
103
|
+
accounts: {},
|
|
104
|
+
enabled: false
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('shows apply status updates from realtime events for the active channel', async () => {
|
|
110
|
+
render(<ChannelForm channelName="weixin" />);
|
|
111
|
+
|
|
112
|
+
await act(async () => {
|
|
113
|
+
subscribeHandler?.({
|
|
114
|
+
type: 'channel.config.apply-status',
|
|
115
|
+
payload: {
|
|
116
|
+
channel: 'weixin',
|
|
117
|
+
status: 'started'
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
expect(screen.getByText('Channel configuration is applying')).toBeTruthy();
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
subscribeHandler?.({
|
|
125
|
+
type: 'channel.config.apply-status',
|
|
126
|
+
payload: {
|
|
127
|
+
channel: 'weixin',
|
|
128
|
+
status: 'succeeded'
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(screen.getByText('Channel configuration applied')).toBeTruthy();
|
|
133
|
+
|
|
134
|
+
await act(async () => {
|
|
135
|
+
subscribeHandler?.({
|
|
136
|
+
type: 'channel.config.apply-status',
|
|
137
|
+
payload: {
|
|
138
|
+
channel: 'weixin',
|
|
139
|
+
status: 'failed',
|
|
140
|
+
message: 'boom'
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
expect(screen.getByText('Failed to apply channel configuration: boom')).toBeTruthy();
|
|
145
|
+
});
|
|
60
146
|
});
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { BookOpen, ChevronDown } from 'lucide-react';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import { useConfig, useConfigMeta, useConfigSchema, useExecuteConfigAction, useUpdateChannel } from '@/hooks/useConfig';
|
|
5
|
+
import { LogoBadge } from '@/components/common/LogoBadge';
|
|
3
6
|
import { Button } from '@/components/ui/button';
|
|
4
7
|
import { StatusDot } from '@/components/ui/status-dot';
|
|
5
|
-
import { LogoBadge } from '@/components/common/LogoBadge';
|
|
6
|
-
import { t } from '@/lib/i18n';
|
|
7
|
-
import { hintForPath } from '@/lib/config-hints';
|
|
8
|
-
import { cn } from '@/lib/utils';
|
|
9
|
-
import { toast } from 'sonner';
|
|
10
|
-
import { BookOpen, ChevronDown } from 'lucide-react';
|
|
11
8
|
import type { ConfigActionManifest } from '@/api/types';
|
|
9
|
+
import { hintForPath } from '@/lib/config-hints';
|
|
12
10
|
import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
|
|
11
|
+
import { t } from '@/lib/i18n';
|
|
13
12
|
import { getChannelLogo } from '@/lib/logos';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { cn } from '@/lib/utils';
|
|
14
|
+
import { appClient } from '@/transport';
|
|
15
|
+
import {
|
|
16
|
+
ConfigSplitDetailPane,
|
|
17
|
+
ConfigSplitEmptyPane,
|
|
18
|
+
ConfigSplitPaneBody,
|
|
19
|
+
ConfigSplitPaneFooter,
|
|
20
|
+
ConfigSplitPaneHeader
|
|
21
|
+
} from './config-split-page';
|
|
16
22
|
import { buildChannelFormDefinitions, type ChannelField, type ChannelFormBlock, type ChannelFormFieldSection } from './channel-form-fields';
|
|
23
|
+
import { ChannelFormFieldsSection } from './channel-form-fields-section';
|
|
17
24
|
import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
|
|
18
25
|
|
|
19
|
-
type ChannelFormProps = {
|
|
20
|
-
|
|
21
|
-
};
|
|
22
|
-
|
|
26
|
+
type ChannelFormProps = { channelName?: string };
|
|
27
|
+
type ChannelApplyState = { status: 'applying' | 'applied' | 'failed'; message?: string } | null;
|
|
23
28
|
const EMPTY_CHANNEL_FIELDS: ChannelField[] = [];
|
|
24
29
|
const DEFAULT_CHANNEL_LAYOUT_BLOCKS: ChannelFormBlock[] = [{ type: 'fields', section: 'all' }];
|
|
25
30
|
|
|
@@ -30,62 +35,77 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
30
35
|
function deepMergeRecords(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
|
|
31
36
|
const next: Record<string, unknown> = { ...base };
|
|
32
37
|
for (const [key, value] of Object.entries(patch)) {
|
|
33
|
-
|
|
34
|
-
if (isRecord(prev) && isRecord(value)) {
|
|
35
|
-
next[key] = deepMergeRecords(prev, value);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
next[key] = value;
|
|
38
|
+
next[key] = isRecord(next[key]) && isRecord(value) ? deepMergeRecords(next[key] as Record<string, unknown>, value) : value;
|
|
39
39
|
}
|
|
40
40
|
return next;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function buildScopeDraft(scope: string, value: Record<string, unknown>)
|
|
44
|
-
const segments = scope.split('.');
|
|
43
|
+
function buildScopeDraft(scope: string, value: Record<string, unknown>) {
|
|
45
44
|
const output: Record<string, unknown> = {};
|
|
46
|
-
let cursor
|
|
45
|
+
let cursor = output;
|
|
46
|
+
const segments = scope.split('.');
|
|
47
47
|
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
48
|
-
|
|
49
|
-
cursor[
|
|
50
|
-
cursor = cursor[segment] as Record<string, unknown>;
|
|
48
|
+
cursor[segments[index]] = {};
|
|
49
|
+
cursor = cursor[segments[index]] as Record<string, unknown>;
|
|
51
50
|
}
|
|
52
51
|
cursor[segments[segments.length - 1]] = value;
|
|
53
52
|
return output;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
function resolveFieldsForSection(fields: ChannelField[], section: ChannelFormFieldSection)
|
|
55
|
+
function resolveFieldsForSection(fields: ChannelField[], section: ChannelFormFieldSection) {
|
|
57
56
|
if (section === 'all') {
|
|
58
57
|
return fields;
|
|
59
58
|
}
|
|
60
|
-
|
|
61
|
-
return fields.filter((field) => field.section === 'primary');
|
|
62
|
-
}
|
|
63
|
-
return fields.filter((field) => field.section !== 'primary');
|
|
59
|
+
return fields.filter((field) => (section === 'primary' ? field.section === 'primary' : field.section !== 'primary'));
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
function buildJsonDrafts(channelConfig: Record<string, unknown>, fields: ChannelField[])
|
|
62
|
+
function buildJsonDrafts(channelConfig: Record<string, unknown>, fields: ChannelField[]) {
|
|
67
63
|
const nextDrafts: Record<string, string> = {};
|
|
68
|
-
fields
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
nextDrafts[field.name] = JSON.stringify(channelConfig[field.name] ?? {}, null, 2);
|
|
72
|
-
});
|
|
64
|
+
fields.filter((field) => field.type === 'json').forEach((field) => {
|
|
65
|
+
nextDrafts[field.name] = JSON.stringify(channelConfig[field.name] ?? {}, null, 2);
|
|
66
|
+
});
|
|
73
67
|
return nextDrafts;
|
|
74
68
|
}
|
|
75
69
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
function useChannelApplyState(channelName: string | undefined) {
|
|
71
|
+
const [channelApplyState, setChannelApplyState] = useState<ChannelApplyState>(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!channelName) {
|
|
75
|
+
setChannelApplyState(null);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
return appClient.subscribe((event) => {
|
|
79
|
+
if (event.type !== 'channel.config.apply-status' || event.payload.channel !== channelName) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setChannelApplyState(
|
|
83
|
+
event.payload.status === 'started'
|
|
84
|
+
? { status: 'applying' }
|
|
85
|
+
: event.payload.status === 'succeeded'
|
|
86
|
+
? { status: 'applied' }
|
|
87
|
+
: { status: 'failed', message: event.payload.message }
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}, [channelName]);
|
|
91
|
+
|
|
92
|
+
return channelApplyState;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildChannelApplyStatusView(channelApplyState: ChannelApplyState) {
|
|
96
|
+
if (!channelApplyState) {
|
|
97
|
+
return null;
|
|
83
98
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
if (channelApplyState.status === 'applying') {
|
|
100
|
+
return { className: 'text-amber-600', label: t('channelConfigApplying') };
|
|
101
|
+
}
|
|
102
|
+
if (channelApplyState.status === 'applied') {
|
|
103
|
+
return { className: 'text-emerald-600', label: t('channelConfigApplied') };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
className: 'text-red-600',
|
|
107
|
+
label: `${t('channelConfigApplyFailed')}${channelApplyState.message ? `: ${channelApplyState.message}` : ''}`
|
|
108
|
+
};
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
@@ -94,71 +114,63 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
94
114
|
const { data: schema } = useConfigSchema();
|
|
95
115
|
const updateChannel = useUpdateChannel();
|
|
96
116
|
const executeAction = useExecuteConfigAction();
|
|
97
|
-
|
|
98
117
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
99
118
|
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
|
100
119
|
const [runningActionId, setRunningActionId] = useState<string | null>(null);
|
|
120
|
+
const channelApplyState = useChannelApplyState(channelName);
|
|
101
121
|
const lastHydrationKeyRef = useRef<string | null>(null);
|
|
102
122
|
|
|
103
123
|
const channelConfig = channelName ? config?.channels[channelName] : null;
|
|
104
|
-
const
|
|
105
|
-
const channelDefinition = channelName ? channelDefinitions[channelName] : undefined;
|
|
124
|
+
const channelDefinition = useMemo(() => buildChannelFormDefinitions()[channelName || ''], [channelName]);
|
|
106
125
|
const fields = channelDefinition?.fields ?? EMPTY_CHANNEL_FIELDS;
|
|
107
126
|
const layoutBlocks = channelDefinition?.layout ?? DEFAULT_CHANNEL_LAYOUT_BLOCKS;
|
|
108
127
|
const uiHints = schema?.uiHints;
|
|
109
128
|
const scope = channelName ? `channels.${channelName}` : null;
|
|
110
129
|
const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
|
|
111
|
-
const channelLabel = channelName
|
|
112
|
-
? hintForPath(`channels.${channelName}`, uiHints)?.label ?? channelName
|
|
113
|
-
: channelName;
|
|
114
130
|
const channelMeta = meta?.channels.find((item) => item.name === channelName);
|
|
131
|
+
const channelLabel = channelName ? hintForPath(`channels.${channelName}`, uiHints)?.label ?? channelName : channelName;
|
|
115
132
|
const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
|
|
116
|
-
const hydrationKey =
|
|
133
|
+
const hydrationKey = channelName && channelConfig
|
|
134
|
+
? JSON.stringify({ channelName, channelConfig, jsonFields: fields.filter((field) => field.type === 'json').map((field) => field.name) })
|
|
135
|
+
: `empty:${channelName ?? ''}`;
|
|
117
136
|
|
|
118
137
|
useEffect(() => {
|
|
119
138
|
if (lastHydrationKeyRef.current === hydrationKey) {
|
|
120
139
|
return;
|
|
121
140
|
}
|
|
122
141
|
lastHydrationKeyRef.current = hydrationKey;
|
|
123
|
-
|
|
124
142
|
if (channelConfig) {
|
|
125
143
|
setFormData({ ...channelConfig });
|
|
126
144
|
setJsonDrafts(buildJsonDrafts(channelConfig, fields));
|
|
127
|
-
|
|
128
|
-
setFormData({});
|
|
129
|
-
setJsonDrafts({});
|
|
145
|
+
return;
|
|
130
146
|
}
|
|
147
|
+
setFormData({});
|
|
148
|
+
setJsonDrafts({});
|
|
131
149
|
}, [channelConfig, fields, hydrationKey]);
|
|
132
150
|
|
|
133
|
-
const updateField = (name: string, value: unknown) => {
|
|
134
|
-
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
135
|
-
};
|
|
151
|
+
const updateField = (name: string, value: unknown) => setFormData((prev) => ({ ...prev, [name]: value }));
|
|
136
152
|
|
|
137
153
|
const handleSubmit = (e: React.FormEvent) => {
|
|
138
154
|
e.preventDefault();
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
if (!channelName) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
141
158
|
|
|
142
159
|
const payload: Record<string, unknown> = { ...formData };
|
|
143
160
|
for (const field of fields) {
|
|
144
|
-
if (field.type
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
delete payload[field.name];
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
for (const field of fields) {
|
|
153
|
-
if (field.type !== 'json') {
|
|
154
|
-
continue;
|
|
161
|
+
if (field.type === 'password') {
|
|
162
|
+
const value = payload[field.name];
|
|
163
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
164
|
+
delete payload[field.name];
|
|
165
|
+
}
|
|
155
166
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
if (field.type === 'json') {
|
|
168
|
+
try {
|
|
169
|
+
payload[field.name] = (jsonDrafts[field.name] ?? '').trim() ? JSON.parse(jsonDrafts[field.name]) : {};
|
|
170
|
+
} catch {
|
|
171
|
+
toast.error(`${t('invalidJson')}: ${field.name}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
162
174
|
}
|
|
163
175
|
}
|
|
164
176
|
|
|
@@ -166,21 +178,14 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
166
178
|
};
|
|
167
179
|
|
|
168
180
|
const applyActionPatchToForm = (patch?: Record<string, unknown>) => {
|
|
169
|
-
if (!patch || !channelName) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const channelsNode = patch.channels;
|
|
173
|
-
if (!isRecord(channelsNode)) {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const channelPatch = channelsNode[channelName];
|
|
177
|
-
if (!isRecord(channelPatch)) {
|
|
181
|
+
if (!patch || !channelName || !isRecord(patch.channels) || !isRecord(patch.channels[channelName])) {
|
|
178
182
|
return;
|
|
179
183
|
}
|
|
184
|
+
const channelPatch = patch.channels[channelName] as Record<string, unknown>;
|
|
180
185
|
setFormData((prev) => deepMergeRecords(prev, channelPatch));
|
|
181
186
|
setJsonDrafts((prev) => {
|
|
182
|
-
let changed = false;
|
|
183
187
|
const nextDrafts = { ...prev };
|
|
188
|
+
let changed = false;
|
|
184
189
|
for (const field of fields) {
|
|
185
190
|
if (field.type !== 'json' || !Object.prototype.hasOwnProperty.call(channelPatch, field.name)) {
|
|
186
191
|
continue;
|
|
@@ -200,34 +205,19 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
200
205
|
setRunningActionId(action.id);
|
|
201
206
|
try {
|
|
202
207
|
let nextData = { ...formData };
|
|
203
|
-
|
|
204
208
|
if (action.saveBeforeRun) {
|
|
205
|
-
nextData = {
|
|
206
|
-
...nextData,
|
|
207
|
-
...(action.savePatch ?? {})
|
|
208
|
-
};
|
|
209
|
+
nextData = { ...nextData, ...(action.savePatch ?? {}) };
|
|
209
210
|
setFormData(nextData);
|
|
210
211
|
await updateChannel.mutateAsync({ channel: channelName, data: nextData });
|
|
211
212
|
}
|
|
212
|
-
|
|
213
213
|
const result = await executeAction.mutateAsync({
|
|
214
214
|
actionId: action.id,
|
|
215
|
-
data: {
|
|
216
|
-
scope,
|
|
217
|
-
draftConfig: buildScopeDraft(scope, nextData)
|
|
218
|
-
}
|
|
215
|
+
data: { scope, draftConfig: buildScopeDraft(scope, nextData) }
|
|
219
216
|
});
|
|
220
|
-
|
|
221
217
|
applyActionPatchToForm(result.patch);
|
|
222
|
-
|
|
223
|
-
if (result.ok) {
|
|
224
|
-
toast.success(result.message || t('success'));
|
|
225
|
-
} else {
|
|
226
|
-
toast.error(result.message || t('error'));
|
|
227
|
-
}
|
|
218
|
+
result.ok ? toast.success(result.message || t('success')) : toast.error(result.message || t('error'));
|
|
228
219
|
} catch (error) {
|
|
229
|
-
|
|
230
|
-
toast.error(`${t('error')}: ${message}`);
|
|
220
|
+
toast.error(`${t('error')}: ${error instanceof Error ? error.message : String(error)}`);
|
|
231
221
|
} finally {
|
|
232
222
|
setRunningActionId(null);
|
|
233
223
|
}
|
|
@@ -235,135 +225,114 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
235
225
|
|
|
236
226
|
if (!channelName || !channelMeta || !channelConfig) {
|
|
237
227
|
return (
|
|
238
|
-
<
|
|
228
|
+
<ConfigSplitEmptyPane>
|
|
239
229
|
<div>
|
|
240
|
-
<h3 className=
|
|
241
|
-
<p className=
|
|
230
|
+
<h3 className='text-base font-semibold text-gray-900'>{t('channelsSelectTitle')}</h3>
|
|
231
|
+
<p className='mt-2 text-sm text-gray-500'>{t('channelsSelectDescription')}</p>
|
|
242
232
|
</div>
|
|
243
|
-
</
|
|
233
|
+
</ConfigSplitEmptyPane>
|
|
244
234
|
);
|
|
245
235
|
}
|
|
246
236
|
|
|
247
237
|
const enabled = typeof formData.enabled === 'boolean' ? formData.enabled : Boolean(channelConfig.enabled);
|
|
238
|
+
const channelApplyStatus = buildChannelApplyStatusView(channelApplyState);
|
|
248
239
|
|
|
249
240
|
return (
|
|
250
|
-
<
|
|
251
|
-
<
|
|
252
|
-
<div className=
|
|
253
|
-
<div className=
|
|
254
|
-
<div className=
|
|
241
|
+
<ConfigSplitDetailPane>
|
|
242
|
+
<ConfigSplitPaneHeader className='px-6 py-5'>
|
|
243
|
+
<div className='flex flex-wrap items-center justify-between gap-3'>
|
|
244
|
+
<div className='min-w-0'>
|
|
245
|
+
<div className='flex items-center gap-3'>
|
|
255
246
|
<LogoBadge
|
|
256
247
|
name={channelName}
|
|
257
248
|
src={getChannelLogo(channelName)}
|
|
258
|
-
className={cn(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)}
|
|
262
|
-
imgClassName="h-5 w-5 object-contain"
|
|
263
|
-
fallback={<span className="text-sm font-semibold uppercase text-gray-500">{channelName[0]}</span>}
|
|
249
|
+
className={cn('h-9 w-9 rounded-lg border', enabled ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white')}
|
|
250
|
+
imgClassName='h-5 w-5 object-contain'
|
|
251
|
+
fallback={<span className='text-sm font-semibold uppercase text-gray-500'>{channelName[0]}</span>}
|
|
264
252
|
/>
|
|
265
|
-
<h3 className=
|
|
253
|
+
<h3 className='truncate text-lg font-semibold text-gray-900 capitalize'>{channelLabel}</h3>
|
|
266
254
|
</div>
|
|
267
|
-
<p className=
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
className=
|
|
272
|
-
>
|
|
273
|
-
<BookOpen className="h-3.5 w-3.5" />
|
|
255
|
+
<p className='mt-2 text-sm text-gray-500'>{t('channelsFormDescription')}</p>
|
|
256
|
+
{channelApplyStatus ? <p className={cn('mt-2 text-xs font-medium', channelApplyStatus.className)}>{channelApplyStatus.label}</p> : null}
|
|
257
|
+
{tutorialUrl ? (
|
|
258
|
+
<a href={tutorialUrl} className='mt-2 inline-flex items-center gap-1.5 text-xs text-primary transition-colors hover:text-primary-hover'>
|
|
259
|
+
<BookOpen className='h-3.5 w-3.5' />
|
|
274
260
|
{t('channelsGuideTitle')}
|
|
275
261
|
</a>
|
|
276
|
-
)}
|
|
262
|
+
) : null}
|
|
277
263
|
</div>
|
|
278
264
|
<StatusDot status={enabled ? 'active' : 'inactive'} label={enabled ? t('statusActive') : t('statusInactive')} />
|
|
279
265
|
</div>
|
|
280
|
-
</
|
|
266
|
+
</ConfigSplitPaneHeader>
|
|
281
267
|
|
|
282
|
-
<form onSubmit={handleSubmit} className=
|
|
283
|
-
<
|
|
268
|
+
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
|
269
|
+
<ConfigSplitPaneBody className='space-y-6 px-6 py-5'>
|
|
284
270
|
{layoutBlocks.map((block, index) => {
|
|
285
271
|
if (block.type === 'fields') {
|
|
286
272
|
const blockFields = resolveFieldsForSection(fields, block.section);
|
|
287
273
|
if (blockFields.length === 0) {
|
|
288
274
|
return null;
|
|
289
275
|
}
|
|
276
|
+
const content = (
|
|
277
|
+
<ChannelFormFieldsSection
|
|
278
|
+
channelName={channelName}
|
|
279
|
+
fields={blockFields}
|
|
280
|
+
formData={formData}
|
|
281
|
+
jsonDrafts={jsonDrafts}
|
|
282
|
+
setJsonDrafts={setJsonDrafts}
|
|
283
|
+
updateField={updateField}
|
|
284
|
+
uiHints={uiHints}
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
290
287
|
if (!block.collapsible) {
|
|
291
|
-
return
|
|
292
|
-
<ChannelFormFieldsSection
|
|
293
|
-
key={`${block.type}-${block.section}-${index}`}
|
|
294
|
-
channelName={channelName}
|
|
295
|
-
fields={blockFields}
|
|
296
|
-
formData={formData}
|
|
297
|
-
jsonDrafts={jsonDrafts}
|
|
298
|
-
setJsonDrafts={setJsonDrafts}
|
|
299
|
-
updateField={updateField}
|
|
300
|
-
uiHints={uiHints}
|
|
301
|
-
/>
|
|
302
|
-
);
|
|
288
|
+
return <div key={`${block.type}-${block.section}-${index}`}>{content}</div>;
|
|
303
289
|
}
|
|
304
290
|
return (
|
|
305
|
-
<details key={`${block.type}-${block.section}-${index}`} className=
|
|
306
|
-
<summary className=
|
|
291
|
+
<details key={`${block.type}-${block.section}-${index}`} className='group rounded-2xl border border-gray-200/80 bg-white'>
|
|
292
|
+
<summary className='flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4 text-sm font-medium text-gray-900'>
|
|
307
293
|
<div>
|
|
308
294
|
<p>{block.collapsible.title}</p>
|
|
309
|
-
{block.collapsible.description ?
|
|
310
|
-
<p className="mt-1 text-xs font-normal text-gray-500">{block.collapsible.description}</p>
|
|
311
|
-
) : null}
|
|
295
|
+
{block.collapsible.description ? <p className='mt-1 text-xs font-normal text-gray-500'>{block.collapsible.description}</p> : null}
|
|
312
296
|
</div>
|
|
313
|
-
<ChevronDown className=
|
|
297
|
+
<ChevronDown className='h-4 w-4 text-gray-400 transition-transform group-open:rotate-180' />
|
|
314
298
|
</summary>
|
|
315
|
-
<div className=
|
|
316
|
-
<ChannelFormFieldsSection
|
|
317
|
-
channelName={channelName}
|
|
318
|
-
fields={blockFields}
|
|
319
|
-
formData={formData}
|
|
320
|
-
jsonDrafts={jsonDrafts}
|
|
321
|
-
setJsonDrafts={setJsonDrafts}
|
|
322
|
-
updateField={updateField}
|
|
323
|
-
uiHints={uiHints}
|
|
324
|
-
/>
|
|
325
|
-
</div>
|
|
299
|
+
<div className='space-y-6 border-t border-gray-100 px-5 py-5'>{content}</div>
|
|
326
300
|
</details>
|
|
327
301
|
);
|
|
328
302
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
/>
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return null;
|
|
303
|
+
return block.sectionId === 'weixin-auth' ? (
|
|
304
|
+
<WeixinChannelAuthSection
|
|
305
|
+
key={`${block.type}-${block.sectionId}-${index}`}
|
|
306
|
+
channelConfig={channelConfig}
|
|
307
|
+
formData={formData}
|
|
308
|
+
channelEnabled={enabled}
|
|
309
|
+
disabled={updateChannel.isPending || Boolean(runningActionId)}
|
|
310
|
+
/>
|
|
311
|
+
) : null;
|
|
343
312
|
})}
|
|
344
|
-
</
|
|
313
|
+
</ConfigSplitPaneBody>
|
|
345
314
|
|
|
346
|
-
<
|
|
347
|
-
<div className=
|
|
315
|
+
<ConfigSplitPaneFooter className='flex flex-wrap items-center justify-between gap-3 px-6 py-4'>
|
|
316
|
+
<div className='flex flex-wrap items-center gap-2'>
|
|
348
317
|
{actions
|
|
349
318
|
.filter((action) => action.trigger === 'manual')
|
|
350
319
|
.map((action) => (
|
|
351
320
|
<Button
|
|
352
321
|
key={action.id}
|
|
353
|
-
type=
|
|
322
|
+
type='button'
|
|
354
323
|
onClick={() => handleManualAction(action)}
|
|
355
324
|
disabled={updateChannel.isPending || Boolean(runningActionId)}
|
|
356
|
-
variant=
|
|
325
|
+
variant='secondary'
|
|
357
326
|
>
|
|
358
327
|
{runningActionId === action.id ? t('connecting') : action.title}
|
|
359
328
|
</Button>
|
|
360
329
|
))}
|
|
361
330
|
</div>
|
|
362
|
-
<Button type=
|
|
331
|
+
<Button type='submit' disabled={updateChannel.isPending || Boolean(runningActionId)}>
|
|
363
332
|
{updateChannel.isPending ? t('saving') : t('save')}
|
|
364
333
|
</Button>
|
|
365
|
-
</
|
|
334
|
+
</ConfigSplitPaneFooter>
|
|
366
335
|
</form>
|
|
367
|
-
</
|
|
336
|
+
</ConfigSplitDetailPane>
|
|
368
337
|
);
|
|
369
338
|
}
|