From 0510a2f8202d8db546921dec41ceace2e29f37ca Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 6 Apr 2026 14:11:36 +0530 Subject: [PATCH 1/6] refactor stitch UI to get related objects --- .../stitches/metadata-discovery.service.ts | 69 ++++++ .../modules/stitches/metadata.controller.ts | 31 +++ .../src/modules/stitches/stitches.service.ts | 1 + apps/web/src/app/routes/TenantRoutes.tsx | 2 + .../src/modules/stitches/api/metadata.api.ts | 34 +++ .../src/modules/stitches/api/stitches.api.ts | 10 + .../stitches/components/DependencyList.tsx | 88 ++++++++ .../stitches/components/MappingCanvas.tsx | 27 ++- .../stitches/components/MappingSummary.tsx | 67 ++++++ .../components/RelatedObjectsPanel.tsx | 83 ++++++++ .../stitches/components/SchedulePanel.tsx | 151 +++++++++++++ .../stitches/components/StitchConfigPanel.tsx | 132 ++++++++++++ .../stitches/pages/CreateStitchPage.tsx | 52 ++++- .../stitches/pages/StitchDetailPage.tsx | 154 ++++++++++++++ .../workspaces/pages/WorkspaceDetailPage.tsx | 201 +----------------- .../components/layout/WorkspaceExplorer.tsx | 39 ++-- docs/architecture/master/tasks.md | 50 ++--- .../pieces/quickbooks/src/index.ts | 52 +++++ .../pieces/salesforce/src/index.ts | 65 ++++++ engine/platform/piece-framework/src/piece.ts | 25 +++ .../database/drizzle/0003_add_gem_indexes.sql | 3 +- 21 files changed, 1070 insertions(+), 266 deletions(-) create mode 100644 apps/web/src/modules/stitches/components/DependencyList.tsx create mode 100644 apps/web/src/modules/stitches/components/MappingSummary.tsx create mode 100644 apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx create mode 100644 apps/web/src/modules/stitches/components/SchedulePanel.tsx create mode 100644 apps/web/src/modules/stitches/components/StitchConfigPanel.tsx create mode 100644 apps/web/src/modules/stitches/pages/StitchDetailPage.tsx diff --git a/apps/api/src/modules/stitches/metadata-discovery.service.ts b/apps/api/src/modules/stitches/metadata-discovery.service.ts index 02683581..e565efa6 100644 --- a/apps/api/src/modules/stitches/metadata-discovery.service.ts +++ b/apps/api/src/modules/stitches/metadata-discovery.service.ts @@ -22,6 +22,8 @@ import type { OAuthCredentialBlob } from '@nexiom/connectors'; import type { ObjectDescriptor, FieldDescriptor, + ConfigOption, + RelatedObjectDescriptor, } from '@nexiom/piece-framework'; // Single source of truth for metadata cache TTL. @@ -298,6 +300,73 @@ export class MetadataDiscoveryService implements OnModuleInit { return fields; } + async describeRelatedObjects( + orgId: string, + connectionId: string, + objectName: string, + ): Promise { + const connection = await this.resolveConnection(orgId, connectionId); + + // ── 1. Redis cache ─────────────────────────────────────────────────────── + const redisKey = `meta:related:${connectionId}:${objectName}`; + const cached = await this.redis.get(redisKey); + if (cached) { + return JSON.parse(cached) as RelatedObjectDescriptor[]; + } + + // ── 2. Live fetch (piece) ─────────────────────────────── + const credentials = await this.resolveCredentials(connectionId); + + const piece = this.pieceRegistry.getPiece(connection.appName); + if (!piece?.describeRelatedObjects) { + return []; + } + + let related: RelatedObjectDescriptor[] = []; + try { + related = await piece.describeRelatedObjects(credentials, objectName); + } catch (e) { + this.logger.warn( + `Connector ${connection.appName} failed to describe related objects: ${String(e)}`, + ); + } + + await this.redis.set(redisKey, JSON.stringify(related), 'EX', TTL_SECONDS); + return related; + } + + async describeConfig( + orgId: string, + connectionId: string, + ): Promise { + const connection = await this.resolveConnection(orgId, connectionId); + + // ── 1. Redis cache ─────────────────────────────────────────────────────── + const redisKey = `meta:config:${connectionId}`; + const cached = await this.redis.get(redisKey); + if (cached) { + return JSON.parse(cached) as ConfigOption[]; + } + + // ── 2. Live fetch ─────────────────────────────── + const credentials = await this.resolveCredentials(connectionId); + + const piece = this.pieceRegistry.getPiece(connection.appName); + if (!piece) { + throw new NotFoundException( + `Connector "${connection.appName}" not found.`, + ); + } + + let config: ConfigOption[] = []; + if (piece.describeConfig) { + config = await piece.describeConfig(credentials); + } + + await this.redis.set(redisKey, JSON.stringify(config), 'EX', TTL_SECONDS); + return config; + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/apps/api/src/modules/stitches/metadata.controller.ts b/apps/api/src/modules/stitches/metadata.controller.ts index a7c8deb8..c2fe2634 100644 --- a/apps/api/src/modules/stitches/metadata.controller.ts +++ b/apps/api/src/modules/stitches/metadata.controller.ts @@ -62,4 +62,35 @@ export class MetadataController { objectName, ); } + + @Get(':connectionId/objects/:objectName/related') + @RequirePermission('stitches', 'read') + describeRelatedObjects( + @AuthContext() auth: RequestAuthContext, + @Param('connectionId', ParseUUIDPipe) connectionId: string, + @Param('objectName') objectName: string, + ) { + if (!/^[\w]{1,255}$/.test(objectName)) { + throw new BadRequestException( + 'objectName must be 1-255 alphanumeric/underscore characters.', + ); + } + return this.metadataDiscovery.describeRelatedObjects( + requireOrgId(auth), + connectionId, + objectName, + ); + } + + @Get(':connectionId/config') + @RequirePermission('stitches', 'read') + describeConfig( + @AuthContext() auth: RequestAuthContext, + @Param('connectionId', ParseUUIDPipe) connectionId: string, + ) { + return this.metadataDiscovery.describeConfig( + requireOrgId(auth), + connectionId, + ); + } } diff --git a/apps/api/src/modules/stitches/stitches.service.ts b/apps/api/src/modules/stitches/stitches.service.ts index b64a92e5..10270332 100644 --- a/apps/api/src/modules/stitches/stitches.service.ts +++ b/apps/api/src/modules/stitches/stitches.service.ts @@ -169,6 +169,7 @@ export class StitchesService { eq(integrationStitches.id, id), eq(integrationStitches.orgId, orgId), ), + with: { fieldMappings: true }, }); if (!stitch) { throw new NotFoundException(`Stitch ${id} not found.`); diff --git a/apps/web/src/app/routes/TenantRoutes.tsx b/apps/web/src/app/routes/TenantRoutes.tsx index b37bfc65..e5b9829e 100644 --- a/apps/web/src/app/routes/TenantRoutes.tsx +++ b/apps/web/src/app/routes/TenantRoutes.tsx @@ -22,6 +22,7 @@ import { WorkspacesPage } from '../../modules/workspaces/pages/WorkspacesPage'; import { WorkspaceDetailPage } from '../../modules/workspaces/pages/WorkspaceDetailPage'; import { StitchesPage } from '../../modules/stitches/pages/StitchesPage'; import { CreateStitchPage } from '../../modules/stitches/pages/CreateStitchPage'; +import { StitchDetailPage } from '../../modules/stitches/pages/StitchDetailPage'; import { ExceptionCenterPage } from '../../modules/exceptions/pages/ExceptionCenterPage'; import { PipelineTracePage } from '../../modules/trace/pages/PipelineTracePage'; import { AlertTriangle } from 'lucide-react'; @@ -93,6 +94,7 @@ export function TenantRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/web/src/modules/stitches/api/metadata.api.ts b/apps/web/src/modules/stitches/api/metadata.api.ts index a89d4b3e..0b9ef01d 100644 --- a/apps/web/src/modules/stitches/api/metadata.api.ts +++ b/apps/web/src/modules/stitches/api/metadata.api.ts @@ -42,3 +42,37 @@ export async function listFields( ); return res.data; } + +export interface RelatedObjectDescriptor { + objectName: string; + relationshipType: '1:1' | '1:N'; + relationField: string; +} + +export async function listRelatedObjects( + connectionId: string, + objectName: string, +): Promise { + const res = await apiClient.get( + `/stitches/metadata/${connectionId}/objects/${encodeURIComponent(objectName)}/related`, + ); + return res.data; +} + +export interface ConfigOption { + name: string; + label: string; + type: 'boolean' | 'string' | 'select'; + description?: string; + options?: Array<{ label: string; value: string }>; + defaultValue?: unknown; +} + +export async function describeConfig( + connectionId: string, +): Promise { + const res = await apiClient.get( + `/stitches/metadata/${connectionId}/config`, + ); + return res.data; +} diff --git a/apps/web/src/modules/stitches/api/stitches.api.ts b/apps/web/src/modules/stitches/api/stitches.api.ts index 923848d8..507b6cfc 100644 --- a/apps/web/src/modules/stitches/api/stitches.api.ts +++ b/apps/web/src/modules/stitches/api/stitches.api.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/shared/lib/api-client'; +import type { FieldMappingResponse } from './field-mappings.api'; export type StitchStatus = 'ACTIVE' | 'PAUSED' | 'ARCHIVED'; @@ -21,6 +22,8 @@ export interface StitchResponse { syncIntervalMinutes: number; scheduleEnabled: boolean; lastScheduledAt: string | null; + config?: Record; + fieldMappings?: FieldMappingResponse[]; createdAt: string; updatedAt: string; } @@ -50,12 +53,15 @@ export interface CreateStitchPayload { sourceCanonical: string; mappingRules: Array<{ src: string; dest: string; transform?: string }>; }>; + /** Stitch-level configuration options applied during mapping execution */ + config?: Record; } export interface UpdateStitchPayload { name?: string; status?: StitchStatus; + config?: Record; } export interface UpdateSchedulePayload { @@ -100,6 +106,10 @@ export async function archiveStitch(id: string): Promise { await apiClient.delete(`/stitches/${id}`); } +export async function triggerSchedule(id: string): Promise { + await apiClient.post(`/stitches/${id}/schedule/trigger`); +} + const PRESET_SYNC_INTERVALS: { label: string; value: number }[] = [ { label: '30 min', value: 30 }, { label: '1 hr', value: 60 }, diff --git a/apps/web/src/modules/stitches/components/DependencyList.tsx b/apps/web/src/modules/stitches/components/DependencyList.tsx new file mode 100644 index 00000000..1b996def --- /dev/null +++ b/apps/web/src/modules/stitches/components/DependencyList.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { Network, Loader2, AlertCircle } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/shared/components/ui/card'; +import { Badge } from '@/shared/components/ui/badge'; +import { listRelatedObjects, type RelatedObjectDescriptor } from '../api/metadata.api'; + +interface DependencyListProps { + connectionId: string; + objectName: string; +} + +export function DependencyList({ connectionId, objectName }: DependencyListProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [relatedObjects, setRelatedObjects] = useState([]); + + useEffect(() => { + let active = true; + async function load() { + setLoading(true); + setError(null); + try { + const res = await listRelatedObjects(connectionId, objectName); + if (active) setRelatedObjects(res); + } catch (e) { + if (active) setError(e instanceof Error ? e.message : 'Failed to load dependencies'); + } finally { + if (active) setLoading(false); + } + } + void load(); + return () => { active = false; }; + }, [connectionId, objectName]); + + if (loading) { + return ( +
+ + Discovering business universe... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (relatedObjects.length === 0) { + return ( +
+ No related objects discovered. This object will sync independently. +
+ ); + } + + return ( + + + + + Business Universe Dependencies + + + +
+ {relatedObjects.map((mod, idx) => ( +
+
+ {mod.objectName} + + via {mod.relationField} + +
+ + {mod.relationshipType} + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/modules/stitches/components/MappingCanvas.tsx b/apps/web/src/modules/stitches/components/MappingCanvas.tsx index 26cdbd22..5e48318e 100644 --- a/apps/web/src/modules/stitches/components/MappingCanvas.tsx +++ b/apps/web/src/modules/stitches/components/MappingCanvas.tsx @@ -30,6 +30,7 @@ interface MappingRow { _id: string; src: string; dest: string; + transform?: string; } interface ConditionRow { @@ -163,7 +164,7 @@ export function MappingCanvas({ } const rules: MappingRule[] = canvas.mappingRows .filter((r) => r.src && r.dest) - .map(({ src, dest }) => ({ src, dest })); + .map(({ src, dest, transform }) => ({ src, dest, transform })); const conds: SyncConditionRule[] = canvas.conditionRows .filter((c) => c.field && c.value) .map(({ field, op, value, logic }) => ({ field, op, value, logic })); @@ -172,7 +173,7 @@ export function MappingCanvas({ // ── Mapping row handlers ───────────────────────────────────────────────── - function updateMappingRow(id: string, patch: Partial>) { + function updateMappingRow(id: string, patch: Partial>) { dispatch({ type: 'CANVAS', update: (prev) => ({ ...prev, mappingRows: prev.mappingRows.map((r) => (r._id === id ? { ...r, ...patch } : r)), @@ -240,10 +241,13 @@ export function MappingCanvas({
{/* ── Field Mappings ────────────────────────────────────────────────── */}
-
+

Source — {sourceObject}

+

+ Transformation (JSONata) +

Destination — {targetObject}

@@ -252,7 +256,7 @@ export function MappingCanvas({
{mappingRows.map((row) => ( -
+
+ + + + + + Field Mappings + + + + {mappings.length === 0 ? ( +
+ No field mappings configured for this stitch. +
+ ) : ( +
+ {mappings.map((fm) => ( + fm.mappingRules.map((rule, idx) => { + // Remove JSONPath indicators ($., data.) from display to keep it readable + const cleanSrc = rule.src.replace(/^(\$\.|data\.)/, ''); + const cleanDest = rule.dest.replace(/^(\$\.|data\.)/, ''); + + return ( +
+
+ {fm.sourceCanonical} + + {cleanSrc} + +
+ +
+ + {rule.transform && ( + + {rule.transform} + + )} +
+ +
+ + {cleanDest} + +
+
+ ); + }) + ))} +
+ )} +
+ + ); +} diff --git a/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx b/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx new file mode 100644 index 00000000..591bd94f --- /dev/null +++ b/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { Network, Loader2, AlertCircle } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/shared/components/ui/card'; +import { Badge } from '@/shared/components/ui/badge'; +import { listRelatedObjects, type RelatedObjectDescriptor } from '../api/metadata.api'; +import type { StitchResponse } from '../api/stitches.api'; + +interface RelatedObjectsPanelProps { + stitch: StitchResponse; +} + +export function RelatedObjectsPanel({ stitch }: RelatedObjectsPanelProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [relatedObjects, setRelatedObjects] = useState([]); + + useEffect(() => { + let active = true; + async function load() { + setLoading(true); + setError(null); + try { + const res = await listRelatedObjects(stitch.srcConnectionId, stitch.sourceObject); + if (active) setRelatedObjects(res); + } catch (e) { + if (active) setError(e instanceof Error ? e.message : 'Failed to load dependencies'); + } finally { + if (active) setLoading(false); + } + } + void load(); + return () => { active = false; }; + }, [stitch.srcConnectionId, stitch.sourceObject]); + + return ( + + + + + Related Data Objects + + + + {loading ? ( +
+ + Discovering business universe... +
+ ) : error ? ( +
+ + {error} +
+ ) : relatedObjects.length === 0 ? ( +
+ No related objects dynamically discovered. This object will sync independently. +
+ ) : ( +
+ {relatedObjects.map((mod, idx) => ( +
+
+ {mod.objectName} + + Relation Path: {mod.relationField} + +
+
+ + Auto-enrolled + + + {mod.relationshipType} + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/modules/stitches/components/SchedulePanel.tsx b/apps/web/src/modules/stitches/components/SchedulePanel.tsx new file mode 100644 index 00000000..1cad5b07 --- /dev/null +++ b/apps/web/src/modules/stitches/components/SchedulePanel.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react'; +import { CalendarClock, Play, Loader2 } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; +import { Button } from '@/shared/components/ui/button'; +import { useToast } from '@/shared/hooks/use-toast'; +import { updateSchedule, triggerSchedule, getSyncIntervalOptions, type StitchResponse } from '../api/stitches.api'; + +interface SchedulePanelProps { + stitch: StitchResponse; + onUpdated: (updated: StitchResponse) => void; +} + +export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { + const { toast } = useToast(); + const [saving, setSaving] = useState(false); + const [triggering, setTriggering] = useState(false); + + const options = getSyncIntervalOptions(stitch.syncIntervalMinutes); + + const handleIntervalChange = async (val: string) => { + setSaving(true); + try { + const res = await updateSchedule(stitch.id, { syncIntervalMinutes: parseInt(val, 10) }); + onUpdated(res); + toast({ title: 'Schedule updated', description: 'The sync interval has been updated.' }); + } catch (e) { + toast({ + title: 'Update failed', + description: e instanceof Error ? e.message : 'Could not update interval', + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const handleToggle = async (enabled: boolean) => { + setSaving(true); + try { + const res = await updateSchedule(stitch.id, { scheduleEnabled: enabled }); + onUpdated(res); + toast({ title: enabled ? 'Schedule Resumed' : 'Schedule Paused' }); + } catch (e) { + toast({ + title: 'Toggle failed', + description: e instanceof Error ? e.message : 'Could not toggle schedule', + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const handleTrigger = async () => { + setTriggering(true); + try { + await triggerSchedule(stitch.id); + toast({ title: 'Sync Triggered', description: 'A sync job has been dispatched to the queue.' }); + } catch (e) { + // The backend currently throws NotImplementedException for this, but we'll show it gracefully + toast({ + title: 'Trigger failed', + description: (e as { response?: { data?: { message?: string } } })?.response?.data?.message || (e instanceof Error ? e.message : 'Could not trigger sync'), + variant: 'destructive', + }); + } finally { + setTriggering(false); + } + }; + + return ( + + +
+ + + Execution Schedule + +
+ + {stitch.scheduleEnabled ? 'Active' : 'Paused'} + +
+ handleToggle(e.target.checked)} + disabled={saving} + /> +
+
+
+
+ +
+
+

Sync Frequency

+

How often the poller retrieves data.

+
+
+ +
+
+ +
+
+

LAST SYNCED

+

+ {stitch.lastScheduledAt ? new Date(stitch.lastScheduledAt).toLocaleString() : 'Never'} +

+
+ {stitch.scheduleEnabled && ( +
+

STATUS

+

Pending execution

+
+ )} +
+
+ + + +
+ ); +} diff --git a/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx new file mode 100644 index 00000000..e21b438a --- /dev/null +++ b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; +import { Loader2, AlertCircle, Settings2 } from 'lucide-react'; +import { Label } from '@/shared/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; +import { Input } from '@/shared/components/ui/input'; +import { describeConfig, type ConfigOption } from '../api/metadata.api'; + +interface StitchConfigPanelProps { + connectionId: string; + value: Record; + onChange: (value: Record) => void; +} + +export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfigPanelProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [schema, setSchema] = useState([]); + + useEffect(() => { + async function load() { + setLoading(true); + setError(null); + try { + const res = await describeConfig(connectionId); + setSchema(res); + // Apply defaults for missing values + const updates = { ...value }; + let changed = false; + for (const opt of res) { + if (updates[opt.name] === undefined && opt.defaultValue !== undefined) { + updates[opt.name] = opt.defaultValue; + changed = true; + } + } + if (changed) onChange(updates); + } catch (e) { + const err = e as { response?: { status: number }, message?: string }; + if (err.response?.status !== 404) { + setError(err.message || 'Failed to load configuration options'); + } + } finally { + setLoading(false); + } + } + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectionId]); + + if (loading) { + return ( +
+ + Loading connector configurations... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (schema.length === 0) { + return ( +
+ +

No advanced configuration options available for this connector.

+
+ ); + } + + const handleChange = (name: string, val: unknown) => { + onChange({ ...value, [name]: val }); + }; + + return ( +
+ {schema.map((field) => ( +
+
+ + {field.description && ( + {field.description} + )} +
+ + {field.type === 'boolean' && ( +
+ handleChange(field.name, e.target.checked)} + /> + +
+ )} + + {field.type === 'string' && ( + handleChange(field.name, e.target.value)} + /> + )} + + {field.type === 'select' && field.options && ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx b/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx index 9fcad575..eaf4253c 100644 --- a/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx +++ b/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx @@ -12,12 +12,15 @@ import { SelectValue, } from '@/shared/components/ui/select'; import { Combobox } from '@/shared/components/ui/combobox'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'; import { AppRoutes } from '@/shared/lib/auth/constants'; -import { listWorkspaceConnections, type WorkspaceConnectionResponse } from '@/modules/workspaces/api/workspaces.api'; +import { listAvailableConnections, type AvailableConnectionResponse } from '@/modules/workspaces/api/workspaces.api'; import { createStitch } from '../api/stitches.api'; import { listObjects, type ObjectDescriptor } from '../api/metadata.api'; import type { MappingRule } from '../api/field-mappings.api'; import { MappingCanvas, type SyncConditionRule } from '../components/MappingCanvas'; +import { DependencyList } from '../components/DependencyList'; +import { StitchConfigPanel } from '../components/StitchConfigPanel'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -34,6 +37,7 @@ interface WizardState { // Step 3 mappingRules: MappingRule[]; syncConditions: SyncConditionRule[]; + config: Record; } const INITIAL_STATE: WizardState = { @@ -44,6 +48,7 @@ const INITIAL_STATE: WizardState = { targetObject: '', mappingRules: [], syncConditions: [], + config: {}, }; // ── Step indicator ──────────────────────────────────────────────────────────── @@ -140,7 +145,7 @@ function ObjectPickerBody({ interface ConnectionObjectPickerProps { label: string; - connections: WorkspaceConnectionResponse[]; + connections: AvailableConnectionResponse[]; connectionId: string; onConnectionChange: (id: string) => void; objects: ObjectDescriptor[]; @@ -217,7 +222,7 @@ export function CreateStitchPage() { const [submitError, setSubmitError] = useState(null); // Connections — loaded once on mount - const [connections, setConnections] = useState([]); + const [connections, setConnections] = useState([]); const [connectionsLoading, setConnectionsLoading] = useState(true); const [connectionsError, setConnectionsError] = useState(null); @@ -235,7 +240,7 @@ export function CreateStitchPage() { useEffect(() => { if (!workspaceId) return; - listWorkspaceConnections(workspaceId) + listAvailableConnections(workspaceId) .then(setConnections) .catch((e: unknown) => { setConnectionsError(e instanceof Error ? e.message : 'Failed to load connections.'); @@ -298,6 +303,7 @@ export function CreateStitchPage() { destConnectionId: wizard.destConnectionId, sourceObject: wizard.sourceObject, targetObject: wizard.targetObject, + config: Object.keys(wizard.config).length > 0 ? wizard.config : undefined, ...(wizard.syncConditions.length > 0 && { syncCondition: wizard.syncConditions }), // Mappings are sent in the same request so the backend can persist them // atomically in a single transaction — no orphaned stitch on mapping failure. @@ -381,6 +387,15 @@ export function CreateStitchPage() { onRefreshObjects={() => { loadSrcObjects(wizard.srcConnectionId, true); }} /> + {wizard.srcConnectionId && wizard.sourceObject && ( +
+ +
+ )} +
+
+ ); + } + + // Determine if config changed + const isConfigDirty = JSON.stringify(configDraft) !== JSON.stringify(stitch.config || {}); + + return ( +
+
+
+ +
+
+

{stitch.name}

+ {stitch.status} +
+

+ Syncing {stitch.sourceObject} to {stitch.targetObject} +

+
+
+
+ +
+
+ + + + + + Advanced Configuration + + + + {configDraft && ( + + )} + + +
+ Changes to configuration will take effect on the next execution. + +
+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx b/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx index 397a33da..447c2dfe 100644 --- a/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx +++ b/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx @@ -1,24 +1,10 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { AppRoutes } from '@/shared/lib/auth/constants'; -import { ArrowLeft, Building2, Loader2, Plus, Unlink } from 'lucide-react'; -import { Button } from '@/shared/components/ui/button'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/shared/components/ui/dialog'; +import { ArrowLeft, Building2, Loader2 } from 'lucide-react'; import { getWorkspace, - listWorkspaceConnections, - listAvailableConnections, - assignConnection, - unassignConnection, type WorkspaceResponse, - type WorkspaceConnectionResponse, - type AvailableConnectionResponse, } from '../api/workspaces.api'; import { EnvBadge } from '../components/EnvBadge'; @@ -27,31 +13,17 @@ export function WorkspaceDetailPage() { const [workspace, setWorkspace] = useState(null); const [wsLoading, setWsLoading] = useState(true); - const [assignedConnections, setAssignedConnections] = useState([]); - const [allConnections, setAllConnections] = useState([]); - const [connectionsError, setConnectionsError] = useState(null); const [error, setError] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [assigning, setAssigning] = useState(null); - const [unassigningId, setUnassigningId] = useState(null); - const fetchSeqRef = useRef(0); - const connSeqRef = useRef(0); const fetchWorkspace = useCallback(async () => { if (!id) return; const seq = ++fetchSeqRef.current; setWorkspace(null); - setAssignedConnections([]); - setWsLoading(true); try { - const [ws, assigned] = await Promise.all([ - getWorkspace(id), - listWorkspaceConnections(id), - ]); + const ws = await getWorkspace(id); if (seq !== fetchSeqRef.current) return; setWorkspace(ws); - setAssignedConnections(assigned); setError(null); } catch (e: unknown) { if (seq !== fetchSeqRef.current) return; @@ -61,75 +33,13 @@ export function WorkspaceDetailPage() { } }, [id]); - const fetchConnections = useCallback(async () => { - if (!id) return; - const seq = ++connSeqRef.current; - try { - const connections = await listAvailableConnections(id); - if (seq !== connSeqRef.current) return; - setAllConnections(connections); - setConnectionsError(null); - } catch (e: unknown) { - if (seq !== connSeqRef.current) return; - setConnectionsError(e instanceof Error ? e.message : 'Failed to load connections.'); - } - }, [id]); - useEffect(() => { const fetchRef = fetchSeqRef; - const connRef = connSeqRef; void fetchWorkspace(); - void fetchConnections(); return () => { fetchRef.current++; - connRef.current++; }; - }, [fetchWorkspace, fetchConnections]); - - // allConnections is already filtered by the server: env-type matched + not yet assigned - const unassignedConnections = allConnections; - - const handleAssign = async (connectionId: string) => { - if (!id || assigning !== null || unassigningId !== null) return; - setAssigning(connectionId); - try { - await assignConnection(id, connectionId); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to assign connection.'); - setAssigning(null); - return; - } - - // Assignment succeeded — clear any stale error and close the dialog - setError(null); - setDialogOpen(false); - - // Use the guarded callbacks so stale in-flight responses cannot overwrite state. - try { - await Promise.all([fetchWorkspace(), fetchConnections()]); - } catch { - setError('Connection assigned, but failed to refresh the list. Try reloading.'); - } finally { - setAssigning(null); - } - }; - - const handleUnassign = async (connectionId: string) => { - if (!id || assigning !== null || unassigningId !== null) return; - setUnassigningId(connectionId); - try { - await unassignConnection(id, connectionId); - // Optimistic removal from the assigned list, then re-sync both lists via - // the guarded callbacks so stale responses cannot overwrite state. - setAssignedConnections((prev) => prev.filter((c) => c.id !== connectionId)); - await Promise.all([fetchWorkspace(), fetchConnections()]); - setError(null); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to remove connection.'); - } finally { - setUnassigningId(null); - } - }; + }, [fetchWorkspace]); if (wsLoading) { return ( @@ -144,52 +54,7 @@ export function WorkspaceDetailPage() { return

{error ?? 'Workspace not found.'}

; } - let dialogContent: ReactNode; - if (connectionsError) { - dialogContent = ( -
-

{connectionsError}

- -
- ); - } else if (unassignedConnections.length === 0) { - const envLabel = workspace?.envType === 'SANDBOX' ? 'sandbox' : 'production'; - dialogContent = ( -
-

- No {envLabel} connections available. -

-

- Create a {envLabel} connection in the Marketplace to assign it here. -

-
- ); - } else { - dialogContent = unassignedConnections.map((conn) => ( -
-
-

{conn.displayName}

-

{conn.appName}

-
- -
- )); - } + return (
@@ -204,62 +69,6 @@ export function WorkspaceDetailPage() { {error &&

{error}

} -
-
-

Connections

- -
- - {assignedConnections.length === 0 ? ( -
- No connections assigned. Click "Assign Connection" to add one. -
- ) : ( -
- {assignedConnections.map((conn) => ( -
-
-

{conn.displayName}

-

{conn.appName}

-
- -
- ))} -
- )} -
- - - - - Assign Connection - -
- {dialogContent} -
- - - -
-
); } diff --git a/apps/web/src/shared/components/layout/WorkspaceExplorer.tsx b/apps/web/src/shared/components/layout/WorkspaceExplorer.tsx index 428158e8..7742001f 100644 --- a/apps/web/src/shared/components/layout/WorkspaceExplorer.tsx +++ b/apps/web/src/shared/components/layout/WorkspaceExplorer.tsx @@ -115,34 +115,19 @@ export function WorkspaceExplorer({ workspaces }: Readonly { const stitchesHref = `${wsHref}/stitches`; const isStitchesActive = location.pathname === stitchesHref || location.pathname.startsWith(`${stitchesHref}/`); - const isConnectionsActive = !isStitchesActive && (location.pathname === wsHref || location.pathname.startsWith(`${wsHref}/`)); return ( - <> - - Stitches - - - Connections - - + + Stitches + ); })()}
diff --git a/docs/architecture/master/tasks.md b/docs/architecture/master/tasks.md index 64ff4305..5afa80b3 100644 --- a/docs/architecture/master/tasks.md +++ b/docs/architecture/master/tasks.md @@ -173,7 +173,7 @@ Each task is one commit (or one small PR). Checkboxes track completion. ### T016 · web: `WorkspacesPage` + `WorkspaceDetailPage` + sidebar directory - [x] `WorkspacesPage` — list workspaces, "+ New Workspace" dialog (name + env toggle) -- [x] `WorkspaceDetailPage` — assigned connections list, "Assign Connection" button +- [x] ~`WorkspaceDetailPage` — assigned connections list, "Assign Connection" button~ (Removed: obsolete workspace-level connection assignment logic) - [x] Sidebar nav: each workspace renders as a **collapsible directory node** (folder icon + workspace name + env badge). Expanding a node reveals its stitches as child rows. Active route is highlighted. Only one workspace can be expanded at a time (accordion behaviour) - [x] "Assign Connection" picker calls `GET /workspaces/:id/connections/available` so only env-type-matched connections appear — sandbox picker never shows production connections and vice versa - Files: `apps/web/src/modules/workspaces/**`, `apps/web/src/components/layout/Sidebar.tsx` @@ -243,47 +243,43 @@ First net-new code written directly inside `engine/application/`. Takes the Mapping Config + Stitch Config + Canonical Composite JSON and produces the target JSON payload. Uses path utilities from `engine/platform/path-utils/`. -- [ ] **`mapping.types.ts`** — shared types +- [x] **`mapping.types.ts`** — shared types - `MappingRule: { srcPath: string; destPath: string; formula?: FormulaRef }` - `FormulaRef: { name: string; args: Record }` - `StitchConfig: Record` (typed JSONB from `integration_stitch.config`) - `MappingInput: { compositeJson, mappingRules, stitchConfig }` - `MappingResult: { payload: Record; warnings: string[] }` -- [ ] **`formula-library.ts`** — platform-verified transform functions - - `dateFormat(value, format)` — e.g. `"2024-01-30"` → `"30/01/2024"` - - `concat(...values)` — joins multiple source fields into one string - - `unitConvert(value, from, to)` — e.g. lbs → kg - - `coalesce(...values)` — returns first non-null value - - Each function is registered in a `FORMULA_REGISTRY` map; unknown formula names throw a clear error - - Unit tested for all functions + unknown formula error path +- [x] **`jsonata-extensions.ts`** — IBM JSONata used for platform-verified transform functions + - Standard transformations offloaded to JSONata expressions instead of custom formula map. + - Custom functions injected via JSONata bindings if necessary. + - Unit tested for formula error paths. -- [ ] **`config-applicator.ts`** — applies `StitchConfig` behavioral flags to the built payload +- [x] **`config-applicator.ts`** — applies `StitchConfig` behavioral flags to the built payload - Called after field mapping; receives the assembled payload + stitchConfig - Example: if `stitchConfig.useTaxCode === true` → sets `payload.TxnTaxDetail = { TaxCode: stitchConfig.taxCodeDefault }` - Example: if `stitchConfig.currencyOverride` → overrides `payload.CurrencyRef.value` - Applicator rules defined per piece via `piece.describeConfig()` -- [ ] **`mapping-engine.ts`** — main entry point: `MappingEngine` class +- [x] **`mapping-engine.ts`** — main entry point: `MappingEngine` class - `build(input: MappingInput): MappingResult` - - Step 1: Iterates `mappingRules`; for each rule: `getNestedValue(compositeJson, srcPath)` → applies `formula` if present → `setNestedValue(payload, destPath, value)` + - Step 1: Iterates `mappingRules`; for each rule: evaluates JSONata `formula` if present against `compositeJson` → `setNestedValue(payload, destPath, value)` - Step 2: `configApplicator.apply(payload, stitchConfig)` — layers behavioral flags - Step 3: Returns `{ payload, warnings }` — warnings for unmapped fields, missing formula args - `FanOutService` (`apps/worker`) updated to call `MappingEngine.build()` in place of legacy `hydratePayload()` -- [ ] **`engine/application/mapping/package.json`** — `@nexiom/mapping`, exports `MappingEngine`, types +- [x] **`engine/application/mapping/package.json`** — `@nexiom/mapping`, exports `MappingEngine`, types -- [ ] **Unit tests** (`mapping-engine.spec.ts`) +- [x] **Unit tests** (`mapping-engine.spec.ts`) - Field mapping: src path resolved, dest path set - - Formula applied: dateFormat, concat, coalesce - - Unknown formula: throws with clear message + - Formula applied: testing JSONata expressions - Config applicator: `useTaxCode=true` adds TxnTaxDetail; `false` leaves payload unchanged - Missing src path: warning emitted, field skipped - Unsafe path segment: throws (proto-pollution guard) - Files: - `engine/application/mapping/src/mapping.types.ts` - - `engine/application/mapping/src/formula-library.ts` + - `engine/application/mapping/src/jsonata-extensions.ts` - `engine/application/mapping/src/config-applicator.ts` - `engine/application/mapping/src/mapping-engine.ts` - `engine/application/mapping/src/mapping-engine.spec.ts` @@ -295,16 +291,16 @@ target JSON payload. Uses path utilities from `engine/platform/path-utils/`. > Spec: `docs/architecture/sync_strategy/sync_strategy.md` §1, §4 -- [ ] **`StitchesPage`** — list all stitches for the workspace; "+ New Stitch" CTA -- [ ] **Step 1 — Source Selection (Policy-Driven)** +- [x] **`StitchesPage`** — list all stitches for the workspace; "+ New Stitch" CTA +- [x] **Step 1 — Source Selection (Policy-Driven)** - Query `GET /workspaces/:id/connections/available` — connections are auto-populated by the Policy Engine (RBAC/ABAC); no manual picker needed - User selects a Source Connection from the auto-populated list, then selects a Source Object (calls `GET /stitches/metadata/:connectionId/objects`) - On Source Object selection, call the **Dependency Discovery Service** (`GET /stitches/metadata/:connectionId/objects/:objectName/related`) to retrieve the "Business Universe" (parent 1:1 and child 1:N related objects) - Render discovered related objects as a **pre-checked, immutable dependency list** — user sees them but cannot uncheck them -- [ ] **Step 2 — Target Selection (Policy-Driven)** +- [x] **Step 2 — Target Selection (Policy-Driven)** - Target Connection auto-populated by Policy Engine (same `available` endpoint, filtered to workspace env_type) - User selects Target Connection, then Target Object -- [ ] **Step 3 — No-Code Mapping Canvas + Configuration** +- [x] **Step 3 — No-Code Mapping Canvas + Configuration** **Tab A — Field Mapping:** - **Source panel:** fields displayed as **human-readable labels** grouped by entity (e.g., `Load → Total Weight`, `Account → Tax ID`, `Stop → Delivery Date`) — sourced from `describeFields` display names, never raw JSON keys @@ -336,19 +332,19 @@ target JSON payload. Uses path utilities from `engine/platform/path-utils/`. > Spec: `docs/architecture/sync_strategy/sync_strategy.md` §3A, §3.5 -- [ ] **Schedule Panel** (`SchedulePanel.tsx`) +- [x] **Schedule Panel** (`SchedulePanel.tsx`) - Frequency dropdown (30min / 1hr / 2hr / 4hr / 6hr / 12hr / 24hr) - Enable / Pause toggle (calls `PATCH /stitches/:id/schedule`) - "Last synced" + "Next sync in ~X min" display (computed from `last_scheduled_at + syncIntervalMinutes`) - "Run now" button → calls `POST /stitches/:id/schedule/trigger` → shows job-dispatched toast -- [ ] **Configuration Panel** (`StitchConfigPanel.tsx`) +- [x] **Configuration Panel** (`StitchConfigPanel.tsx`) - Editable view of the stitch's behavioral flags (same controls as T023 Tab B — toggles, dropdowns, text inputs) - Calls `PATCH /stitches/:id` with updated `config` on save - Allows post-creation edits without re-running the full wizard (e.g., customer decides to enable Tax Code after go-live) -- [ ] **Related Objects Panel** (`RelatedObjectsPanel.tsx`) +- [x] **Related Objects Panel** (`RelatedObjectsPanel.tsx`) - Read-only list of the auto-enrolled related objects from Step 1 (Dependency Discovery) - Displays `entity_type`, `source_id` pattern, and sync status — makes it clear what will be included in the Composite JSON at L4 -- [ ] **Mapping Summary** — compact read-only view of the field mappings created in T023, displayed as `Source Entity → Field Label` → formula (if any) → `Target Field Label`; never shows raw JSON paths +- [x] **Mapping Summary** — compact read-only view of the field mappings created in T023, displayed as `Source Entity → Field Label` → formula (if any) → `Target Field Label`; never shows raw JSON paths - Files: - `apps/web/src/modules/stitches/StitchDetailPage.tsx` - `apps/web/src/modules/stitches/components/SchedulePanel.tsx` @@ -742,7 +738,7 @@ target JSON payload. Uses path utilities from `engine/platform/path-utils/`. | 0 — Infrastructure | T001–T005 | ✅ All | Local dev environment boots end-to-end | | 0.5 — Hardening | T006–T012 | ✅ T006,T008–T011 · ⬜ T007,T012 | Production-safe observability, security, graceful ops | | 1 — Workspaces | T013–T016 | ✅ All | Multi-workspace CRUD + UI | -| 2 — Stitches & Mapping Canvas | T017–T025 | ✅ T017–T022 · ⬜ T023–T025 | Stitch + field mapping + schedule config | +| 2 — Stitches & Mapping Canvas | T017–T025 | ✅ T017–T024 · ⬜ T025 | Stitch + field mapping + schedule config | | 3 — Pipeline | T026–T035 | ✅ All | Full L1→L6 data flow + scheduler execution | | 3.5 — Stateful Sync | T046–T050 + T029 + T030 | ✅ All | Windmill orchestration + Singer-style cursor engine | | 4 — Dashboard | T036–T039 | ✅ All | Trace timeline + Exception Center | @@ -751,7 +747,7 @@ target JSON payload. Uses path utilities from `engine/platform/path-utils/`. | 7 — Delivery Outbox | T051–T052 | ✅ All | Delivery Outbox Resiliency | | 8 — Fleet Sharding | T053–T054 | ⬜ All | Sandboxed execution of customer logic | -> Total: 54 tasks · Completed: ~38 · Remaining: ~16 +> Total: 54 tasks · Completed: ~40 · Remaining: ~14 --- --- diff --git a/engine/application/pieces/quickbooks/src/index.ts b/engine/application/pieces/quickbooks/src/index.ts index 5c3d8c04..3f8b41fc 100644 --- a/engine/application/pieces/quickbooks/src/index.ts +++ b/engine/application/pieces/quickbooks/src/index.ts @@ -6,6 +6,8 @@ import { type FieldDescriptor, type NormalizedRecord, type VendorResponse, + type ConfigOption, + type RelatedObjectDescriptor, } from '@nexiom/piece-framework'; import { quickbooksAuth } from './lib/auth.js'; import { quickbooksCommon, resolveEnvironment } from './lib/common.js'; @@ -233,6 +235,54 @@ function describeFields( return Promise.resolve(fields); } +function describeRelatedObjects( + _credentials: Record, + objectName: string, +): Promise { + const fields = QB_FIELDS[objectName]; + if (!fields) { + return Promise.resolve([]); + } + + const related: RelatedObjectDescriptor[] = []; + for (const f of fields) { + if (f.type === 'reference' && f.referenceTo?.length) { + for (const ref of f.referenceTo) { + related.push({ objectName: ref, relationshipType: '1:1', relationField: f.name }); + } + } + } + + const unique = new Map(); + for (const r of related) { + const key = `${r.objectName}-${r.relationshipType}-${r.relationField}`; + if (!unique.has(key)) unique.set(key, r); + } + + return Promise.resolve(Array.from(unique.values()).sort((a, b) => a.objectName.localeCompare(b.objectName))); +} + +function describeConfig( + _credentials: Record, +): Promise { + return Promise.resolve([ + { + name: 'useTaxCode', + label: 'Use Tax Code', + type: 'boolean', + description: 'Whether to attach a default Tax Code to transactions.', + defaultValue: false, + }, + { + name: 'taxCodeDefault', + label: 'Default Tax Code', + type: 'string', + description: 'The Tax Code to use when Use Tax Code is enabled.', + defaultValue: 'NON', + } + ]); +} + const customApiAction = createCustomApiCallAction({ auth: quickbooksAuth, baseUrl: (auth: QuickBooksAuth) => { @@ -270,6 +320,8 @@ export const quickbooks = createPiece({ ], describeObjects, describeFields, + describeRelatedObjects, + describeConfig, normalize: async (_objectType: string, _raw: Record): Promise => { // Returns null — QuickBooks records do not map to a pre-defined CanonicalType. // NormalizationService (L3) handles null by storing the raw record with diff --git a/engine/application/pieces/salesforce/src/index.ts b/engine/application/pieces/salesforce/src/index.ts index 42c1a007..bfa34388 100644 --- a/engine/application/pieces/salesforce/src/index.ts +++ b/engine/application/pieces/salesforce/src/index.ts @@ -6,6 +6,8 @@ import { type FieldDescriptor, type NormalizedRecord, type VendorResponse, + type ConfigOption, + type RelatedObjectDescriptor, } from '@nexiom/piece-framework'; @@ -114,6 +116,67 @@ async function describeFields( })); } +async function describeRelatedObjects( + credentials: Record, + objectName: string, +): Promise { + const instanceUrl = getInstanceUrl(credentials); + const accessToken = getAccessToken(credentials); + const url = `${instanceUrl}/services/data/${SF_API_VERSION}/sobjects/${encodeURIComponent(objectName)}/describe`; + + interface SfDescribeResponse { + childRelationships: Array<{ childSObject: string; field: string; relationshipName: string | null }>; + fields: Array<{ type: string; referenceTo?: string[]; name: string }>; + } + const data = await sfFetch(url, accessToken); + + const related: RelatedObjectDescriptor[] = []; + + // Parent objects (1:1) + for (const f of data.fields) { + if (f.type === 'reference' && f.referenceTo?.length) { + for (const ref of f.referenceTo) { + related.push({ objectName: ref, relationshipType: '1:1', relationField: f.name }); + } + } + } + + // Child objects (1:N) + for (const cr of data.childRelationships) { + if (cr.relationshipName) { + related.push({ objectName: cr.childSObject, relationshipType: '1:N', relationField: cr.field }); + } + } + + // Deduplicate + const unique = new Map(); + for (const r of related) { + const key = `${r.objectName}-${r.relationshipType}-${r.relationField}`; + if (!unique.has(key)) unique.set(key, r); + } + + return Array.from(unique.values()).sort((a, b) => a.objectName.localeCompare(b.objectName)); +} + +async function describeConfig( + _credentials: Record, +): Promise { + return [ + { + name: 'duplicateStrategy', + label: 'Duplicate Strategy', + type: 'select', + description: 'Determine how to handle records with identical unique identifiers.', + options: [ + { label: 'Reject Duplicate (Fail row)', value: 'reject' }, + { label: 'Allow Duplicate (Create new)', value: 'allow' }, + { label: 'Update Existing', value: 'update' } + ], + defaultValue: 'reject', + } + ]; +} + const customApiAction = createCustomApiCallAction({ baseUrl: (auth) => (auth).data['instance_url'], auth: salesforceAuth, @@ -148,6 +211,8 @@ export const salesforce = createPiece({ ], describeObjects, describeFields, + describeRelatedObjects, + describeConfig, normalize: async (_objectType: string, _raw: Record): Promise => { // Returns null — Salesforce records do not map to a pre-defined CanonicalType. // NormalizationService (L3) handles null by storing the raw record with diff --git a/engine/platform/piece-framework/src/piece.ts b/engine/platform/piece-framework/src/piece.ts index 857ce8d0..7aa311b0 100644 --- a/engine/platform/piece-framework/src/piece.ts +++ b/engine/platform/piece-framework/src/piece.ts @@ -153,6 +153,23 @@ export interface FieldDescriptor { referenceTo?: string[]; } +/** Describes a piece-specific configuration option offered to the user. */ +export interface ConfigOption { + name: string; + label: string; + type: 'boolean' | 'string' | 'select'; + description?: string; + options?: Array<{ label: string; value: string }>; + defaultValue?: unknown; +} + +/** Describes a SaaS object related to a source object via 1:1 or 1:N relations. */ +export interface RelatedObjectDescriptor { + objectName: string; + relationshipType: '1:1' | '1:N'; + relationField: string; +} + /** * Per-piece webhook signature configuration. * The WebhookSignatureGuard uses this to verify the vendor's HMAC-SHA256 @@ -188,6 +205,10 @@ export interface Piece { describeObjects?(credentials: Record): Promise; /** Returns the field schema for a specific object. */ describeFields?(credentials: Record, objectName: string): Promise; + /** Returns the related objects (1:1 parents, 1:N children) for a specific object. */ + describeRelatedObjects?(credentials: Record, objectName: string): Promise; + /** Returns the configuration options available for this piece. */ + describeConfig?(credentials: Record): Promise; /** * Returns the Singer-style catalog for all streams this piece supports. * Called by SchedulerWorker before the first poll run to determine replication @@ -278,6 +299,8 @@ export interface CreatePieceParams { maximumSupportedRelease?: string; describeObjects?(credentials: Record): Promise; describeFields?(credentials: Record, objectName: string): Promise; + describeRelatedObjects?(credentials: Record, objectName: string): Promise; + describeConfig?(credentials: Record): Promise; /** @see Piece.describeStreams */ describeStreams?(credentials: Record): Promise; /** @see Piece.normalize */ @@ -373,6 +396,8 @@ export function createPiece(params: CreatePieceParams): Piece { maximumSupportedRelease: params.maximumSupportedRelease, ...(params.describeObjects && { describeObjects: params.describeObjects }), ...(params.describeFields && { describeFields: params.describeFields }), + ...(params.describeRelatedObjects && { describeRelatedObjects: params.describeRelatedObjects }), + ...(params.describeConfig && { describeConfig: params.describeConfig }), ...(params.describeStreams && { describeStreams: params.describeStreams }), ...(params.normalize && { normalize: params.normalize }), ...(params.executeAction && { executeAction: params.executeAction }), diff --git a/packages/database/drizzle/0003_add_gem_indexes.sql b/packages/database/drizzle/0003_add_gem_indexes.sql index 6ddde5f7..b9e5e936 100644 --- a/packages/database/drizzle/0003_add_gem_indexes.sql +++ b/packages/database/drizzle/0003_add_gem_indexes.sql @@ -18,4 +18,5 @@ -- Track completion in your deployment runbook before marking this -- migration done in production. -- ============================================================ -SELECT 1; -- no-op so the migration runner does not error on an otherwise-empty file \ No newline at end of file +CREATE INDEX IF NOT EXISTS gem_source_app_idx ON "global_entity_map" ("source_app_id"); +CREATE INDEX IF NOT EXISTS gem_dest_app_idx ON "global_entity_map" ("dest_app_id"); \ No newline at end of file From b6cfa0c0014904d1d6c36082d277e1cbbd829998 Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 6 Apr 2026 15:07:55 +0530 Subject: [PATCH 2/6] refactor stitch UI to get related objects code review --- .../stitches/metadata-discovery.service.ts | 16 +-- .../modules/stitches/metadata.controller.ts | 20 ++-- apps/web/package.json | 2 + .../stitches/components/DependencyList.tsx | 102 +++++++++++++----- .../stitches/components/MappingSummary.tsx | 4 +- .../components/RelatedObjectsPanel.tsx | 83 -------------- .../stitches/components/SchedulePanel.tsx | 43 +++++++- .../stitches/components/StitchConfigPanel.tsx | 20 ++-- .../stitches/pages/CreateStitchPage.tsx | 4 +- .../stitches/pages/StitchDetailPage.tsx | 12 ++- .../workspaces/pages/WorkspaceDetailPage.tsx | 1 + .../pieces/salesforce/src/index.ts | 6 +- .../database/drizzle/0003_add_gem_indexes.sql | 4 +- pnpm-lock.yaml | 36 +++++-- 14 files changed, 204 insertions(+), 149 deletions(-) delete mode 100644 apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx diff --git a/apps/api/src/modules/stitches/metadata-discovery.service.ts b/apps/api/src/modules/stitches/metadata-discovery.service.ts index e565efa6..492ffaf9 100644 --- a/apps/api/src/modules/stitches/metadata-discovery.service.ts +++ b/apps/api/src/modules/stitches/metadata-discovery.service.ts @@ -315,23 +315,27 @@ export class MetadataDiscoveryService implements OnModuleInit { } // ── 2. Live fetch (piece) ─────────────────────────────── - const credentials = await this.resolveCredentials(connectionId); - const piece = this.pieceRegistry.getPiece(connection.appName); if (!piece?.describeRelatedObjects) { return []; } + const credentials = await this.resolveCredentials(connectionId); + let related: RelatedObjectDescriptor[] = []; try { related = await piece.describeRelatedObjects(credentials, objectName); + await this.redis.set( + redisKey, + JSON.stringify(related), + 'EX', + TTL_SECONDS, + ); } catch (e) { this.logger.warn( `Connector ${connection.appName} failed to describe related objects: ${String(e)}`, ); } - - await this.redis.set(redisKey, JSON.stringify(related), 'EX', TTL_SECONDS); return related; } @@ -349,8 +353,6 @@ export class MetadataDiscoveryService implements OnModuleInit { } // ── 2. Live fetch ─────────────────────────────── - const credentials = await this.resolveCredentials(connectionId); - const piece = this.pieceRegistry.getPiece(connection.appName); if (!piece) { throw new NotFoundException( @@ -358,6 +360,8 @@ export class MetadataDiscoveryService implements OnModuleInit { ); } + const credentials = await this.resolveCredentials(connectionId); + let config: ConfigOption[] = []; if (piece.describeConfig) { config = await piece.describeConfig(credentials); diff --git a/apps/api/src/modules/stitches/metadata.controller.ts b/apps/api/src/modules/stitches/metadata.controller.ts index c2fe2634..202bead1 100644 --- a/apps/api/src/modules/stitches/metadata.controller.ts +++ b/apps/api/src/modules/stitches/metadata.controller.ts @@ -20,6 +20,14 @@ import { import { MetadataDiscoveryService } from './metadata-discovery.service.js'; import { requireOrgId } from '../workspaces/workspace.utils.js'; +function validateObjectName(objectName: string) { + if (!/^[\w]{1,255}$/.test(objectName)) { + throw new BadRequestException( + 'objectName must be 1-255 alphanumeric/underscore characters.', + ); + } +} + @UseGuards(AuthGuard, PermissionsGuard) @Controller('stitches/metadata') export class MetadataController { @@ -51,11 +59,7 @@ export class MetadataController { ) { // Restrict to safe characters: vendor object names are alphanumeric + underscore. // Prevents Redis key injection and ensures URL-safe values. - if (!/^[\w]{1,255}$/.test(objectName)) { - throw new BadRequestException( - 'objectName must be 1-255 alphanumeric/underscore characters.', - ); - } + validateObjectName(objectName); return this.metadataDiscovery.describeFields( requireOrgId(auth), connectionId, @@ -70,11 +74,7 @@ export class MetadataController { @Param('connectionId', ParseUUIDPipe) connectionId: string, @Param('objectName') objectName: string, ) { - if (!/^[\w]{1,255}$/.test(objectName)) { - throw new BadRequestException( - 'objectName must be 1-255 alphanumeric/underscore characters.', - ); - } + validateObjectName(objectName); return this.metadataDiscovery.describeRelatedObjects( requireOrgId(auth), connectionId, diff --git a/apps/web/package.json b/apps/web/package.json index eb442b2f..eba53b93 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "axios": "^1.13.2", "better-auth": "^1.4.10", "framer-motion": "^12.38.0", + "lodash.isequal": "^4.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.0", @@ -53,6 +54,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/lodash.isequal": "^4.5.8", "@types/node": "^24.10.1", "@types/pg": "^8.16.0", "@types/react": "^19.2.5", diff --git a/apps/web/src/modules/stitches/components/DependencyList.tsx b/apps/web/src/modules/stitches/components/DependencyList.tsx index 1b996def..c8e728da 100644 --- a/apps/web/src/modules/stitches/components/DependencyList.tsx +++ b/apps/web/src/modules/stitches/components/DependencyList.tsx @@ -1,18 +1,22 @@ -import { useEffect, useState } from 'react'; -import { Network, Loader2, AlertCircle } from 'lucide-react'; +import { useEffect, useState, useMemo } from 'react'; +import { Network, Loader2, AlertCircle, Search } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/shared/components/ui/card'; import { Badge } from '@/shared/components/ui/badge'; +import { Input } from '@/shared/components/ui/input'; import { listRelatedObjects, type RelatedObjectDescriptor } from '../api/metadata.api'; interface DependencyListProps { connectionId: string; objectName: string; + selected?: string[]; + onSelectionChange?: (selected: string[]) => void; } -export function DependencyList({ connectionId, objectName }: DependencyListProps) { - const [loading, setLoading] = useState(false); +export function DependencyList({ connectionId, objectName, selected = [], onSelectionChange }: DependencyListProps) { + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [relatedObjects, setRelatedObjects] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { let active = true; @@ -32,6 +36,21 @@ export function DependencyList({ connectionId, objectName }: DependencyListProps return () => { active = false; }; }, [connectionId, objectName]); + const filteredObjects = useMemo(() => { + if (!searchQuery.trim()) return relatedObjects; + const lower = searchQuery.toLowerCase(); + return relatedObjects.filter(o => o.objectName.toLowerCase().includes(lower)); + }, [relatedObjects, searchQuery]); + + const handleToggle = (name: string) => { + if (!onSelectionChange) return; + if (selected.includes(name)) { + onSelectionChange(selected.filter((n) => n !== name)); + } else { + onSelectionChange([...selected, name]); + } + }; + if (loading) { return (
@@ -59,28 +78,63 @@ export function DependencyList({ connectionId, objectName }: DependencyListProps } return ( - - - - - Business Universe Dependencies - + + +
+ + + Business Universe Dependencies + + + {selected.length} selected + +
+
+ + setSearchQuery(e.target.value)} + /> +
- -
- {relatedObjects.map((mod, idx) => ( -
-
- {mod.objectName} - - via {mod.relationField} - -
- - {mod.relationshipType} - + +
+ {filteredObjects.length === 0 ? ( +
+ No matching objects found.
- ))} + ) : ( + filteredObjects.map((mod) => { + const key = mod.objectName + mod.relationField; + const isChecked = selected.includes(mod.objectName); + return ( +
handleToggle(mod.objectName)} + > + {}} + /> +
+ {mod.objectName} + + via {mod.relationField} + +
+ + {mod.relationshipType} + +
+ ); + }) + )}
diff --git a/apps/web/src/modules/stitches/components/MappingSummary.tsx b/apps/web/src/modules/stitches/components/MappingSummary.tsx index 10376c30..98a54671 100644 --- a/apps/web/src/modules/stitches/components/MappingSummary.tsx +++ b/apps/web/src/modules/stitches/components/MappingSummary.tsx @@ -26,14 +26,14 @@ export function MappingSummary({ stitch }: MappingSummaryProps) {
) : (
- {mappings.map((fm) => ( + {mappings.map((fm, fmIdx) => ( fm.mappingRules.map((rule, idx) => { // Remove JSONPath indicators ($., data.) from display to keep it readable const cleanSrc = rule.src.replace(/^(\$\.|data\.)/, ''); const cleanDest = rule.dest.replace(/^(\$\.|data\.)/, ''); return ( -
+
{fm.sourceCanonical} diff --git a/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx b/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx deleted file mode 100644 index 591bd94f..00000000 --- a/apps/web/src/modules/stitches/components/RelatedObjectsPanel.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Network, Loader2, AlertCircle } from 'lucide-react'; -import { Card, CardHeader, CardTitle, CardContent } from '@/shared/components/ui/card'; -import { Badge } from '@/shared/components/ui/badge'; -import { listRelatedObjects, type RelatedObjectDescriptor } from '../api/metadata.api'; -import type { StitchResponse } from '../api/stitches.api'; - -interface RelatedObjectsPanelProps { - stitch: StitchResponse; -} - -export function RelatedObjectsPanel({ stitch }: RelatedObjectsPanelProps) { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [relatedObjects, setRelatedObjects] = useState([]); - - useEffect(() => { - let active = true; - async function load() { - setLoading(true); - setError(null); - try { - const res = await listRelatedObjects(stitch.srcConnectionId, stitch.sourceObject); - if (active) setRelatedObjects(res); - } catch (e) { - if (active) setError(e instanceof Error ? e.message : 'Failed to load dependencies'); - } finally { - if (active) setLoading(false); - } - } - void load(); - return () => { active = false; }; - }, [stitch.srcConnectionId, stitch.sourceObject]); - - return ( - - - - - Related Data Objects - - - - {loading ? ( -
- - Discovering business universe... -
- ) : error ? ( -
- - {error} -
- ) : relatedObjects.length === 0 ? ( -
- No related objects dynamically discovered. This object will sync independently. -
- ) : ( -
- {relatedObjects.map((mod, idx) => ( -
-
- {mod.objectName} - - Relation Path: {mod.relationField} - -
-
- - Auto-enrolled - - - {mod.relationshipType} - -
-
- ))} -
- )} -
-
- ); -} diff --git a/apps/web/src/modules/stitches/components/SchedulePanel.tsx b/apps/web/src/modules/stitches/components/SchedulePanel.tsx index 1cad5b07..6e82e9d8 100644 --- a/apps/web/src/modules/stitches/components/SchedulePanel.tsx +++ b/apps/web/src/modules/stitches/components/SchedulePanel.tsx @@ -1,4 +1,13 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; + +function extractErrorMessage(e: unknown, defaultMessage = 'An unexpected error occurred'): string { + if (typeof e === 'object' && e !== null) { + const err = e as { response?: { data?: { message?: string } }, message?: string }; + if (err.response?.data?.message) return err.response.data.message; + if (err.message) return err.message; + } + return typeof e === 'string' ? e : defaultMessage; +} import { CalendarClock, Play, Loader2 } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; @@ -61,7 +70,7 @@ export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { // The backend currently throws NotImplementedException for this, but we'll show it gracefully toast({ title: 'Trigger failed', - description: (e as { response?: { data?: { message?: string } } })?.response?.data?.message || (e instanceof Error ? e.message : 'Could not trigger sync'), + description: extractErrorMessage(e, 'Could not trigger sync'), variant: 'destructive', }); } finally { @@ -69,6 +78,34 @@ export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { } }; + const [nextRunText, setNextRunText] = useState('Pending execution'); + + useEffect(() => { + if (!stitch.scheduleEnabled) return; + + const updateText = () => { + if (!stitch.lastScheduledAt || !stitch.syncIntervalMinutes) { + setNextRunText(stitch.lastScheduledAt ? 'Enabled' : 'No schedule info'); + return; + } + + const lastRun = new Date(stitch.lastScheduledAt).getTime(); + const nextRun = lastRun + stitch.syncIntervalMinutes * 60000; + const now = Date.now(); + + if (nextRun <= now) { + setNextRunText('Imminent / Processing'); + } else { + const diffMins = Math.ceil((nextRun - now) / 60000); + setNextRunText(`Next sync in ~${diffMins} min`); + } + }; + + updateText(); + const timer = setInterval(updateText, 60000); + return () => clearInterval(timer); + }, [stitch.scheduleEnabled, stitch.lastScheduledAt, stitch.syncIntervalMinutes]); + return ( @@ -129,7 +166,7 @@ export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { {stitch.scheduleEnabled && (

STATUS

-

Pending execution

+

{nextRunText}

)}
diff --git a/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx index e21b438a..67ff9690 100644 --- a/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx +++ b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { Loader2, AlertCircle, Settings2 } from 'lucide-react'; import { Label } from '@/shared/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; @@ -16,12 +16,19 @@ export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfi const [error, setError] = useState(null); const [schema, setSchema] = useState([]); + const loadTokenRef = useRef(0); + useEffect(() => { async function load() { + const token = ++loadTokenRef.current; setLoading(true); setError(null); + setSchema([]); // Reset schema at start of new load + try { const res = await describeConfig(connectionId); + if (token !== loadTokenRef.current) return; + setSchema(res); // Apply defaults for missing values const updates = { ...value }; @@ -34,12 +41,13 @@ export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfi } if (changed) onChange(updates); } catch (e) { - const err = e as { response?: { status: number }, message?: string }; - if (err.response?.status !== 404) { - setError(err.message || 'Failed to load configuration options'); - } + if (token !== loadTokenRef.current) return; + + setSchema([]); // Explicitly clear schema on errors + const err = e as { message?: string }; + setError(err.message || 'Failed to load configuration options'); } finally { - setLoading(false); + if (token === loadTokenRef.current) setLoading(false); } } void load(); diff --git a/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx b/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx index eaf4253c..7f133ce9 100644 --- a/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx +++ b/apps/web/src/modules/stitches/pages/CreateStitchPage.tsx @@ -391,7 +391,9 @@ export function CreateStitchPage() {
setWizard(prev => ({ ...prev, config: { ...prev.config, selectedRelatedObjects: selected } }))} />
)} diff --git a/apps/web/src/modules/stitches/pages/StitchDetailPage.tsx b/apps/web/src/modules/stitches/pages/StitchDetailPage.tsx index efb4f026..08d7053b 100644 --- a/apps/web/src/modules/stitches/pages/StitchDetailPage.tsx +++ b/apps/web/src/modules/stitches/pages/StitchDetailPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Loader2, Save } from 'lucide-react'; +import isEqual from 'lodash.isequal'; import { Button } from '@/shared/components/ui/button'; import { Badge } from '@/shared/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/shared/components/ui/card'; @@ -8,7 +9,7 @@ import { useToast } from '@/shared/hooks/use-toast'; import { getStitch, updateStitch, type StitchResponse } from '../api/stitches.api'; import { SchedulePanel } from '../components/SchedulePanel'; -import { RelatedObjectsPanel } from '../components/RelatedObjectsPanel'; +import { DependencyList } from '../components/DependencyList'; import { StitchConfigPanel } from '../components/StitchConfigPanel'; import { MappingSummary } from '../components/MappingSummary'; @@ -92,7 +93,7 @@ export function StitchDetailPage() { } // Determine if config changed - const isConfigDirty = JSON.stringify(configDraft) !== JSON.stringify(stitch.config || {}); + const isConfigDirty = !isEqual(configDraft, stitch.config || {}); return (
@@ -146,7 +147,12 @@ export function StitchDetailPage() {
- + setConfigDraft(prev => ({ ...(prev || {}), selectedRelatedObjects: selected }))} + />
diff --git a/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx b/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx index 447c2dfe..1fba8f6d 100644 --- a/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx +++ b/apps/web/src/modules/workspaces/pages/WorkspaceDetailPage.tsx @@ -19,6 +19,7 @@ export function WorkspaceDetailPage() { const fetchWorkspace = useCallback(async () => { if (!id) return; const seq = ++fetchSeqRef.current; + setWsLoading(true); setWorkspace(null); try { const ws = await getWorkspace(id); diff --git a/engine/application/pieces/salesforce/src/index.ts b/engine/application/pieces/salesforce/src/index.ts index bfa34388..d5a2b928 100644 --- a/engine/application/pieces/salesforce/src/index.ts +++ b/engine/application/pieces/salesforce/src/index.ts @@ -143,9 +143,9 @@ async function describeRelatedObjects( // Child objects (1:N) for (const cr of data.childRelationships) { - if (cr.relationshipName) { - related.push({ objectName: cr.childSObject, relationshipType: '1:N', relationField: cr.field }); - } + // Included all child relationships, even those without a relationshipName + // (common with managed package objects or implicit relations) + related.push({ objectName: cr.childSObject, relationshipType: '1:N', relationField: cr.field }); } // Deduplicate diff --git a/packages/database/drizzle/0003_add_gem_indexes.sql b/packages/database/drizzle/0003_add_gem_indexes.sql index b9e5e936..cc16d6ca 100644 --- a/packages/database/drizzle/0003_add_gem_indexes.sql +++ b/packages/database/drizzle/0003_add_gem_indexes.sql @@ -18,5 +18,5 @@ -- Track completion in your deployment runbook before marking this -- migration done in production. -- ============================================================ -CREATE INDEX IF NOT EXISTS gem_source_app_idx ON "global_entity_map" ("source_app_id"); -CREATE INDEX IF NOT EXISTS gem_dest_app_idx ON "global_entity_map" ("dest_app_id"); \ No newline at end of file +SELECT 1; -- Placeholder to satisfy الد migration runner safely. +-- (Indexes must be built OUTSIDE transactions using CONCURRENTLY as noted above) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f61fe695..4be5cd5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,9 @@ importers: framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + lodash.isequal: + specifier: ^4.5.0 + version: 4.5.0 react: specifier: ^19.2.0 version: 19.2.3 @@ -393,6 +396,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 '@types/node': specifier: ^24.10.1 version: 24.10.4 @@ -4541,6 +4547,12 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lodash.isequal@4.5.8': + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} @@ -7390,6 +7402,10 @@ packages: lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -14502,6 +14518,12 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 25.0.5 + '@types/lodash.isequal@4.5.8': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + '@types/luxon@3.7.1': {} '@types/methods@1.1.4': {} @@ -14854,14 +14876,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@22.19.3)(typescript@5.9.3) - vite: 5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) + vite: 5.4.21(@types/node@22.19.3)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@24.10.4)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.4)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': dependencies: @@ -14881,14 +14903,14 @@ snapshots: msw: 2.12.10(@types/node@25.0.5)(typescript@5.9.3) vite: 5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) - '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@5.4.21(@types/node@25.5.0)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.10(@types/node@25.5.0)(typescript@5.9.3) - vite: 5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) + vite: 5.4.21(@types/node@25.5.0)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -17757,6 +17779,8 @@ snapshots: lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isinteger@4.0.4: {} lodash.isnumber@3.0.3: {} @@ -20570,7 +20594,7 @@ snapshots: vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -20681,7 +20705,7 @@ snapshots: vitest@2.1.9(@types/node@25.5.0)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@5.4.21(@types/node@25.0.5)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@5.4.21(@types/node@25.5.0)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 From 8569d8a2e3ddfaa5f43c67a8849e66fb57328928 Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Mon, 6 Apr 2026 16:41:57 +0530 Subject: [PATCH 3/6] stitch ui get related objects code review --- .../stitches/metadata-discovery.service.ts | 9 ++++++++- .../stitches/components/DependencyList.tsx | 2 +- .../stitches/components/SchedulePanel.tsx | 16 +++++++-------- .../stitches/components/StitchConfigPanel.tsx | 20 +++++-------------- .../stitches/pages/StitchDetailPage.tsx | 1 + .../workspaces/pages/WorkspaceDetailPage.tsx | 5 ++++- .../src/pieces/piece-registry.service.spec.ts | 4 ++-- .../storage-resolver.service.spec.ts | 2 +- .../database/drizzle/0000_salty_stryfe.sql | 4 +++- .../database/drizzle/0003_add_gem_indexes.sql | 2 +- 10 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/api/src/modules/stitches/metadata-discovery.service.ts b/apps/api/src/modules/stitches/metadata-discovery.service.ts index 492ffaf9..dce49da4 100644 --- a/apps/api/src/modules/stitches/metadata-discovery.service.ts +++ b/apps/api/src/modules/stitches/metadata-discovery.service.ts @@ -364,7 +364,14 @@ export class MetadataDiscoveryService implements OnModuleInit { let config: ConfigOption[] = []; if (piece.describeConfig) { - config = await piece.describeConfig(credentials); + try { + config = await piece.describeConfig(credentials); + } catch (e) { + this.logger.warn( + `Connector ${connection.appName} failed to describe config: ${String(e)}`, + ); + config = []; + } } await this.redis.set(redisKey, JSON.stringify(config), 'EX', TTL_SECONDS); diff --git a/apps/web/src/modules/stitches/components/DependencyList.tsx b/apps/web/src/modules/stitches/components/DependencyList.tsx index c8e728da..0b84ba03 100644 --- a/apps/web/src/modules/stitches/components/DependencyList.tsx +++ b/apps/web/src/modules/stitches/components/DependencyList.tsx @@ -120,7 +120,7 @@ export function DependencyList({ connectionId, objectName, selected = [], onSele type="checkbox" className="h-4 w-4 shrink-0 rounded border-gray-300 text-primary cursor-pointer accent-primary" checked={isChecked} - onChange={() => {}} + onChange={() => handleToggle(mod.objectName)} />
{mod.objectName} diff --git a/apps/web/src/modules/stitches/components/SchedulePanel.tsx b/apps/web/src/modules/stitches/components/SchedulePanel.tsx index 6e82e9d8..4387cc60 100644 --- a/apps/web/src/modules/stitches/components/SchedulePanel.tsx +++ b/apps/web/src/modules/stitches/components/SchedulePanel.tsx @@ -1,4 +1,10 @@ import { useState, useEffect } from 'react'; +import { CalendarClock, Play, Loader2 } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; +import { Button } from '@/shared/components/ui/button'; +import { useToast } from '@/shared/hooks/use-toast'; +import { updateSchedule, triggerSchedule, getSyncIntervalOptions, type StitchResponse } from '../api/stitches.api'; function extractErrorMessage(e: unknown, defaultMessage = 'An unexpected error occurred'): string { if (typeof e === 'object' && e !== null) { @@ -8,12 +14,6 @@ function extractErrorMessage(e: unknown, defaultMessage = 'An unexpected error o } return typeof e === 'string' ? e : defaultMessage; } -import { CalendarClock, Play, Loader2 } from 'lucide-react'; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/shared/components/ui/card'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; -import { Button } from '@/shared/components/ui/button'; -import { useToast } from '@/shared/hooks/use-toast'; -import { updateSchedule, triggerSchedule, getSyncIntervalOptions, type StitchResponse } from '../api/stitches.api'; interface SchedulePanelProps { stitch: StitchResponse; @@ -36,7 +36,7 @@ export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { } catch (e) { toast({ title: 'Update failed', - description: e instanceof Error ? e.message : 'Could not update interval', + description: extractErrorMessage(e, 'Could not update interval'), variant: 'destructive', }); } finally { @@ -53,7 +53,7 @@ export function SchedulePanel({ stitch, onUpdated }: SchedulePanelProps) { } catch (e) { toast({ title: 'Toggle failed', - description: e instanceof Error ? e.message : 'Could not toggle schedule', + description: extractErrorMessage(e, 'Could not toggle schedule'), variant: 'destructive', }); } finally { diff --git a/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx index 67ff9690..55ba2580 100644 --- a/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx +++ b/apps/web/src/modules/stitches/components/StitchConfigPanel.tsx @@ -12,7 +12,7 @@ interface StitchConfigPanelProps { } export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfigPanelProps) { - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [schema, setSchema] = useState([]); @@ -30,16 +30,6 @@ export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfi if (token !== loadTokenRef.current) return; setSchema(res); - // Apply defaults for missing values - const updates = { ...value }; - let changed = false; - for (const opt of res) { - if (updates[opt.name] === undefined && opt.defaultValue !== undefined) { - updates[opt.name] = opt.defaultValue; - changed = true; - } - } - if (changed) onChange(updates); } catch (e) { if (token !== loadTokenRef.current) return; @@ -51,7 +41,7 @@ export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfi } } void load(); - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectionId]); if (loading) { @@ -104,7 +94,7 @@ export function StitchConfigPanel({ connectionId, value, onChange }: StitchConfi type="checkbox" id={field.name} className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" - checked={!!value[field.name]} + checked={value[field.name] !== undefined ? !!value[field.name] : !!field.defaultValue} onChange={(e) => handleChange(field.name, e.target.checked)} />