Skip to content

Commit e39045d

Browse files
authored
improvement(data-drains): move drains to the fullscreen list/detail pattern (#5954)
* improvement(data-drains): move drains to the fullscreen list/detail pattern Data drains was the last settings surface still on a Table plus a create modal. It now matches Skills and Custom Tools: - the list is SettingsResourceRow rows in a clickable button, with the source, destination, cadence, and last run on the row and a Disabled tag when a drain is paused - clicking a row opens a detail sub-view: the drain's actions (Run now / Test connection / Delete), its enabled toggle, resolved destination config, and recent run history — replacing the expanding table row - creating a drain is a fullscreen view instead of a modal, reusing the destination form registry so the two never fork - a drain's detail is deep-linkable via `data-drain-id`, pushed on open and replaced on close, matching custom-tool-id and custom-block-id The detail is read-only apart from the enabled toggle: destination credentials are never returned by the API, so changing one means recreating the drain. Alignment work the migration surfaced: - the destination registry rendered its 36 fields with ChipModalField, whose label is byte-identical to a SettingsSection header — on a page that made section titles and field labels indistinguishable. All of them, and the create view's own fields, now use SettingRow like every other page-level detail view - the shared source/destination/cadence label maps moved to labels.ts, now that three files need them - run status uses Badge with a dot rather than hand-coloured text, and byte counts use formatFileSize — the old /1024 math showed a 5 GB export as "5242880.0 KB" - copy follows the sibling surfaces: Create drain, Disabled, Run queued, Connection test passed, and "No drains found matching …" - seed the list cache on create so landing on the new drain's detail doesn't depend on the invalidation refetch succeeding * fix(data-drains): wire destination field labels to their controls The ChipModalField -> SettingRow migration dropped the label/control association that ChipModalField generated for free, so screen readers announced the destination fields unnamed and clicking a label focused nothing. All 35 id-capable controls now carry an id with a matching htmlFor on their row; the one ChipSelect has no id prop, matching how the existing SettingRow + combobox rows render. Also pass includeBytes to formatFileSize — without it any run writing under 1 KB reported '0 Bytes'. * fix(data-drains): name the select fields for assistive tech ChipSelect takes an aria-label that lands on its trigger button, so the four select fields no longer announce as unnamed buttons — the Datadog site plus the create view's source, cadence, and destination type. The visible SettingRow label stays; this only gives the trigger an accessible name, since ChipSelect exposes no id for htmlFor to point at.
1 parent 2272d4c commit e39045d

7 files changed

Lines changed: 743 additions & 495 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,22 @@ export const customToolIdUrlKeys = {
163163
clearOnDefault: true,
164164
} as const
165165

166+
/**
167+
* `data-drain-id` deep-links the Data Drains settings tab to a specific drain's
168+
* detail sub-view. The "create new" flow stays in local state — only existing
169+
* entities are deep-linkable.
170+
*/
171+
export const dataDrainIdParam = {
172+
key: 'data-drain-id',
173+
parser: parseAsString,
174+
} as const
175+
176+
/** Opening a drain's detail is a destination → push to history; clear on close. */
177+
export const dataDrainIdUrlKeys = {
178+
history: 'push',
179+
clearOnDefault: true,
180+
} as const
181+
166182
/**
167183
* `fork-direction` is the sync direction (push/pull) on the parent fork's detail
168184
* page — shareable view state, so a copied link opens the same side of the sync.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ChipInput, ChipSelect, toast } from '@sim/emcn'
5+
import { ArrowLeft, Database } from '@sim/emcn/icons'
6+
import { createLogger } from '@sim/logger'
7+
import { toError } from '@sim/utils/errors'
8+
import type { CreateDataDrainBody } from '@/lib/api/contracts/data-drains'
9+
import type { CADENCE_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
10+
import { DESTINATION_TYPES } from '@/lib/data-drains/types'
11+
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
12+
import {
13+
CredentialDetailHeading,
14+
UnsavedChangesModal,
15+
} from '@/app/workspace/[workspaceId]/components/credential-detail'
16+
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
17+
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
18+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
19+
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
20+
import { SettingRow } from '@/ee/components/setting-row'
21+
import { DESTINATION_FORM_REGISTRY } from '@/ee/data-drains/destinations/registry'
22+
import { useCreateDataDrain } from '@/ee/data-drains/hooks/data-drains'
23+
import {
24+
CADENCE_OPTIONS,
25+
DESTINATION_LABELS,
26+
DESTINATION_OPTIONS,
27+
SOURCE_OPTIONS,
28+
} from '@/ee/data-drains/labels'
29+
30+
const logger = createLogger('DataDrainCreate')
31+
32+
interface DataDrainCreateProps {
33+
organizationId: string
34+
onBack: () => void
35+
/** Lands the caller on the drain it just created, matching the skills create flow. */
36+
onCreated: (drainId: string) => void
37+
}
38+
39+
/**
40+
* Full-page data drain creation rendered as a settings detail sub-view: a back
41+
* chip, a Create action, and the common fields plus the selected destination's
42+
* own fields from {@link DESTINATION_FORM_REGISTRY}.
43+
*/
44+
export function DataDrainCreate({ organizationId, onBack, onCreated }: DataDrainCreateProps) {
45+
const createDrain = useCreateDataDrain()
46+
47+
const [name, setName] = useState('')
48+
const [source, setSource] = useState<(typeof SOURCE_TYPES)[number]>('workflow_logs')
49+
const [cadence, setCadence] = useState<(typeof CADENCE_TYPES)[number]>('daily')
50+
const [destinationType, setDestinationType] = useState<(typeof DESTINATION_TYPES)[number]>(
51+
DESTINATION_TYPES[0]
52+
)
53+
const [destState, setDestState] = useState<unknown>(
54+
() => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState
55+
)
56+
57+
const spec = DESTINATION_FORM_REGISTRY[destinationType]
58+
const canSubmit = name.trim().length > 0 && spec.isComplete(destState)
59+
60+
const submitError = createDrain.error ? toError(createDrain.error).message : null
61+
const isDirty =
62+
name.trim().length > 0 ||
63+
JSON.stringify(destState) !== JSON.stringify(spec.initialState) ||
64+
source !== 'workflow_logs' ||
65+
cadence !== 'daily' ||
66+
destinationType !== DESTINATION_TYPES[0]
67+
68+
const guard = useSettingsUnsavedGuard({ isDirty })
69+
70+
const handleDestinationChange = (next: (typeof DESTINATION_TYPES)[number]) => {
71+
setDestinationType(next)
72+
setDestState(DESTINATION_FORM_REGISTRY[next].initialState)
73+
}
74+
75+
const handleSubmit = async () => {
76+
if (!canSubmit || createDrain.isPending) return
77+
const body = {
78+
name: name.trim(),
79+
source,
80+
scheduleCadence: cadence,
81+
...spec.toDestinationBranch(destState),
82+
} as CreateDataDrainBody
83+
try {
84+
const drain = await createDrain.mutateAsync({ organizationId, body })
85+
toast.success('Drain created')
86+
onCreated(drain.id)
87+
} catch (error) {
88+
const message = toError(error).message
89+
toast.error("Couldn't create drain", { description: message })
90+
logger.error('Failed to create data drain', { error: message })
91+
}
92+
}
93+
94+
const actions: SettingsAction[] = [
95+
{
96+
text: createDrain.isPending ? 'Creating...' : 'Create',
97+
variant: 'primary',
98+
onSelect: handleSubmit,
99+
disabled: !canSubmit || createDrain.isPending,
100+
tooltip: canSubmit ? undefined : 'Name the drain and complete its destination',
101+
},
102+
]
103+
104+
return (
105+
<>
106+
<SettingsPanel
107+
back={{ text: 'Data drains', icon: ArrowLeft, onSelect: () => guard.guardBack(onBack) }}
108+
title='New drain'
109+
actions={actions}
110+
>
111+
<div className='flex flex-col gap-7'>
112+
<CredentialDetailHeading
113+
leading={<ResourceTile icon={Database} />}
114+
title='New drain'
115+
subtitle='Export logs, chats, and runs to your own storage or observability stack on a schedule.'
116+
/>
117+
118+
<SettingsSection label='Drain'>
119+
<div className='flex flex-col gap-4'>
120+
<SettingRow label='Name' htmlFor='data-drain-name'>
121+
<ChipInput
122+
id='data-drain-name'
123+
value={name}
124+
onChange={(e) => setName(e.target.value)}
125+
placeholder='Workflow logs export'
126+
/>
127+
</SettingRow>
128+
<SettingRow label='Source'>
129+
<ChipSelect
130+
aria-label='Source'
131+
value={source}
132+
onChange={(v) => setSource(v as (typeof SOURCE_TYPES)[number])}
133+
options={SOURCE_OPTIONS}
134+
align='start'
135+
/>
136+
</SettingRow>
137+
<SettingRow label='Cadence'>
138+
<ChipSelect
139+
aria-label='Cadence'
140+
value={cadence}
141+
onChange={(v) => setCadence(v as (typeof CADENCE_TYPES)[number])}
142+
options={CADENCE_OPTIONS}
143+
align='start'
144+
/>
145+
</SettingRow>
146+
</div>
147+
</SettingsSection>
148+
149+
<SettingsSection label='Destination'>
150+
<div className='flex flex-col gap-4'>
151+
<SettingRow label='Type'>
152+
<ChipSelect
153+
aria-label='Destination type'
154+
value={destinationType}
155+
onChange={(v) => handleDestinationChange(v as (typeof DESTINATION_TYPES)[number])}
156+
options={DESTINATION_OPTIONS}
157+
displayLabel={DESTINATION_LABELS[destinationType]}
158+
align='start'
159+
/>
160+
</SettingRow>
161+
<spec.FormFields state={destState} setState={setDestState} />
162+
{submitError && (
163+
<p role='alert' className='text-[var(--text-error)] text-caption'>
164+
{submitError}
165+
</p>
166+
)}
167+
</div>
168+
</SettingsSection>
169+
</div>
170+
</SettingsPanel>
171+
172+
<UnsavedChangesModal
173+
open={guard.showUnsavedModal}
174+
onOpenChange={guard.setShowUnsavedModal}
175+
onDiscard={guard.confirmDiscard}
176+
/>
177+
</>
178+
)
179+
}

0 commit comments

Comments
 (0)