Skip to content

Commit 7524f46

Browse files
authored
DT-3070: Add codec server form component (#2797)
* DT-3070: Add codec server form component Implement codec server configuration form using search-attributes-form as template: - Form fields: endpoint URL, access token toggle, CORS credentials toggle - Expandable custom error message and redirect link section - Smart visibility logic for custom fields when values are populated - Zod validation for required URL endpoints and optional custom links - Toggle switches for better UX instead of checkboxes - Tainted state tracking with badge on save button - SuperForms integration with dataType: 'json' and resetForm: false - Skeleton loading states matching existing patterns - API integration with codec adapter for OSS endpoints - Storybook documentation and examples - Direct imports (no barrel exports) for better tree shaking - Svelte 5 state management instead of legacy stores * DT-3070: Update codec adapter to use existing settings service - Use fetchSettings() from settings-service instead of direct API calls - Properly integrate with existing Settings type structure - Follow same pattern as other codec functionality in codebase - Clear messaging about development limitations vs actual functionality * DT-3070: Update codec adapter for cluster-level configuration - Remove namespace parameter from createCodecAdapter() for OSS - OSS operates at cluster level, Cloud will be namespace-scoped - Update messaging and documentation to reflect cluster-level scope - Prepare for future Cloud adapter implementation * refactor: extract reusable FormMessage and TaintedBadge components Create FormMessage component to handle both form validation errors and status messages. Abstract TaintedBadge logic from duplicated implementations in both forms. * refactor: improve form components and add i18n support - Move form components to /form/ directory and rename for better organization - Make TaintedBadge and Message components "dumb" - accept resolved values instead of stores - Add comprehensive i18n translations for codec server form - Fix search attributes form reset function error - Clean up component interfaces with better prop naming (message -> value, formErrors -> errors) - Extract tainted count calculation to derived variables in parent components - Add proper TypeScript types for Alert intent * fix: use proper Holocene Link component and improve translation templating - Replace custom HTML link with Holocene Link component for better consistency - Split endpoint description into prefix/suffix pattern for cleaner templating - Remove manual styling in favor of component defaults - Follow cloud-ui patterns for codec server endpoint links * remove unused codec adapter and custom class stories * fix: only send custom fields when section is visible * refactor: use nullish coalescing operator over logical OR * refactor: move retry logic into ApiError component Move retry state and logic from callers into ApiError component itself. Components now pass retryConfig with retryFn instead of managing retry state externally. - Add internal retry state (retryCount, isRetrying) - Replace onRetry callback with retryConfig interface - Add loading state during retry operations - Simplify caller interface to config-based approach * refactor: make adapter callbacks async * refactor: simplify form config interfaces and call adapter callbacks directly * address comments
1 parent 3d62cfc commit 7524f46

22 files changed

Lines changed: 954 additions & 72 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ SvelteKit + Svelte 5 + TypeScript + TailwindCSS + Holocene design system
77
```bash
88
pnpm lint # Run all linters
99
pnpm check # TypeScript type checking
10-
pnpm test # Run unit tests
10+
pnpm test -- --run # Run unit tests
1111
```
1212

1313
## Svelte 5 Patterns

src/lib/components/api-error.svelte

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,92 @@
22
import Alert from '$lib/holocene/alert.svelte';
33
import Button from '$lib/holocene/button.svelte';
44
import Icon from '$lib/holocene/icon/icon.svelte';
5+
import { translate } from '$lib/i18n/translate';
56
import type { ApiError as ApiErrorType } from '$lib/utilities/api-error-handler';
67
8+
interface RetryConfig {
9+
maxRetries?: number;
10+
onRetry: () => Promise<unknown>;
11+
}
12+
713
interface Props {
814
class?: string;
915
error: ApiErrorType;
10-
retryCount: number;
11-
maxRetries: number;
12-
onRetry: () => void;
16+
retryConfig?: RetryConfig;
1317
title?: string;
1418
}
1519
1620
let {
1721
class: className = '',
1822
error,
19-
retryCount,
20-
maxRetries,
21-
onRetry,
22-
title = 'An Error Occurred',
23+
retryConfig,
24+
title = translate('common.error-occurred'),
2325
}: Props = $props();
26+
27+
let retryCount = $state(0);
28+
let isRetrying = $state(false);
29+
const maxRetries = $derived(retryConfig?.maxRetries ?? 3);
30+
31+
const handleRetry = async () => {
32+
if (!retryConfig || retryCount >= maxRetries) return;
33+
34+
isRetrying = true;
35+
retryCount++;
36+
37+
try {
38+
await retryConfig.onRetry();
39+
} catch (err) {
40+
console.error('Retry failed:', err);
41+
} finally {
42+
isRetrying = false;
43+
}
44+
};
2445
</script>
2546

2647
<div class="space-y-6 {className}">
2748
<Alert intent="error" {title}>
2849
<div class="space-y-3">
2950
<p>
30-
{error.userMessage || 'An unexpected error occurred.'}
51+
{error.userMessage || translate('common.unexpected-error')}
3152
</p>
3253

3354
{#if error.isTemporary}
3455
<p class="text-sm text-orange-600">
35-
This appears to be a temporary issue. Please try again in a few
36-
moments.
56+
{translate('common.temporary-error')}
3757
</p>
3858
{/if}
3959

40-
{#if error.isRetryable && retryCount < maxRetries}
60+
{#if error.isRetryable && retryConfig && retryCount < maxRetries}
4161
<div class="flex gap-2">
42-
<Button variant="secondary" size="sm" on:click={onRetry}>
62+
<Button
63+
variant="secondary"
64+
size="sm"
65+
on:click={handleRetry}
66+
disabled={isRetrying}
67+
>
4368
<Icon name="retry" />
44-
Try Again
69+
{isRetrying
70+
? translate('common.retrying')
71+
: translate('common.try-again')}
4572
</Button>
4673
{#if retryCount > 0}
4774
<span class="text-gray-500 self-center text-sm">
48-
Attempt {retryCount + 1} of {maxRetries + 1}
75+
{translate('common.retry-attempt', {
76+
current: retryCount + 1,
77+
total: maxRetries + 1,
78+
})}
4979
</span>
5080
{/if}
5181
</div>
52-
{:else if retryCount >= maxRetries}
82+
{:else if retryConfig && retryCount >= maxRetries}
5383
<div class="text-gray-600 text-sm">
54-
<p>Unable to complete operation after {maxRetries + 1} attempts.</p>
5584
<p>
56-
Please refresh the page or contact support if the problem persists.
85+
{translate('common.max-retries-exceeded', {
86+
attempts: maxRetries + 1,
87+
})}
88+
</p>
89+
<p>
90+
{translate('common.retry-support-message')}
5791
</p>
5892
</div>
5993
{/if}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { page } from '$app/state';
2+
3+
import { fetchSettings } from '$lib/services/settings-service';
4+
import type { Settings } from '$lib/types/global';
5+
6+
import type { CodecServerAdapter, CodecServerFormData } from './types';
7+
8+
export const createCodecAdapter = (): CodecServerAdapter => ({
9+
async fetchCodecServer(): Promise<CodecServerFormData> {
10+
// Get current settings from page data or fetch fresh
11+
let settings: Settings = page.data?.settings;
12+
13+
if (!settings) {
14+
// Fallback to fetch settings if not available in page data
15+
settings = await fetchSettings();
16+
}
17+
18+
// Extract codec settings from current settings
19+
const codecSettings = settings?.codec;
20+
21+
return {
22+
endpoint: codecSettings?.endpoint ?? '',
23+
passUserAccessToken: codecSettings?.passAccessToken ?? false,
24+
includeCrossOriginCredentials: codecSettings?.includeCredentials ?? false,
25+
customMessage: codecSettings?.customErrorMessage?.default?.message ?? '',
26+
customLink: codecSettings?.customErrorMessage?.default?.link ?? '',
27+
};
28+
},
29+
30+
async saveCodecServer(data: CodecServerFormData): Promise<void> {
31+
// Convert form data to API format matching the existing Settings structure
32+
const codecPayload = {
33+
Endpoint: data.endpoint,
34+
PassAccessToken: data.passUserAccessToken,
35+
IncludeCredentials: data.includeCrossOriginCredentials,
36+
...(data.customMessage && { DefaultErrorMessage: data.customMessage }),
37+
...(data.customLink && { DefaultErrorLink: data.customLink }),
38+
};
39+
40+
// For OSS: cluster-level codec server configuration
41+
// For now, this logs what would be sent to a codec server settings API
42+
// In the future, this could be a PUT/PATCH to /api/v1/settings/codec
43+
console.log('Would save cluster-level codec server settings:', {
44+
codec: codecPayload,
45+
});
46+
47+
// Simulate API call delay
48+
await new Promise((resolve) => setTimeout(resolve, 500));
49+
50+
// For development, we can't actually persist these settings
51+
// but we simulate a successful response
52+
},
53+
54+
onSuccess: async (data: CodecServerFormData) => {
55+
console.log('Cluster-level codec server configuration saved:', data);
56+
// In a real implementation, this might:
57+
// - Trigger a cluster settings refresh
58+
// - Update the global page data
59+
// - Show a success toast
60+
},
61+
62+
onCancel: () => {
63+
console.log('Codec server configuration cancelled');
64+
// In a real implementation, this might:
65+
// - Navigate back to cluster settings
66+
// - Reset any temporary state
67+
},
68+
});
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<script lang="ts">
2+
import Message from '$lib/components/form/message.svelte';
3+
import TaintedBadge from '$lib/components/form/tainted-badge.svelte';
4+
import Alert from '$lib/holocene/alert.svelte';
5+
import Button from '$lib/holocene/button.svelte';
6+
import Card from '$lib/holocene/card.svelte';
7+
import Icon from '$lib/holocene/icon/icon.svelte';
8+
import Input from '$lib/holocene/input/input.svelte';
9+
import Link from '$lib/holocene/link.svelte';
10+
import Textarea from '$lib/holocene/textarea.svelte';
11+
import ToggleSwitch from '$lib/holocene/toggle-switch.svelte';
12+
import { translate } from '$lib/i18n/translate';
13+
14+
import type { CodecServerAdapter, CodecServerFormData } from './types';
15+
16+
import { createFormConfig, createFormHandlers } from './config.svelte';
17+
18+
interface Props {
19+
class?: string;
20+
adapter: CodecServerAdapter;
21+
initialData: CodecServerFormData;
22+
}
23+
24+
let { class: className = '', adapter, initialData }: Props = $props();
25+
26+
const { superFormInstance } = $derived(
27+
createFormConfig(adapter, initialData, () => showCustomSection),
28+
);
29+
30+
const {
31+
form,
32+
errors,
33+
submitting,
34+
message,
35+
enhance,
36+
tainted,
37+
isTainted,
38+
reset,
39+
} = $derived(superFormInstance);
40+
41+
const { handleCancel } = $derived(createFormHandlers(adapter, reset));
42+
43+
const taintedCount = $derived(
44+
Object.values($tainted || {}).filter((value) => value === true).length,
45+
);
46+
47+
// Show custom section if there are existing values
48+
let showCustomSection = $state(
49+
!!(initialData.customMessage || initialData.customLink),
50+
);
51+
</script>
52+
53+
<div class="space-y-6 {className}">
54+
<form use:enhance class="space-y-6">
55+
<Card class="space-y-6">
56+
<!-- Info Alert -->
57+
<Alert intent="info" class="text-sm">
58+
<Icon name="info" slot="icon" />
59+
{translate('codec-server.info-message')}
60+
</Alert>
61+
62+
<!-- Endpoint Input -->
63+
<div class="space-y-2">
64+
<p class="text-gray-700 text-sm">
65+
{translate('codec-server.endpoint-description-prefix')}
66+
<Link
67+
href="https://docs.temporal.io/dataconversion#codec-server"
68+
newTab
69+
>
70+
{translate('codec-server.endpoint-link-text')}
71+
</Link>
72+
{translate('codec-server.endpoint-description-suffix')}
73+
</p>
74+
75+
<Input
76+
id="endpoint"
77+
label={translate('codec-server.endpoint-label')}
78+
labelHidden={true}
79+
name="endpoint"
80+
bind:value={$form.endpoint}
81+
placeholder={translate('codec-server.endpoint-placeholder')}
82+
error={!!$errors.endpoint?.[0]}
83+
hintText={$errors.endpoint?.[0]}
84+
disabled={$submitting}
85+
class="w-full"
86+
/>
87+
</div>
88+
89+
<!-- Toggle Switches -->
90+
<div class="space-y-3">
91+
<ToggleSwitch
92+
id="passUserAccessToken"
93+
label={translate('codec-server.pass-access-token-label')}
94+
bind:checked={$form.passUserAccessToken}
95+
disabled={$submitting}
96+
/>
97+
98+
<ToggleSwitch
99+
id="includeCrossOriginCredentials"
100+
label={translate('codec-server.cross-origin-credentials-label')}
101+
bind:checked={$form.includeCrossOriginCredentials}
102+
disabled={$submitting}
103+
/>
104+
</div>
105+
106+
<!-- Custom Message and Link Section -->
107+
<div class="space-y-4">
108+
<p class="text-gray-600 text-sm">
109+
{translate('codec-server.custom-section-description')}
110+
</p>
111+
112+
{#if !showCustomSection}
113+
<Button
114+
type="button"
115+
variant="secondary"
116+
size="sm"
117+
on:click={() => (showCustomSection = true)}
118+
>
119+
<Icon name="add" class="h-4 w-4" />
120+
{translate('codec-server.add-custom-button')}
121+
</Button>
122+
{/if}
123+
124+
{#if showCustomSection}
125+
<div class="space-y-4">
126+
<div>
127+
<Textarea
128+
id="customMessage"
129+
label={translate('codec-server.custom-message-label')}
130+
name="customMessage"
131+
bind:value={$form.customMessage}
132+
placeholder={translate(
133+
'codec-server.custom-message-placeholder',
134+
)}
135+
disabled={$submitting}
136+
rows={3}
137+
/>
138+
</div>
139+
140+
<div>
141+
<Input
142+
id="customLink"
143+
label={translate('codec-server.custom-link-label')}
144+
name="customLink"
145+
bind:value={$form.customLink}
146+
placeholder={translate('codec-server.custom-link-placeholder')}
147+
error={!!$errors.customLink?.[0]}
148+
hintText={$errors.customLink?.[0]}
149+
disabled={$submitting}
150+
/>
151+
<p class="text-gray-600 mt-1 text-sm">
152+
{translate('codec-server.custom-link-description')}
153+
</p>
154+
</div>
155+
156+
<div>
157+
<Button
158+
type="button"
159+
variant="secondary"
160+
size="sm"
161+
on:click={() => {
162+
showCustomSection = false;
163+
}}
164+
>
165+
<Icon name="trash" class="h-4 w-4" />
166+
{translate('codec-server.remove-custom-button')}
167+
</Button>
168+
</div>
169+
</div>
170+
{/if}
171+
</div>
172+
</Card>
173+
174+
<Message
175+
value={$message}
176+
errors={$errors._errors}
177+
errorsTitle={translate('codec-server.validation-error-title')}
178+
/>
179+
180+
<div class="flex gap-3">
181+
<Button
182+
type="submit"
183+
variant="primary"
184+
disabled={$submitting}
185+
class="relative"
186+
>
187+
{$submitting
188+
? translate('codec-server.saving-button')
189+
: translate('codec-server.save-button')}
190+
<TaintedBadge show={isTainted($tainted)} count={taintedCount} />
191+
</Button>
192+
193+
<Button
194+
type="button"
195+
variant="secondary"
196+
on:click={handleCancel}
197+
disabled={$submitting}
198+
>
199+
{translate('codec-server.cancel-button')}
200+
</Button>
201+
</div>
202+
</form>
203+
</div>

0 commit comments

Comments
 (0)