refactor(integrations): drop unused logos, brand icons, and card wrapper
Keep only the Settings Integrations page wiring: three plugins, i18n, search/metadata, and the plugins-store registry boolean needed for failure status. Remove ProviderLogo fallbacks, SVG assets, custom sprite icons, and the separate IntegrationCard layer. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
Cursor Agent
co-authored by
Serhii Dziupin
parent
0aba428e95
commit
53ab67ea35
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
|
||||
const integrationCards = await import('./Integration' + 'Card').catch(() => ({}));
|
||||
|
||||
type IntegrationCardComponent = (props: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
header: React.ReactNode;
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => React.ReactElement;
|
||||
|
||||
const IntegrationCard = (
|
||||
integrationCards as unknown as {
|
||||
IntegrationCard?: IntegrationCardComponent;
|
||||
}
|
||||
).IntegrationCard;
|
||||
|
||||
describe('IntegrationCard', () => {
|
||||
test('keeps header controls outside the expansion button', () => {
|
||||
expect(typeof IntegrationCard).toBe('function');
|
||||
if (!IntegrationCard) return;
|
||||
|
||||
const onOpenChange = () => undefined;
|
||||
const toggle = <input aria-label="Integration enabled" type="checkbox" />;
|
||||
const card = IntegrationCard({
|
||||
open: false,
|
||||
onOpenChange,
|
||||
header: <span>Integration</span>,
|
||||
headerAction: toggle,
|
||||
children: <div>Details</div>,
|
||||
});
|
||||
|
||||
const cardProps = card.props as {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactElement;
|
||||
};
|
||||
expect(cardProps.open).toBe(false);
|
||||
expect(cardProps.onOpenChange).toBe(onOpenChange);
|
||||
|
||||
const surface = cardProps.children as React.ReactElement<{ children: React.ReactNode }>;
|
||||
const headerRow = React.Children.toArray(surface.props.children)[0] as React.ReactElement<{
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
const headerChildren = React.Children.toArray(headerRow.props.children) as React.ReactElement<{
|
||||
children?: React.ReactNode;
|
||||
}>[];
|
||||
|
||||
expect(headerChildren[0]?.type).toBe('button');
|
||||
expect(headerChildren[1]?.props.children).toBe(toggle);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface IntegrationCardProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
header: React.ReactNode;
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
settingsItem?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for an integration summary and its on-demand configuration.
|
||||
* Header actions sit beside—not inside—the expansion button so their native
|
||||
* controls preserve their own keyboard and click behavior.
|
||||
*/
|
||||
export const IntegrationCard: React.FC<IntegrationCardProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
header,
|
||||
headerAction,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
settingsItem,
|
||||
}) => {
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<div
|
||||
data-settings-item={settingsItem}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
{header}
|
||||
<Icon
|
||||
name={open ? 'arrow-up-s' : 'arrow-down-s'}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
{headerAction ? <div className="shrink-0 pr-4">{headerAction}</div> : null}
|
||||
</div>
|
||||
<CollapsibleContent
|
||||
className={cn('border-t border-[var(--interactive-border)] px-4 py-4', contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
+89
-74
@@ -12,6 +12,7 @@ import {
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
THIRD_PARTY_PLUGINS,
|
||||
type ThirdPartyPluginDefinition,
|
||||
} from './thirdPartyPlugins';
|
||||
import { IntegrationCard } from './IntegrationCard';
|
||||
|
||||
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
|
||||
|
||||
@@ -296,16 +296,26 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
|
||||
}[primaryAction];
|
||||
|
||||
const open = openPluginIds.has(plugin.id);
|
||||
|
||||
return (
|
||||
<IntegrationCard
|
||||
<Collapsible
|
||||
key={plugin.id}
|
||||
open={openPluginIds.has(plugin.id)}
|
||||
onOpenChange={(open) => setPluginOpen(plugin.id, open)}
|
||||
settingsItem={`integrations.third-party.${plugin.id}`}
|
||||
header={(
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
|
||||
>
|
||||
<div
|
||||
data-settings-item={`integrations.third-party.${plugin.id}`}
|
||||
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setPluginOpen(plugin.id, !open)}
|
||||
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
|
||||
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
|
||||
<Icon name={plugin.icon} className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
|
||||
@@ -322,74 +332,79 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/50 px-3 py-2.5',
|
||||
hasUpdate && 'border-[var(--status-warning)]/35 bg-[var(--status-warning)]/10',
|
||||
)}
|
||||
>
|
||||
<p aria-live="polite" className="text-sm font-medium text-foreground">{status}</p>
|
||||
{state.projectEntries.length > 0 ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('settings.integrations.thirdParty.status.projectInstalled')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={primaryAction === 'manage' ? 'outline' : 'default'}
|
||||
onClick={() => void handlePrimaryAction(plugin)}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : primaryAction === 'setup' ? (
|
||||
<Icon name="plug-2" className="size-3.5" />
|
||||
) : null}
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
{registryUnavailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refresh()}
|
||||
disabled={isRefreshing || isLoadingRegistry || isPending}
|
||||
<Icon
|
||||
name={open ? 'arrow-up-s' : 'arrow-down-s'}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-border/60 bg-background/50 px-3 py-2.5',
|
||||
hasUpdate && 'border-[var(--status-warning)]/35 bg-[var(--status-warning)]/10',
|
||||
)}
|
||||
>
|
||||
{isRefreshing || isLoadingRegistry ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
|
||||
{t('settings.integrations.thirdParty.actions.refresh')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void openExternalUrl(plugin.homepage)}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.docs')}
|
||||
</Button>
|
||||
{state.userEntry && !state.userEntryIsAmbiguous ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setRemoveTarget(plugin)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.remove')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p aria-live="polite" className="text-sm font-medium text-foreground">{status}</p>
|
||||
{state.projectEntries.length > 0 ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('settings.integrations.thirdParty.status.projectInstalled')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={primaryAction === 'manage' ? 'outline' : 'default'}
|
||||
onClick={() => void handlePrimaryAction(plugin)}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : primaryAction === 'setup' ? (
|
||||
<Icon name="plug-2" className="size-3.5" />
|
||||
) : null}
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
{registryUnavailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void refresh()}
|
||||
disabled={isRefreshing || isLoadingRegistry || isPending}
|
||||
>
|
||||
{isRefreshing || isLoadingRegistry ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
|
||||
{t('settings.integrations.thirdParty.actions.refresh')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void openExternalUrl(plugin.homepage)}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.docs')}
|
||||
</Button>
|
||||
{state.userEntry && !state.userEntryIsAmbiguous ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setRemoveTarget(plugin)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.remove')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ export interface ThirdPartyPluginDefinition {
|
||||
packageName: string;
|
||||
providerId: string;
|
||||
icon: IconName;
|
||||
/** Brand mark color class (theme tokens only). */
|
||||
brandClassName: string;
|
||||
nameKey: I18nKey;
|
||||
descriptionKey: I18nKey;
|
||||
homepage: string;
|
||||
@@ -19,8 +17,7 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
|
||||
id: 'opencode-claude',
|
||||
packageName: '@otto-assistant/opencode-claude',
|
||||
providerId: 'claude-code',
|
||||
icon: 'claude-code',
|
||||
brandClassName: 'text-foreground',
|
||||
icon: 'sparkling',
|
||||
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
|
||||
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
|
||||
homepage: 'https://github.com/otto-assistant/opencode-claude',
|
||||
@@ -29,8 +26,7 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
|
||||
id: 'opencode-commandcode',
|
||||
packageName: '@otto-assistant/opencode-commandcode',
|
||||
providerId: 'command-code',
|
||||
icon: 'command-code',
|
||||
brandClassName: 'text-foreground',
|
||||
icon: 'terminal-box',
|
||||
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
|
||||
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
|
||||
homepage: 'https://github.com/otto-assistant/opencode-commandcode',
|
||||
@@ -40,7 +36,6 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
|
||||
packageName: '@otto-assistant/opencode-cursor-oauth',
|
||||
providerId: 'cursor',
|
||||
icon: 'cursor',
|
||||
brandClassName: 'text-foreground',
|
||||
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
|
||||
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
|
||||
homepage: 'https://github.com/otto-assistant/opencode-cursor',
|
||||
|
||||
Reference in New Issue
Block a user