diff --git a/apps/web/app/connect/AddConnection.client.tsx b/apps/web/app/connect/AddConnection.client.tsx index b2c024e92..35c77692a 100644 --- a/apps/web/app/connect/AddConnection.client.tsx +++ b/apps/web/app/connect/AddConnection.client.tsx @@ -1,11 +1,22 @@ 'use client' +import type {ReactElement} from 'react' import type {ConnectorConfig} from '@openint/api-v1/trpc/routers/connectorConfig.models' import type {Id} from '@openint/cdk' +import {SearchIcon} from 'lucide-react' +import {useState} from 'react' import {type ConnectorName} from '@openint/api-v1/trpc/routers/connector.models' -import {Label} from '@openint/shadcn/ui' -import {ConnectorConfigCard} from '@openint/ui-v1/domain-components/ConnectorConfigCard' +import {cn} from '@openint/shadcn/lib/utils' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Input, +} from '@openint/shadcn/ui' +import {ConnectorDisplay} from '@openint/ui-v1/domain-components/ConnectorDisplay' +import {useMutableSearchParams} from '@openint/ui-v1/hooks/useStateFromSearchParam' import {ConnectorConnectContainer} from './ConnectorConnect.client' export type ConnectorConfigForCustomer = Pick< @@ -13,6 +24,116 @@ export type ConnectorConfigForCustomer = Pick< 'id' | 'connector_name' | 'connector' > +export type AddConnectionPrefetchedCard = { + connectorName: ConnectorName + connectorDisplayName?: string + card: ReactElement +} + +export function AddConnectionClient({ + cards, + className, +}: { + cards: AddConnectionPrefetchedCard[] + className?: string +}) { + const [isOpen, setIsOpen] = useState(true) + const [_, setSearchParams] = useMutableSearchParams() + + const onClose = () => { + setIsOpen(false) + setSearchParams({view: 'manage'}, {shallow: false}) + } + const [searchQuery, setSearchQuery] = useState('') + const [isFocused, setIsFocused] = useState(false) + + const filteredCards = cards.filter((card) => { + const searchLower = searchQuery.toLowerCase() + return ( + card.connectorName.toLowerCase().includes(searchLower) || + card.connectorDisplayName?.toLowerCase().includes(searchLower) + ) + }) + + return ( + !open && onClose()}> + {/* TODO: consider making full screen horizontal on mobile https://github.com/radix-ui/themes/issues/142 */} + + + Add Integrations + + +
+
+
+
+ + setSearchQuery(e.target.value)} + className="border-gray-200 bg-gray-50/50 pl-10 transition-all duration-200 focus:bg-white focus:shadow-sm" + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> +
+
+
+ +
{ + const scrollTop = e.currentTarget.scrollTop + const shouldBeFocused = scrollTop > 5 + if (shouldBeFocused !== isFocused) { + setIsFocused(shouldBeFocused) + } + }}> + {/* Mobile view */} +
+ {filteredCards.map((card, index) => ( +
+ {card.card} + {index < filteredCards.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Desktop view: Grid layout */} +
+ {filteredCards.map((card, index) => ( +
+ {card.card} +
+ ))} +
+ + {filteredCards.length === 0 && ( +
+ No integrations found matching{' '} + {searchQuery ? `"${searchQuery}"` : 'your search'} +
+ )} +
+
+ +
+ ) +} + export function AddConnectionCard({ connectorConfig, }: { @@ -25,20 +146,49 @@ export function AddConnectionCard({ connector={connectorConfig.connector!} connectorConfigId={connectorConfig.id as Id['ccfg']}> {({isConnecting, handleConnect}) => ( - } - onPress={() => { - if (isConnecting) { - return - } - handleConnect() - }}> - - + <> + {/* NOTE/TODO: note sure how to remove duplication, + its there as we're using tailwind for consistent breakpoints with AddConnectionClient */} + {/* Mobile view (visible below md breakpoint) */} +
+ { + if (isConnecting) { + return + } + handleConnect() + }} + /> +
+ + {/* Desktop view (visible at md breakpoint and above) */} +
+ { + if (isConnecting) { + return + } + handleConnect() + }} + /> +
+ )} ) diff --git a/apps/web/app/connect/MyConnections.client.tsx b/apps/web/app/connect/MyConnections.client.tsx index f2a33cae1..548c22588 100644 --- a/apps/web/app/connect/MyConnections.client.tsx +++ b/apps/web/app/connect/MyConnections.client.tsx @@ -1,6 +1,7 @@ 'use client' import {useSuspenseQuery} from '@tanstack/react-query' +import {Link} from 'lucide-react' import React from 'react' import {type ConnectorName} from '@openint/api-v1/trpc/routers/connector.models' import {Id} from '@openint/cdk' @@ -9,7 +10,7 @@ import { CommandPopover, ConnectionStatusPill, DataTileView, - Spinner, + LoadingSpinner, useMutableSearchParams, } from '@openint/ui-v1' import {ConnectionCard} from '@openint/ui-v1/domain-components/ConnectionCard' @@ -41,75 +42,106 @@ export function MyConnectionsClient(props: { const definitions = useCommandDefinitionMap() if (isLoading) { - return ( -
- -
- ) + return } if (!res.data?.items?.length || res.data.items.length === 0) { return ( -
-

- You have no configured integrations. -

+
+
+
+ +
+

+ Let's Get You Connected +

+

+ You have not configured any integrations yet. Connect your first app + to get started. +

+
) } return ( - conn.id} - renderItem={(conn) => { - const renderCard = ({handleConnect}: {handleConnect?: () => void}) => ( - - -
- - - ({timeSince(conn.updated_at)}) - -
- - } - /> -
- ) + <> +
+

My Integrations

+ +
+ +
+ conn.id} + renderItem={(conn) => { + const renderCard = ({ + handleConnect, + }: { + handleConnect?: () => void + }) => ( + + +
+ + + ({timeSince(conn.updated_at)}) + +
+ + } + /> +
+ ) - if (conn.status !== 'disconnected') { - return renderCard({handleConnect: undefined}) - } + if (conn.status !== 'disconnected') { + return renderCard({handleConnect: undefined}) + } - return ( - - {renderCard} - - ) - }} - /> + return ( + + {renderCard} + + ) + }} + /> + ) } diff --git a/apps/web/app/connect/page.client.tsx b/apps/web/app/connect/page.client.tsx deleted file mode 100644 index c53a2c8d0..000000000 --- a/apps/web/app/connect/page.client.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client' - -import React from 'react' -import {Tabs} from '@openint/shadcn/ui' -import {useStateFromSearchParams} from '@openint/ui-v1' - -/** - * Tabs that automatically sync with search params - * However this does not work very well so far because it causes the entire server side route to reload - * which is not actually intentional - * - */ -export function TabsClient({ - paramKey, - ...props -}: Omit, 'value' | 'onValueChange'> & { - paramKey: string -}) { - const [value, setValue] = useStateFromSearchParams(paramKey, { - defaultValue: props.defaultValue, - shallow: true, - }) - - return -} diff --git a/apps/web/app/connect/page.tsx b/apps/web/app/connect/page.tsx index d0642baec..a601f69d3 100644 --- a/apps/web/app/connect/page.tsx +++ b/apps/web/app/connect/page.tsx @@ -21,7 +21,6 @@ import { CardHeader, CardTitle, } from '@openint/shadcn/ui/card' -import {TabsContent, TabsList, TabsTrigger} from '@openint/shadcn/ui/tabs' import {ErrorBoundarySuspense, LoadingSpinner} from '@openint/ui-v1' import {GlobalCommandBarProvider} from '@/lib-client/GlobalCommandBarProvider' import {TRPCApp} from '@/lib-client/TRPCApp' @@ -32,11 +31,10 @@ import { getServerComponentContext, serverComponentContextForViewer, } from '@/lib-server/trpc.server' -import {AddConnectionCard} from './AddConnection.client' +import {AddConnectionCard, AddConnectionClient} from './AddConnection.client' import {ConnectContextProvider} from './ConnectContextProvider' import {ConnectOpWrapper} from './ConnectOpWrapper' import {MyConnectionsClient} from './MyConnections.client' -import {TabsClient} from './page.client' export default async function ConnectPage( pageProps: PageProps< @@ -100,6 +98,8 @@ export default async function ConnectPage( .map(([key, value]) => [key, value]), ) + const isManage = searchParams.view === 'manage' || !searchParams.view + const viewerConnections = await queryClient.fetchQuery( trpc.listConnections.queryOptions({ connector_names: searchParams.connector_names, @@ -142,7 +142,7 @@ export default async function ConnectPage(
{/* Left Banner - Hidden on mobile and tablets, shown only on lg+ screens */} {!searchParams.is_embedded && ( -
+
0 ? 'manage' : 'add' - } - paramKey="view" - className="flex-1 p-4 lg:pt-12"> -
- - - Manage Integrations - - Add New Integration - - - }> - - - - - }> - - - + {/* QQ: should we always load myConnections and just load AddConnections on top given that it's a modal */} + }> +
+ {isManage ? ( + + ) : ( + + )}
- +
@@ -301,23 +286,27 @@ async function AddConnections({ // "You have configured all enabled integrations. If you'd like to enable new ones please contact the {organizationName} support team" return ( -
- }> - {availableToConnect.map((ccfg) => ( - - ))} - -
+ }> + ({ + connectorName: ccfg.connector_name as ConnectorName, + connectorDisplayName: ccfg.connector?.display_name, + card: ( + + ), + }))} + /> + ) } diff --git a/apps/web/app/console/(authenticated)/connect/page.client.tsx b/apps/web/app/console/(authenticated)/connect/page.client.tsx index e695d32d1..de96d51cb 100644 --- a/apps/web/app/console/(authenticated)/connect/page.client.tsx +++ b/apps/web/app/console/(authenticated)/connect/page.client.tsx @@ -10,7 +10,7 @@ import React from 'react' import {connectRouterModels} from '@openint/api-v1/trpc/routers/connect.models' import {ConnectButton, ConnectEmbed} from '@openint/connect' import {getBaseURLs} from '@openint/env' -import {Spinner} from '@openint/ui-v1' +import {LoadingSpinner} from '@openint/ui-v1' import {PreviewWindow} from '@openint/ui-v1/components/PreviewWindow' import {ZodSchemaForm} from '@openint/ui-v1/components/schema-form' import {createURL} from '@openint/util/url-utils' @@ -114,7 +114,7 @@ export function ConnectEmbedPreview(props: { }> {tokenRes.status === 'pending' ? (
- +
) : tokenRes.status === 'error' ? ( // TODO: Show a better error message diff --git a/packages/ui-v1/components/Spinner.tsx b/packages/ui-v1/components/Spinner.tsx deleted file mode 100644 index cc6c281f1..000000000 --- a/packages/ui-v1/components/Spinner.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import {Loader2} from 'lucide-react' - -export function Spinner() { - return ( -
- -
- ) -} diff --git a/packages/ui-v1/components/index.ts b/packages/ui-v1/components/index.ts index 62cc1cdc9..b4a71a580 100644 --- a/packages/ui-v1/components/index.ts +++ b/packages/ui-v1/components/index.ts @@ -16,7 +16,6 @@ export * from './PropertyListView' export * from './schema-form' export * from './SearchInput' export * from './SecureInput' -export * from './Spinner' export * from './StatusDot' export * from './tabs' export * from './ThemeProvider' diff --git a/packages/ui-v1/domain-components/LinkConnectorModal.tsx b/packages/ui-v1/domain-components/LinkConnectorModal.tsx deleted file mode 100644 index 422e2a8bb..000000000 --- a/packages/ui-v1/domain-components/LinkConnectorModal.tsx +++ /dev/null @@ -1,143 +0,0 @@ -'use client' - -import type {Core} from '@openint/api-v1/models' - -import {Search} from 'lucide-react' -import {useState} from 'react' -import {cn} from '@openint/shadcn/lib/utils' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Input, -} from '@openint/shadcn/ui' -import {ConnectorDisplay} from './ConnectorDisplay' - -export interface LinkConnectorModalProps { - isOpen: boolean - onClose: () => void - connectors: Array - onSelectConnector?: (connector: Core['connector']) => void - title?: string - className?: string - initialSearchValue?: string -} - -export function LinkConnectorModal({ - isOpen, - onClose, - connectors = [], - onSelectConnector, - title = 'Select Integration', - className, - initialSearchValue = '', -}: LinkConnectorModalProps) { - const [searchQuery, setSearchQuery] = useState(initialSearchValue) - const [isFocused, setIsFocused] = useState(false) - - const filteredConnectors = connectors.filter((connector) => { - const searchLower = searchQuery.toLowerCase() - return ( - connector.name.toLowerCase().includes(searchLower) || - (connector.display_name && - connector.display_name.toLowerCase().includes(searchLower)) - ) - }) - - return ( - !open && onClose()}> - e.preventDefault()}> - - {title} - - -
-
-
-
- - setSearchQuery(e.target.value)} - className="border-gray-200 bg-gray-50/50 pl-10 transition-all duration-200 focus:bg-white focus:shadow-sm" - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - /> -
-
-
- -
{ - // When scrolling, set focus to create the separation effect - if (e.currentTarget.scrollTop > 5) { - setIsFocused(true) - } else { - setIsFocused(false) - } - }}> - {/* Mobile view: Row mode with scrolling */} -
- {filteredConnectors.map((connector, index) => ( -
-
onSelectConnector?.(connector)}> - onSelectConnector?.(connector)} - className="hover:bg-transparent" - /> -
- {index < filteredConnectors.length - 1 && ( -
- )} -
- ))} -
- - {/* Desktop view: Card grid */} -
- {filteredConnectors.map((connector, index) => ( -
onSelectConnector?.(connector)} - className="transition-transform duration-100 hover:scale-[1.01]"> - onSelectConnector?.(connector)} - /> -
- ))} -
- - {filteredConnectors.length === 0 && ( -
- No integrations found matching{' '} - {searchQuery ? `"${searchQuery}"` : 'your search'} -
- )} -
-
- -
- ) -} diff --git a/packages/ui-v1/domain-components/__stories__/LinkConnectorModal.stories.tsx b/packages/ui-v1/domain-components/__stories__/LinkConnectorModal.stories.tsx deleted file mode 100644 index 3612bfc56..000000000 --- a/packages/ui-v1/domain-components/__stories__/LinkConnectorModal.stories.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react' - -import {useState} from 'react' -import {Button} from '@openint/shadcn/ui' -import {LinkConnectorModal} from '../LinkConnectorModal' - -const meta: Meta = { - title: 'Domain Components/LinkConnectorModal', - component: LinkConnectorModal, - parameters: { - layout: 'centered', - }, -} - -export default meta -type Story = StoryObj - -const connectors = [ - { - name: 'wise', - display_name: 'Wise', - logo_url: - 'https://cdn.iconscout.com/icon/free/png-256/free-wise-3770198-3155604.png', - stage: 'ga' as 'ga' | 'beta' | 'alpha', - platforms: ['web'] as Array<'web' | 'mobile' | 'desktop'>, - }, - { - name: 'firebase', - display_name: 'Firebase', - logo_url: - 'https://firebase.google.com/static/images/brand-guidelines/logo-logomark.png', - stage: 'ga' as 'ga' | 'beta' | 'alpha', - platforms: ['web', 'mobile'] as Array<'web' | 'mobile' | 'desktop'>, - }, - { - name: 'hubspot', - display_name: 'Hubspot', - logo_url: - 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/168_Hubspot_logo_logos-512.png', - stage: 'beta' as 'ga' | 'beta' | 'alpha', - platforms: ['web', 'mobile'] as Array<'web' | 'mobile' | 'desktop'>, - }, - { - name: 'ramp', - display_name: 'Ramp', - logo_url: 'https://ramp.com/img/logo.png', - stage: 'beta' as 'ga' | 'beta' | 'alpha', - platforms: ['web'] as Array<'web' | 'mobile' | 'desktop'>, - }, - { - name: 'discord', - display_name: 'Discord', - logo_url: - 'https://assets-global.website-files.com/6257adef93867e50d84d30e2/636e0a69f118df70ad7828d4_icon_clyde_blurple_RGB.png', - stage: 'alpha' as 'ga' | 'beta' | 'alpha', - platforms: ['web', 'desktop'] as Array<'web' | 'mobile' | 'desktop'>, - }, - { - name: 'gong', - display_name: 'Gong', - logo_url: - 'https://www.gong.io/wp-content/themes/gong/assets/images/site-icon-gong.png', - stage: 'alpha' as 'ga' | 'beta' | 'alpha', - platforms: ['web'] as Array<'web' | 'mobile' | 'desktop'>, - }, -] - -// Create a larger set of connectors for testing scroll behavior -const manyConnectors = [ - ...connectors, - // Duplicating existing connectors with slight name variations for testing - ...connectors.map((connector) => ({ - ...connector, - name: connector.name, // Keep the same name so ConnectorLogo can find it - display_name: `${connector.display_name} Pro`, - })), - ...connectors.map((connector) => ({ - ...connector, - name: connector.name, // Keep the same name so ConnectorLogo can find it - display_name: `${connector.display_name} Team`, - })), - ...connectors.map((connector) => ({ - ...connector, - name: connector.name, // Keep the same name so ConnectorLogo can find it - display_name: `${connector.display_name} Enterprise`, - })), - ...connectors.map((connector) => ({ - ...connector, - name: connector.name, // Keep the same name so ConnectorLogo can find it - display_name: `${connector.display_name} Cloud`, - })), -] - -// Controlled Example -const ControlledExample = () => { - const [isOpen, setIsOpen] = useState(false) - - return ( -
- - setIsOpen(false)} - connectors={connectors} - onSelectConnector={(connector) => { - console.log('Selected connector:', connector) - setIsOpen(false) - }} - /> -
- ) -} - -export const Default: Story = { - render: () => , -} - -export const WithCustomTitle: Story = { - args: { - isOpen: true, - title: 'Choose an Integration', - connectors: connectors, - }, -} - -export const EmptyState: Story = { - args: { - isOpen: true, - connectors: [], - }, -} - -// Prefiltered Search Example -const PreFilteredExample = () => { - const [isOpen, setIsOpen] = useState(false) - const [prefilterValue] = useState('disc') - - return ( -
- - setIsOpen(false)} - connectors={connectors} - onSelectConnector={(connector) => { - console.log('Selected connector:', connector) - setIsOpen(false) - }} - initialSearchValue={prefilterValue} - /> -
- ) -} - -export const WithSearchPrefiltered: Story = { - render: () => , -} - -// Add a story with many connectors to test scrolling -const ManyConnectorsExample = () => { - const [isOpen, setIsOpen] = useState(false) - - return ( -
- - setIsOpen(false)} - connectors={manyConnectors} - onSelectConnector={(connector) => { - console.log('Selected connector:', connector) - setIsOpen(false) - }} - /> -
- ) -} - -export const WithManyConnectors: Story = { - render: () => , -} diff --git a/packages/ui-v1/domain-components/index.ts b/packages/ui-v1/domain-components/index.ts index b34d67341..fc57eeed7 100644 --- a/packages/ui-v1/domain-components/index.ts +++ b/packages/ui-v1/domain-components/index.ts @@ -4,4 +4,3 @@ export * from './ConnectorConfigForm' export * from './ConnectionsCardView' export * from './tables' export * from './ConnectionStatus' -export * from './LinkConnectorModal'