From 258e36d44c443558b17ff70c048fb05434078a61 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Fri, 28 Aug 2026 16:42:30 +0200 Subject: [PATCH 01/14] feat(connectors): implement Confluence connector across frontend --- .../admin/components/CreateProjectWizard.tsx | 59 +++++- .../admin/components/StagedSourceList.tsx | 9 + .../wizard/sources/AddSourceFlow.tsx | 47 +++++ .../wizard/steps/WizardReviewStep.tsx | 4 +- .../wizard/steps/WizardSourcesStep.tsx | 2 +- src/features/admin/projectSourcesDraft.ts | 65 ++++++- .../components/ConfluenceConnectStep.tsx | 177 ++++++++++++++++++ .../components/ConnectorSourcesSection.tsx | 109 ++++++++--- .../components/useConfluenceSync.ts | 56 ++++++ src/features/connectors/data.ts | 9 +- .../components/AddSourceModal.tsx | 52 ++++- .../components/ConfluenceConnectStep.tsx | 118 ++++++++++++ .../components/RunDetailsPanel.tsx | 4 + .../components/SourceDetailsPanel.tsx | 40 +++- src/features/data-ingestion/data.ts | 80 +++++++- src/features/data-ingestion/types.ts | 15 +- .../components/jira/JiraCredentialAddForm.tsx | 5 +- src/pages/DataIngestionPage.tsx | 109 ++++++++--- src/services/sources/confluenceService.ts | 111 +++++++++++ .../a11y/SourceConnectModal.a11y.test.tsx | 6 + .../connectors/ConfluenceConnectStep.test.tsx | 66 +++++++ .../ConfluenceConnectStep.test.tsx | 51 +++++ .../unit/features/data-ingestion/data.test.ts | 2 +- .../OnboardingMetricsPage.filter.test.tsx | 8 +- .../starter-work/CorpusIssueBrowser.test.tsx | 4 +- tests/unit/services/confluenceService.test.ts | 110 +++++++++++ tests/unit/setup/msw-handlers.ts | 5 + 27 files changed, 1235 insertions(+), 88 deletions(-) create mode 100644 src/features/connectors/components/ConfluenceConnectStep.tsx create mode 100644 src/features/connectors/components/useConfluenceSync.ts create mode 100644 src/features/data-ingestion/components/ConfluenceConnectStep.tsx create mode 100644 src/services/sources/confluenceService.ts create mode 100644 tests/unit/features/connectors/ConfluenceConnectStep.test.tsx create mode 100644 tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx create mode 100644 tests/unit/services/confluenceService.test.ts diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx index c62631ca..72dd7fb0 100644 --- a/src/features/admin/components/CreateProjectWizard.tsx +++ b/src/features/admin/components/CreateProjectWizard.tsx @@ -13,6 +13,7 @@ import { import { addDraftSource, connectDraftSources, + createConfluenceDraft, createDraftSourceFromDiscovery, createJiraDraft, createUploadDraft, @@ -70,8 +71,8 @@ const STEP_INDEX: Record, number> = { review: 3, }; -// All three connectors can now be staged from the add-source sub-flow. -const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD"]; +// All four connectors can now be staged from the add-source sub-flow. +const AVAILABLE_SOURCE_TYPES: SourceSystem[] = ["GITHUB", "JIRA", "UPLOAD", "CONFLUENCE"]; /** * Transactional create-project wizard: everything is drafted locally across the @@ -127,14 +128,23 @@ export function CreateProjectWizard({ // The token list is owned here so an inline "add token" can refresh it and // auto-select the new token. It falls back to the prop until it has loaded so // discovery still works on the first open without waiting for the refetch. - const { tokenNames: loadedTokenNames, tokensLoaded, loadTokenNames, addTokenNameLocally } = - useGithubTokens(); + const { + tokenNames: loadedTokenNames, + tokensLoaded, + loadTokenNames, + addTokenNameLocally, + } = useGithubTokens(); const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames; const [jiraDisplayName, setJiraDisplayName] = useState(""); const [jiraUrl, setJiraUrl] = useState(""); const [jiraCredentialName, setJiraCredentialName] = useState(""); + const [confluenceBaseUrl, setConfluenceBaseUrl] = useState(""); + const [confluenceSpaceId, setConfluenceSpaceId] = useState(""); + const [confluenceEmail, setConfluenceEmail] = useState(""); + const [confluenceApiToken, setConfluenceApiToken] = useState(""); + // Upload files staged in memory; uploaded during provisioning once a project // id exists. const [uploadFiles, setUploadFiles] = useState([]); @@ -234,6 +244,13 @@ export function CreateProjectWizard({ void Promise.resolve().then(loadManagerCandidates); }, [isOpen, loadManagerCandidates]); + const resetConfluenceDraftFields = () => { + setConfluenceBaseUrl(""); + setConfluenceSpaceId(""); + setConfluenceEmail(""); + setConfluenceApiToken(""); + }; + const resetWizard = () => { setPhase("details"); setName(""); @@ -249,6 +266,7 @@ export function CreateProjectWizard({ setAddType("GITHUB"); setGithubSelection([]); resetJiraDraftFields(); + resetConfluenceDraftFields(); setUploadFiles([]); setCreatedProjectId(""); }; @@ -262,6 +280,7 @@ export function CreateProjectWizard({ const resetSourceDraftFields = () => { setGithubSelection([]); resetJiraDraftFields(); + resetConfluenceDraftFields(); setUploadFiles([]); }; @@ -391,7 +410,14 @@ export function CreateProjectWizard({ ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) : addType === "UPLOAD" ? uploadFiles.length > 0 - : false; + : addType === "CONFLUENCE" + ? Boolean( + confluenceBaseUrl.trim() && + confluenceSpaceId.trim() && + confluenceEmail.trim() && + confluenceApiToken.trim(), + ) + : false; const commitAddSource = () => { if (!canAddSource) return; @@ -419,6 +445,18 @@ export function CreateProjectWizard({ } else if (addType === "UPLOAD") { const displayName = uploadFiles.length === 1 ? uploadFiles[0].name : "Uploaded documents"; setSources((current) => addDraftSource(current, createUploadDraft(displayName, uploadFiles))); + } else if (addType === "CONFLUENCE") { + setSources((current) => + addDraftSource( + current, + createConfluenceDraft({ + baseUrl: confluenceBaseUrl.trim(), + spaceId: confluenceSpaceId.trim(), + email: confluenceEmail.trim(), + apiToken: confluenceApiToken.trim(), + }), + ), + ); } closeAddSource(); @@ -781,6 +819,17 @@ export function CreateProjectWizard({ current.filter((_, position) => position !== index), ), }} + confluence={{ + baseUrl: confluenceBaseUrl, + spaceId: confluenceSpaceId, + email: confluenceEmail, + apiToken: confluenceApiToken, + onBaseUrlChange: setConfluenceBaseUrl, + onSpaceIdChange: setConfluenceSpaceId, + onEmailChange: setConfluenceEmail, + onApiTokenChange: setConfluenceApiToken, + onSubmit: commitAddSource, + }} /> ) : ( ; } + if (source.type === "CONFLUENCE") { + return ; + } + return ; } @@ -83,6 +88,10 @@ function sourceDetail(source: DraftSource): string { return source.url; } + if (source.type === "CONFLUENCE") { + return `${source.baseUrl} (${source.spaceId})`; + } + return source.tokenName; } diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx index cd18ac03..cb895534 100644 --- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx +++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx @@ -19,6 +19,7 @@ import { type DiscoverySelection, } from "../../../../data-ingestion/components/GithubRepositoryDiscovery"; import { JiraConnectStep } from "../../../../data-ingestion/components/JiraConnectStep"; +import { ConfluenceConnectStep } from "../../../../data-ingestion/components/ConfluenceConnectStep"; import { SourceTypeStep } from "../../../../data-ingestion/components/SourceTypeStep"; import { FileUploadZone } from "../../../../knowledge-base/components/FileUploadZone"; import { TokenAddForm } from "../../../../settings/components/TokenAddForm"; @@ -82,6 +83,20 @@ type UploadDetailProps = { onRemoveFile: (index: number) => void; }; +/** Confluence detail — a staged form; nothing connects until provisioning. */ +type ConfluenceDetailProps = { + baseUrl: string; + spaceId: string; + email: string; + apiToken: string; + onBaseUrlChange: (value: string) => void; + onSpaceIdChange: (value: string) => void; + onEmailChange: (value: string) => void; + onApiTokenChange: (value: string) => void; + /** Enter in a field stages the source (guarded), matching "Add to list". */ + onSubmit: () => void; +}; + type AddSourceFlowProps = { step: AddSourceStep; selectedType: SourceSystem; @@ -98,6 +113,7 @@ type AddSourceFlowProps = { github: GithubDetailProps; jira: JiraDetailProps; upload: UploadDetailProps; + confluence: ConfluenceDetailProps; /** * Told when the desktop credential companion opens/closes, so the wizard can * slide its modal left to make room for it. @@ -454,11 +470,39 @@ function UploadDetail({ files, onAddFiles, onRemoveFile }: UploadDetailProps) { ); } +/** Confluence detail form for connecting spaces. */ +function ConfluenceDetail({ + isBusy, + confluence, +}: { + isBusy: boolean; + confluence: ConfluenceDetailProps; +}) { + return ( +
+ +
+ ); +} + /** One-line brief shown under the detail header, per source type. */ const DETAIL_SUBTITLE: Record = { GITHUB: "Pick the repositories to index, then add them to your source list.", JIRA: "Point to your Jira instance and pick a credential, then add it to the list.", UPLOAD: "Files are staged now and uploaded right after the project is created.", + CONFLUENCE: + "Enter your Confluence Cloud details and credentials, then add the space to the list.", }; /** @@ -516,6 +560,7 @@ export function AddSourceFlow({ github, jira, upload, + confluence, onCompanionOpenChange, }: AddSourceFlowProps) { const prefersReducedMotion = useReducedMotion(); @@ -529,6 +574,8 @@ export function AddSourceFlow({ onAddFiles={upload.onAddFiles} onRemoveFile={upload.onRemoveFile} /> + ) : selectedType === "CONFLUENCE" ? ( + ) : ( ); diff --git a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx index f8953dd8..7c8b6d22 100644 --- a/src/features/admin/components/wizard/steps/WizardReviewStep.tsx +++ b/src/features/admin/components/wizard/steps/WizardReviewStep.tsx @@ -1,4 +1,4 @@ -import { GitBranch, Ticket, FileText } from "lucide-react"; +import { GitBranch, Ticket, FileText, BookOpen } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; import { UserAvatar } from "../../../../../components/common/UserAvatar"; import { SourceTypeBadge } from "../../../../data-ingestion/components/SourceTypeBadge"; @@ -28,12 +28,14 @@ const typeIcons: Record> GITHUB: GitBranch, JIRA: Ticket, UPLOAD: FileText, + CONFLUENCE: BookOpen, }; const typeLabels: Record = { GITHUB: "GitHub", JIRA: "Jira", UPLOAD: "Upload", + CONFLUENCE: "Confluence", }; function sourceTitle(source: DraftSource): string { diff --git a/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx b/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx index 02170275..5b2fdaf3 100644 --- a/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx +++ b/src/features/admin/components/wizard/steps/WizardSourcesStep.tsx @@ -35,7 +35,7 @@ export function WizardSourcesStep({ sources={sources} disabled={disabled} onRemove={onRemove} - emptyMessage="No sources yet. Add a GitHub repo, Jira project, or files to start." + emptyMessage="No sources yet. Add a GitHub repo, Jira project, Confluence space, or files to start." /> + + +
+ + setBaseUrl(e.target.value)} + placeholder="https://your-domain.atlassian.net" + required + /> + + + + setSpaceId(e.target.value)} + placeholder="e.g. 123456" + required + /> + + + + setEmail(e.target.value)} + placeholder="user@example.com" + required + autoComplete="email" + /> + + + + setApiToken(e.target.value)} + placeholder="Atlassian API token" + required + autoComplete="off" + /> + + +
+ + +
+
+ + ); +} diff --git a/src/features/connectors/components/ConnectorSourcesSection.tsx b/src/features/connectors/components/ConnectorSourcesSection.tsx index 60309511..f50e58e4 100644 --- a/src/features/connectors/components/ConnectorSourcesSection.tsx +++ b/src/features/connectors/components/ConnectorSourcesSection.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState } from "react"; -import { CheckCircle2, ExternalLink, RefreshCw, Search, XCircle } from "lucide-react"; +import { CheckCircle2, ExternalLink, Plus, RefreshCw, Search, XCircle } from "lucide-react"; import { Button } from "../../../components/ui/Button.tsx"; import { Input } from "../../../components/ui/Input.tsx"; import { useToast } from "../../../context/useToast.ts"; import { connectorService } from "../../../services/connectorService.ts"; import { buildSourceKey } from "../data.ts"; +import { ConfluenceConnectStep } from "./ConfluenceConnectStep.tsx"; +import { useConfluenceSync } from "./useConfluenceSync.ts"; import type { ConnectorListItem, ConnectorSourceRow, @@ -44,7 +46,10 @@ export function ConnectorSourcesSection({ const [draft, setDraft] = useState(EMPTY_DRAFT); const [saveState, setSaveState] = useState("idle"); const [query, setQuery] = useState(""); + const [isAddingConfluence, setIsAddingConfluence] = useState(false); const toast = useToast(); + const { syncConnection, syncingId } = useConfluenceSync(projectId); + const isConfluence = connector.id === "confluence"; const sourceKey = useMemo(() => buildSourceKey(sources), [sources]); @@ -202,13 +207,37 @@ export function ConnectorSourcesSection({

Sources

- {!isLoading && draftSources.length > 0 && ( - - {inScopeCount} / {draftSources.length} in scope - - )} +
+ {isConfluence && projectId && !isAddingConfluence && ( + + )} + + {!isLoading && draftSources.length > 0 && ( + + {inScopeCount} / {draftSources.length} in scope + + )} +
+ {isAddingConfluence && projectId && ( + setIsAddingConfluence(false)} + onSaved={() => { + retryLoadSources(); + onSourcesSaved?.(); + }} + /> + )} + {hasLoadedSelectedConnector && errorMessage && (

{errorMessage}

@@ -272,31 +301,51 @@ export function ConnectorSourcesSection({
- )} - {source.enabled ? "In scope" : "Excluded"} - + + + ); })} diff --git a/src/features/connectors/components/useConfluenceSync.ts b/src/features/connectors/components/useConfluenceSync.ts new file mode 100644 index 00000000..401c18a7 --- /dev/null +++ b/src/features/connectors/components/useConfluenceSync.ts @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { useToast } from "../../../context/useToast.ts"; +import { parseApiError } from "../../../services/apiError.ts"; +import { + confluenceService, + type ConfluenceIngestionResult, +} from "../../../services/sources/confluenceService.ts"; + +/** + * Hook to trigger a manual sync for a Confluence space connection and surface + * result toasts based on the ingestion status. + */ +export function useConfluenceSync(projectId?: string | null) { + const [syncingId, setSyncingId] = useState(null); + const toast = useToast(); + + const syncConnection = async ( + connectionId: string, + onSuccess?: (result: ConfluenceIngestionResult) => void, + ) => { + if (!projectId) { + toast.error("Project ID is required to sync Confluence spaces."); + return; + } + + setSyncingId(connectionId); + try { + const result = await confluenceService.syncConnection(projectId, connectionId); + + if (result.status === "COMPLETED") { + toast.success("Confluence sync completed", { + description: `${result.created} created, ${result.updated} updated, ${result.unchanged} unchanged.`, + }); + } else if (result.status === "PARTIAL") { + toast.warning("Confluence sync finished with errors", { + description: `${result.failed} pages failed out of ${result.discovered} discovered.`, + }); + } else { + toast.error("Confluence sync failed", { + description: "No pages could be ingested. Check connection permissions.", + }); + } + + onSuccess?.(result); + } catch (error) { + toast.error(parseApiError(error, "Failed to synchronize Confluence space.")); + } finally { + setSyncingId(null); + } + }; + + return { + syncConnection, + syncingId, + }; +} diff --git a/src/features/connectors/data.ts b/src/features/connectors/data.ts index 895cf17d..8e0ccfcf 100644 --- a/src/features/connectors/data.ts +++ b/src/features/connectors/data.ts @@ -1,11 +1,9 @@ -import { GitBranch, Plug } from "lucide-react"; +import { BookOpen, GitBranch, Plug } from "lucide-react"; import type { ConnectorDto } from "../../services/connectorService.ts"; import type { ConnectorListItem, ConnectorMeta } from "./types.ts"; /** * Presentation metadata for connectors known to the frontend today. - * Only "github" is implemented; any other connector id the backend returns - * (e.g. a future Jira connector) falls back to `getConnectorMeta` below. */ const CONNECTOR_META: Record = { github: { @@ -14,6 +12,11 @@ const CONNECTOR_META: Record = { "Commits, files, issues and pull request metadata from connected GitHub repositories.", icon: GitBranch, }, + confluence: { + label: "Confluence Cloud Connector", + description: "Pages and spaces from connected Confluence Cloud tenants.", + icon: BookOpen, + }, }; const FALLBACK_CONNECTOR_META: Omit = { diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx index 69841b7d..d6808493 100644 --- a/src/features/data-ingestion/components/AddSourceModal.tsx +++ b/src/features/data-ingestion/components/AddSourceModal.tsx @@ -7,6 +7,7 @@ import { useToast } from "../../../context/useToast.ts"; import { addDraftSource, connectDraftSources, + createConfluenceDraft, createDraftSourceFromDiscovery, createJiraDraft, createUploadDraft, @@ -93,8 +94,12 @@ export function AddSourceModal({ // The token list is owned here so an inline "add token" can refresh it and // auto-select the new token; it falls back to the prop until it has loaded so // discovery works on the first open without waiting for the refetch. - const { tokenNames: loadedTokenNames, tokensLoaded, loadTokenNames, addTokenNameLocally } = - useGithubTokens(); + const { + tokenNames: loadedTokenNames, + tokensLoaded, + loadTokenNames, + addTokenNameLocally, + } = useGithubTokens(); const effectiveTokenNames = tokensLoaded ? loadedTokenNames : tokenNames; // Jira detail state. @@ -102,6 +107,12 @@ export function AddSourceModal({ const [jiraUrl, setJiraUrl] = useState(""); const [jiraCredentialName, setJiraCredentialName] = useState(""); + // Confluence detail state. + const [confluenceBaseUrl, setConfluenceBaseUrl] = useState(""); + const [confluenceSpaceId, setConfluenceSpaceId] = useState(""); + const [confluenceEmail, setConfluenceEmail] = useState(""); + const [confluenceApiToken, setConfluenceApiToken] = useState(""); + // Upload detail state — files staged in memory until the list is connected. const [uploadFiles, setUploadFiles] = useState([]); @@ -148,6 +159,10 @@ export function AddSourceModal({ setJiraUrl(""); setJiraCredentialName(""); setUploadFiles([]); + setConfluenceBaseUrl(""); + setConfluenceSpaceId(""); + setConfluenceEmail(""); + setConfluenceApiToken(""); }; // --- Add-source sub-flow --- @@ -201,11 +216,18 @@ export function AddSourceModal({ ? Boolean(jiraDisplayName.trim() && jiraUrl.trim() && selectedJiraCredential) : addType === "UPLOAD" ? uploadFiles.length > 0 - : false; + : addType === "CONFLUENCE" + ? Boolean( + confluenceBaseUrl.trim() && + confluenceSpaceId.trim() && + confluenceEmail.trim() && + confluenceApiToken.trim(), + ) + : false; /** * The draft(s) captured on the current detail screen — several at once for the - * GitHub multi-select, one for Jira/Upload. Empty when the detail isn't + * GitHub multi-select, one for Jira/Upload/Confluence. Empty when the detail isn't * complete enough to stage. */ const buildDetailDrafts = (): DraftSource[] => { @@ -233,6 +255,17 @@ export function AddSourceModal({ return [createUploadDraft(displayName, uploadFiles)]; } + if (addType === "CONFLUENCE") { + return [ + createConfluenceDraft({ + baseUrl: confluenceBaseUrl.trim(), + spaceId: confluenceSpaceId.trim(), + email: confluenceEmail.trim(), + apiToken: confluenceApiToken.trim(), + }), + ]; + } + return []; }; @@ -501,6 +534,17 @@ export function AddSourceModal({ onRemoveFile: (index) => setUploadFiles((current) => current.filter((_, position) => position !== index)), }} + confluence={{ + baseUrl: confluenceBaseUrl, + spaceId: confluenceSpaceId, + email: confluenceEmail, + apiToken: confluenceApiToken, + onBaseUrlChange: setConfluenceBaseUrl, + onSpaceIdChange: setConfluenceSpaceId, + onEmailChange: setConfluenceEmail, + onApiTokenChange: setConfluenceApiToken, + onSubmit: commitAddSource, + }} /> ) : ( diff --git a/src/features/data-ingestion/components/ConfluenceConnectStep.tsx b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx new file mode 100644 index 00000000..a338de6b --- /dev/null +++ b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx @@ -0,0 +1,118 @@ +import { AlertTriangle } from "lucide-react"; +import { Field } from "../../../components/ui/Field.tsx"; +import { Input } from "../../../components/ui/Input.tsx"; + +/** + * Controlled Confluence connect form for base URL, space ID, and credentials. + * + * Shared between the Data Ingestion "Add source" wizard and the project-creation + * wizard so the Confluence connect experience is identical in both places. + */ +export function ConfluenceConnectStep({ + baseUrl, + spaceId, + email, + apiToken, + isBusy = false, + canIngest = true, + ingestBlockedReason, + errorMessage, + onBaseUrlChange, + onSpaceIdChange, + onEmailChange, + onApiTokenChange, + onSubmit, +}: { + baseUrl: string; + spaceId: string; + email: string; + apiToken: string; + isBusy?: boolean; + canIngest?: boolean; + ingestBlockedReason?: string; + errorMessage?: string | null; + onBaseUrlChange: (value: string) => void; + onSpaceIdChange: (value: string) => void; + onEmailChange: (value: string) => void; + onApiTokenChange: (value: string) => void; + onSubmit?: () => void; +}) { + return ( +
{ + event.preventDefault(); + onSubmit?.(); + }} + > + {!canIngest && ( +
+ {ingestBlockedReason ?? "You can only connect sources to projects you manage."} +
+ )} + + + onBaseUrlChange(event.target.value)} + placeholder="https://your-domain.atlassian.net" + required + /> + + + + onSpaceIdChange(event.target.value)} + placeholder="e.g. 123456" + required + /> + + + + onEmailChange(event.target.value)} + placeholder="user@example.com" + required + autoComplete="email" + /> + + + + onApiTokenChange(event.target.value)} + placeholder="Atlassian API token" + required + autoComplete="off" + /> + + + {errorMessage && ( +
+ + {errorMessage} +
+ )} +
+ ); +} diff --git a/src/features/data-ingestion/components/RunDetailsPanel.tsx b/src/features/data-ingestion/components/RunDetailsPanel.tsx index f030817a..90208878 100644 --- a/src/features/data-ingestion/components/RunDetailsPanel.tsx +++ b/src/features/data-ingestion/components/RunDetailsPanel.tsx @@ -189,6 +189,10 @@ function buildOriginRow(run: IngestionRun): { label: string; value: string } | n return owner ? { label: "Owner", value: owner } : null; } + if (run.sourceSystem === "CONFLUENCE") { + return run.sourceId ? { label: "Space", value: run.sourceId } : null; + } + return null; } diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx index 45330555..19383179 100644 --- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx +++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx @@ -1,5 +1,6 @@ import { ArrowUp, + BookOpen, CalendarClock, Clock3, Database, @@ -99,14 +100,18 @@ export function SourceDetailsPanel({ const Icon = SOURCE_META[source.sourceSystem].icon; const repository = source.githubRepository; const jira = source.jiraInstance ?? null; + const confluence = source.confluenceSpace ?? null; const isJira = source.sourceSystem === "JIRA"; + const isConfluence = source.sourceSystem === "CONFLUENCE"; const isUpdating = updateState === "loading"; const isRefreshing = refreshState === "loading"; - // Update is available for a GitHub repo (needs owner/name) or a Jira instance - // (needs its URL); enable/disable and unlink stay GitHub-only for now. + // Update is available for a GitHub repo (needs owner/name), a Jira instance + // (needs its URL), or a Confluence space (needs its ID). const canUpdate = onUpdateSource !== undefined && - ((source.sourceSystem === "GITHUB" && repository !== null) || (isJira && jira !== null)); + ((source.sourceSystem === "GITHUB" && repository !== null) || + (isJira && jira !== null) || + (isConfluence && Boolean(source.sourceId))); const canManageRepositoryConfig = canManageSyncSettings && source.sourceSystem === "GITHUB" && @@ -308,16 +313,26 @@ export function SourceDetailsPanel({ }} disabled={!canUpdate || isRefreshing} loading={isUpdating} - icon={isJira ? : } + icon={ + isJira ? ( + + ) : isConfluence ? ( + + ) : ( + + ) + } title={ canUpdate ? undefined : isJira ? "Instance updates need the Jira instance URL." - : "Repository updates need GitHub owner and repository name." + : isConfluence + ? "Space updates need the Confluence space ID." + : "Repository updates need GitHub owner and repository name." } > - {isJira ? "Update instance" : "Update repo"} + {isJira ? "Update instance" : isConfluence ? "Update space" : "Update repo"} setIsAddOpen(false)} @@ -99,19 +99,19 @@ function renderGroup() { ); } -describe("AccessConnectorGroup — Jira", () => { +describe("AccessConnectorGroup — Atlassian", () => { beforeEach(() => { vi.clearAllMocks(); mockAuthEmail(EMAIL); - vi.mocked(getMyJiraCredentials).mockResolvedValue([cred("default")]); - vi.mocked(addJiraCredential).mockResolvedValue(undefined); - vi.mocked(changeJiraCredentialName).mockResolvedValue(cred("renamed")); - vi.mocked(changeJiraCredentialToken).mockResolvedValue(cred("default")); - vi.mocked(deleteJiraCredential).mockResolvedValue(undefined); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([cred("default")]); + vi.mocked(addAtlassianCredential).mockResolvedValue(undefined); + vi.mocked(changeAtlassianCredentialName).mockResolvedValue(cred("renamed")); + vi.mocked(changeAtlassianCredentialToken).mockResolvedValue(cred("default")); + vi.mocked(deleteAtlassianCredential).mockResolvedValue(undefined); }); - it("renders all credentials with their Jira account emails", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + it("renders all credentials with their Atlassian account emails", async () => { + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ cred("default"), cred("support", "support@corp.com"), ]); @@ -123,11 +123,11 @@ describe("AccessConnectorGroup — Jira", () => { expect(screen.getByText(EMAIL)).toBeInTheDocument(); expect(screen.getByText("support@corp.com")).toBeInTheDocument(); expect(screen.getByText("2 credentials")).toBeInTheDocument(); - expect(getMyJiraCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); + expect(getMyAtlassianCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); }); - it("keeps two credentials of the same name apart by Jira account", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + it("keeps two credentials of the same name apart by Atlassian account", async () => { + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ cred("default"), cred("default", "other@corp.com"), ]); @@ -143,23 +143,23 @@ describe("AccessConnectorGroup — Jira", () => { renderGroup(); expect(await screen.findByText("default")).toBeInTheDocument(); - expect(getMyJiraCredentials).toHaveBeenCalled(); + expect(getMyAtlassianCredentials).toHaveBeenCalled(); await userEvent.setup().click(screen.getByRole("button", { name: "open add form" })); - expect(screen.getByTestId("settings-jira-add-email")).toHaveValue(""); + expect(screen.getByTestId("settings-atlassian-add-email")).toHaveValue(""); }); it("shows the empty state when there are no credentials", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderGroup(); expect(await screen.findByText("No credentials yet")).toBeInTheDocument(); }); - it("prefills the Jira email and adds a credential", async () => { + it("prefills the Atlassian email and adds a credential", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("default")]) .mockResolvedValueOnce([cred("default"), cred("ci")]); @@ -167,13 +167,13 @@ describe("AccessConnectorGroup — Jira", () => { await screen.findByText("default"); await user.click(screen.getByRole("button", { name: "open add form" })); - expect(screen.getByTestId("settings-jira-add-email")).toHaveValue(EMAIL); - await user.type(screen.getByTestId("settings-jira-add-name"), "ci"); - await user.type(screen.getByTestId("settings-jira-add-token"), "secret-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + expect(screen.getByTestId("settings-atlassian-add-email")).toHaveValue(EMAIL); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "ci"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "secret-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(addJiraCredential).toHaveBeenCalledWith({ + expect(addAtlassianCredential).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "ci", authToken: "secret-token", @@ -182,21 +182,21 @@ describe("AccessConnectorGroup — Jira", () => { expect(await screen.findByText("2 credentials")).toBeInTheDocument(); }); - it("allows a Jira email different from the login email", async () => { + it("allows an Atlassian email different from the login email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); await user.click(screen.getByRole("button", { name: "open add form" })); - const emailInput = screen.getByTestId("settings-jira-add-email"); + const emailInput = screen.getByTestId("settings-atlassian-add-email"); await user.clear(emailInput); await user.type(emailInput, "jira-account@atlassian.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "work"); - await user.type(screen.getByTestId("settings-jira-add-token"), "tok"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "work"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "tok"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(addJiraCredential).toHaveBeenCalledWith({ + expect(addAtlassianCredential).toHaveBeenCalledWith({ userEmail: "jira-account@atlassian.com", tokenName: "work", authToken: "tok", @@ -204,19 +204,19 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("renames a credential using its stored Jira email", async () => { + it("renames a credential using its stored Atlassian email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-rename-open-default")); - const input = screen.getByTestId("settings-jira-rename-input-default"); + await user.click(screen.getByTestId("settings-atlassian-rename-open-default")); + const input = screen.getByTestId("settings-atlassian-rename-input-default"); await user.clear(input); await user.type(input, "renamed"); - await user.click(screen.getByTestId("settings-jira-rename-submit-default")); + await user.click(screen.getByTestId("settings-atlassian-rename-submit-default")); await waitFor(() => - expect(changeJiraCredentialName).toHaveBeenCalledWith({ + expect(changeAtlassianCredentialName).toHaveBeenCalledWith({ userEmail: EMAIL, oldName: "default", newName: "renamed", @@ -224,17 +224,17 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("rotates a credential token using its stored Jira email", async () => { + it("rotates a credential token using its stored Atlassian email", async () => { const user = userEvent.setup(); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-rotate-open-default")); - await user.type(screen.getByTestId("settings-jira-rotate-input-default"), "new-token"); - await user.click(screen.getByTestId("settings-jira-rotate-submit-default")); + await user.click(screen.getByTestId("settings-atlassian-rotate-open-default")); + await user.type(screen.getByTestId("settings-atlassian-rotate-input-default"), "new-token"); + await user.click(screen.getByTestId("settings-atlassian-rotate-submit-default")); await waitFor(() => - expect(changeJiraCredentialToken).toHaveBeenCalledWith({ + expect(changeAtlassianCredentialToken).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "default", newToken: "new-token", @@ -242,20 +242,20 @@ describe("AccessConnectorGroup — Jira", () => { ); }); - it("deletes a credential using its stored Jira email and refreshes", async () => { + it("deletes a credential using its stored Atlassian email and refreshes", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("default")]) .mockResolvedValueOnce([]); renderGroup(); await screen.findByText("default"); - await user.click(screen.getByTestId("settings-jira-delete-open-default")); - await user.click(screen.getByTestId("settings-jira-delete-confirm-default")); + await user.click(screen.getByTestId("settings-atlassian-delete-open-default")); + await user.click(screen.getByTestId("settings-atlassian-delete-confirm-default")); await waitFor(() => - expect(deleteJiraCredential).toHaveBeenCalledWith({ + expect(deleteAtlassianCredential).toHaveBeenCalledWith({ userEmail: EMAIL, tokenName: "default", }), diff --git a/tests/unit/features/access/AccessManagementView.test.tsx b/tests/unit/features/access/AccessManagementView.test.tsx index ad447dad..55d3e60d 100644 --- a/tests/unit/features/access/AccessManagementView.test.tsx +++ b/tests/unit/features/access/AccessManagementView.test.tsx @@ -21,17 +21,17 @@ vi.mock("../../../../src/services/sources/githubService", () => ({ deleteGithubPat: vi.fn(), })); -vi.mock("../../../../src/services/sources/jiraService", () => ({ - getMyJiraCredentials: vi.fn(), - addJiraCredential: vi.fn(), - changeJiraCredentialName: vi.fn(), - changeJiraCredentialToken: vi.fn(), - deleteJiraCredential: vi.fn(), +vi.mock("../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), + addAtlassianCredential: vi.fn(), + changeAtlassianCredentialName: vi.fn(), + changeAtlassianCredentialToken: vi.fn(), + deleteAtlassianCredential: vi.fn(), })); import { useAuth } from "../../../../src/context/useAuth"; import { getGithubPatNames } from "../../../../src/services/sources/githubService"; -import { getMyJiraCredentials } from "../../../../src/services/sources/jiraService"; +import { getMyAtlassianCredentials } from "../../../../src/services/sources/atlassianService"; /** * The filter is owned by whoever hosts the view, so that it survives the admin @@ -68,8 +68,8 @@ describe("AccessManagementView", () => { status: "authenticated", } as unknown as ReturnType); vi.mocked(getGithubPatNames).mockResolvedValue(["gh-default"]); - vi.mocked(getMyJiraCredentials).mockResolvedValue([ - { userEmail: "user@corp.com", displayName: "jira-default" }, + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ + { userEmail: "user@corp.com", displayName: "atlassian-default" }, ]); }); @@ -79,13 +79,13 @@ describe("AccessManagementView", () => { // Visibility rather than presence: a filtered-out source stays mounted, so // its rows are in the DOM either way. await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); expect(screen.getByText("gh-default")).toBeInTheDocument(); - expect(screen.getByText("jira-default")).toBeInTheDocument(); + expect(screen.getByText("atlassian-default")).toBeInTheDocument(); // Both sources fetched without anyone switching to them. expect(getGithubPatNames).toHaveBeenCalled(); - expect(getMyJiraCredentials).toHaveBeenCalled(); + expect(getMyAtlassianCredentials).toHaveBeenCalled(); }); it("renders one group per registered connector, in registry order", async () => { @@ -115,27 +115,27 @@ describe("AccessManagementView", () => { it("hides a source with no credentials by default and reveals it under All sources", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); await selectSource(user, "All sources"); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); expect(screen.getByText("No credentials yet")).toBeVisible(); }); it("keeps loading every source while unused ones are hidden", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); // The filter can only know which sources are in use because the hidden // ones are still mounted and still fetch. - await waitFor(() => expect(getMyJiraCredentials).toHaveBeenCalled()); + await waitFor(() => expect(getMyAtlassianCredentials).toHaveBeenCalled()); }); it("narrows the list to a single source and back", async () => { @@ -144,30 +144,30 @@ describe("AccessManagementView", () => { await waitFor(() => expect(screen.getByText("gh-default")).toBeInTheDocument()); - await selectSource(user, "Jira"); + await selectSource(user, "Atlassian"); await waitFor(() => expect(screen.getByTestId("access-group-github")).not.toBeVisible()); - expect(screen.getByTestId("access-group-jira")).toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).toBeVisible(); await selectSource(user, "All sources"); await waitFor(() => expect(screen.getByTestId("access-group-github")).toBeVisible()); - expect(screen.getByTestId("access-group-jira")).toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).toBeVisible(); }); it("adds to a hidden source through the global add button", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); await user.click(screen.getByTestId("access-add-open")); - await user.click(await screen.findByTestId("access-add-source-jira")); + await user.click(await screen.findByTestId("access-add-source-atlassian")); // Choosing a source reveals it and opens its form, even though it holds // nothing and the default filter had hidden it. - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); - expect(screen.getByTestId("settings-jira-add-email")).toBeVisible(); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); + expect(screen.getByTestId("settings-atlassian-add-email")).toBeVisible(); }); it("takes the filter from its host instead of owning it", async () => { @@ -186,27 +186,27 @@ describe("AccessManagementView", () => { , ); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); expect(screen.getByTestId("access-group-github")).toBeVisible(); await user.click(screen.getByRole("combobox", { name: "Filter access by source" })); - await user.click(await screen.findByRole("option", { name: "Jira" })); + await user.click(await screen.findByRole("option", { name: "Atlassian" })); - expect(onSourceFilterChange).toHaveBeenCalledWith("jira"); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(onSourceFilterChange).toHaveBeenCalledWith("atlassian"); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); }); it("leaves the filter where the user put it and hides the source again on cancel", async () => { const user = userEvent.setup(); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); await user.click(screen.getByTestId("access-add-open")); - await user.click(await screen.findByTestId("access-add-source-jira")); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).toBeVisible()); + await user.click(await screen.findByTestId("access-add-source-atlassian")); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).toBeVisible()); // Revealing a source for the add form must not rewrite the filter. expect(screen.getByRole("combobox", { name: "Filter access by source" })).toHaveTextContent( @@ -215,7 +215,7 @@ describe("AccessManagementView", () => { await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(screen.getByTestId("access-group-jira")).not.toBeVisible()); + await waitFor(() => expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible()); expect(screen.getByRole("combobox", { name: "Filter access by source" })).toHaveTextContent( "In use", ); @@ -226,23 +226,23 @@ describe("AccessManagementView", () => { renderView(); await user.click(screen.getByTestId("access-add-open")); - expect(await screen.findByTestId("access-add-source-jira")).toBeInTheDocument(); + expect(await screen.findByTestId("access-add-source-atlassian")).toBeInTheDocument(); await user.keyboard("{Escape}"); await waitFor(() => - expect(screen.queryByTestId("access-add-source-jira")).not.toBeInTheDocument(), + expect(screen.queryByTestId("access-add-source-atlassian")).not.toBeInTheDocument(), ); }); it("explains the empty view when no source is set up at all", async () => { vi.mocked(getGithubPatNames).mockResolvedValue([]); - vi.mocked(getMyJiraCredentials).mockResolvedValue([]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([]); renderView(); expect(await screen.findByText("No source is set up yet")).toBeVisible(); expect(screen.getByTestId("access-group-github")).not.toBeVisible(); - expect(screen.getByTestId("access-group-jira")).not.toBeVisible(); + expect(screen.getByTestId("access-group-atlassian")).not.toBeVisible(); }); }); diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx index c310ce07..63c15997 100644 --- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx +++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx @@ -30,8 +30,11 @@ vi.mock("../../../../../src/services/ingestionService", () => ({ vi.mock("../../../../../src/services/sources/jiraService", () => ({ connectJiraInstance: vi.fn(), - getMyJiraCredentials: vi.fn(), - addJiraCredential: vi.fn(), +})); + +vi.mock("../../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), + addAtlassianCredential: vi.fn(), })); vi.mock("../../../../../src/services/knowledgeService", () => ({ @@ -46,11 +49,11 @@ import { discoverRepositories, getGithubPatNames, } from "../../../../../src/services/sources/githubService"; +import { connectJiraInstance } from "../../../../../src/services/sources/jiraService"; import { - addJiraCredential, - connectJiraInstance, - getMyJiraCredentials, -} from "../../../../../src/services/sources/jiraService"; + addAtlassianCredential, + getMyAtlassianCredentials, +} from "../../../../../src/services/sources/atlassianService"; import { knowledgeService } from "../../../../../src/services/knowledgeService"; import { getIngestionSourceStatuses } from "../../../../../src/services/ingestionService"; @@ -189,10 +192,10 @@ describe("CreateProjectWizard", () => { vi.mocked(getIngestionSourceStatuses).mockResolvedValue([]); vi.mocked(getGithubPatNames).mockResolvedValue(["team-pat"]); vi.mocked(addGithubPat).mockResolvedValue(undefined); - vi.mocked(addJiraCredential).mockResolvedValue(undefined); + vi.mocked(addAtlassianCredential).mockResolvedValue(undefined); vi.mocked(projectService.assignUsersToProject).mockResolvedValue([]); vi.mocked(connectJiraInstance).mockResolvedValue(undefined); - vi.mocked(getMyJiraCredentials).mockResolvedValue([ + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([ { userEmail: "me@example.com", displayName: "Team token" }, ]); vi.mocked(knowledgeService.uploadDocuments).mockResolvedValue([ @@ -587,7 +590,7 @@ describe("CreateProjectWizard", () => { }); it("adds a Jira credential inline and selects the new one", async () => { - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([]) // initial load: none stored .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]); const user = userEvent.setup(); @@ -597,14 +600,14 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add source/i })); await user.click(screen.getByRole("button", { name: /indexes jira issues/i })); - await user.click(screen.getByRole("button", { name: /add jira credential/i })); - await user.type(screen.getByTestId("settings-jira-add-email"), "new@example.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "Fresh cred"); - await user.type(screen.getByTestId("settings-jira-add-token"), "jira-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "jira-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); await waitFor(() => - expect(vi.mocked(addJiraCredential)).toHaveBeenCalledWith({ + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({ userEmail: "new@example.com", tokenName: "Fresh cred", authToken: "jira-token", @@ -651,11 +654,11 @@ describe("CreateProjectWizard", () => { it("keeps the new Jira credential visible and selected when the post-add refetch fails", async () => { let added = false; - vi.mocked(addJiraCredential).mockImplementation(() => { + vi.mocked(addAtlassianCredential).mockImplementation(() => { added = true; return Promise.resolve(); }); - vi.mocked(getMyJiraCredentials).mockImplementation(() => + vi.mocked(getMyAtlassianCredentials).mockImplementation(() => added ? Promise.reject(new Error("Network error")) : Promise.resolve([]), ); const user = userEvent.setup(); @@ -665,16 +668,16 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add source/i })); await user.click(screen.getByRole("button", { name: /indexes jira issues/i })); - await user.click(screen.getByRole("button", { name: /add jira credential/i })); - await user.type(screen.getByTestId("settings-jira-add-email"), "new@example.com"); - await user.type(screen.getByTestId("settings-jira-add-name"), "Fresh cred"); - await user.type(screen.getByTestId("settings-jira-add-token"), "jira-token"); - await user.click(screen.getByTestId("settings-jira-add-submit")); + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "jira-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); - await waitFor(() => expect(vi.mocked(addJiraCredential)).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledTimes(1)); await screen.findByText(/Fresh cred - new@example.com/i); expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred"); - expect(vi.mocked(addJiraCredential)).toHaveBeenCalledTimes(1); + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledTimes(1); }); it("does not create the project when cancelled on the first step", async () => { diff --git a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx index 9f419cc3..863bd6aa 100644 --- a/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx +++ b/tests/unit/features/data-ingestion/components/AddSourceModal.test.tsx @@ -70,8 +70,8 @@ function sourceStatusHandler({ inThisProject = [] as string[] } = {}) { }); } -function jiraCredentialsHandler(names: string[], email = "me@corp.com") { - return http.get("/api/v1/jira/credentials", () => +function atlassianCredentialsHandler(names: string[], email = "me@corp.com") { + return http.get("/api/v1/atlassian/credentials", () => HttpResponse.json(names.map((displayName) => ({ userEmail: email, displayName }))), ); } @@ -109,7 +109,7 @@ describe("AddSourceModal", () => { }); it("offers both 'Add to list' and 'Connect now' on a detail screen", async () => { - server.use(jiraCredentialsHandler(["default"])); + server.use(atlassianCredentialsHandler(["default"])); const user = userEvent.setup(); renderModal(); @@ -127,7 +127,7 @@ describe("AddSourceModal", () => { it("stages a Jira instance and connects the list", async () => { let capturedBody: unknown = null; server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", async ({ request }) => { capturedBody = await request.json(); return new HttpResponse(null, { status: 202 }); @@ -163,7 +163,7 @@ describe("AddSourceModal", () => { it("connects a source immediately with 'Connect now'", async () => { let capturedBody: unknown = null; server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", async ({ request }) => { capturedBody = await request.json(); return new HttpResponse(null, { status: 202 }); @@ -192,7 +192,7 @@ describe("AddSourceModal", () => { it("surfaces a failed connect on the connecting screen with a retry", async () => { server.use( - jiraCredentialsHandler(["default"]), + atlassianCredentialsHandler(["default"]), http.post("/api/v1/jira/connect", () => HttpResponse.json({ message: "bad gateway" }, { status: 502 }), ), @@ -350,7 +350,7 @@ describe("AddSourceModal", () => { }); it("keeps building the list across source types before connecting", async () => { - server.use(discoveryHandler, jiraCredentialsHandler(["default"])); + server.use(discoveryHandler, atlassianCredentialsHandler(["default"])); const user = userEvent.setup(); renderModal(); diff --git a/tests/unit/features/settings/useJiraCredentials.test.ts b/tests/unit/features/settings/useAtlassianCredentials.test.ts similarity index 59% rename from tests/unit/features/settings/useJiraCredentials.test.ts rename to tests/unit/features/settings/useAtlassianCredentials.test.ts index de6de463..a891739e 100644 --- a/tests/unit/features/settings/useJiraCredentials.test.ts +++ b/tests/unit/features/settings/useAtlassianCredentials.test.ts @@ -1,47 +1,47 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; -import { useJiraCredentials } from "../../../../src/features/settings/hooks/useJiraCredentials"; -import type { JiraCredentialsDto } from "../../../../src/services/sources/jiraService"; +import { useAtlassianCredentials } from "../../../../src/features/settings/hooks/useAtlassianCredentials"; +import type { AtlassianCredentialDto } from "../../../../src/services/sources/atlassianService"; -vi.mock("../../../../src/services/sources/jiraService", () => ({ - getMyJiraCredentials: vi.fn(), +vi.mock("../../../../src/services/sources/atlassianService", () => ({ + getMyAtlassianCredentials: vi.fn(), })); -import { getMyJiraCredentials } from "../../../../src/services/sources/jiraService"; +import { getMyAtlassianCredentials } from "../../../../src/services/sources/atlassianService"; -const cred = (displayName: string): JiraCredentialsDto => ({ +const cred = (displayName: string): AtlassianCredentialDto => ({ userEmail: "a@b.com", displayName, }); -describe("useJiraCredentials", () => { +describe("useAtlassianCredentials", () => { beforeEach(() => { vi.clearAllMocks(); }); it("loads the authenticated user's credentials on mount", async () => { - vi.mocked(getMyJiraCredentials).mockResolvedValue([cred("default"), cred("ci")]); + vi.mocked(getMyAtlassianCredentials).mockResolvedValue([cred("default"), cred("ci")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.credentials.map((c) => c.displayName)).toEqual(["default", "ci"]); expect(result.current.error).toBeNull(); - expect(getMyJiraCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); + expect(getMyAtlassianCredentials).toHaveBeenCalledWith(expect.any(AbortSignal)); }); it("settles into a loaded-empty state without fetching when disabled", async () => { - const { result } = renderHook(() => useJiraCredentials(false)); + const { result } = renderHook(() => useAtlassianCredentials(false)); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.credentials).toEqual([]); - expect(getMyJiraCredentials).not.toHaveBeenCalled(); + expect(getMyAtlassianCredentials).not.toHaveBeenCalled(); }); it("surfaces an error message when loading fails", async () => { - vi.mocked(getMyJiraCredentials).mockRejectedValue(new Error("Network down")); + vi.mocked(getMyAtlassianCredentials).mockRejectedValue(new Error("Network down")); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.loaded).toBe(true)); expect(result.current.error).toBe("Network down"); @@ -49,11 +49,11 @@ describe("useJiraCredentials", () => { }); it("reloads credentials via reload", async () => { - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([cred("a")]) .mockResolvedValueOnce([cred("a"), cred("b")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.credentials.map((c) => c.displayName)).toEqual(["a"]), @@ -69,15 +69,15 @@ describe("useJiraCredentials", () => { }); it("a slow stale fetch does not overwrite a newer one", async () => { - let resolveSlow: (list: JiraCredentialsDto[]) => void = () => {}; - const slow = new Promise((resolve) => { + let resolveSlow: (list: AtlassianCredentialDto[]) => void = () => {}; + const slow = new Promise((resolve) => { resolveSlow = resolve; }); - vi.mocked(getMyJiraCredentials) + vi.mocked(getMyAtlassianCredentials) .mockReturnValueOnce(slow) .mockResolvedValueOnce([cred("fresh")]); - const { result } = renderHook(() => useJiraCredentials()); + const { result } = renderHook(() => useAtlassianCredentials()); await waitFor(() => expect(result.current.isRefreshing).toBe(true)); await act(async () => { diff --git a/tests/unit/services/atlassianService.test.ts b/tests/unit/services/atlassianService.test.ts new file mode 100644 index 00000000..84db65d9 --- /dev/null +++ b/tests/unit/services/atlassianService.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../setup/vitest.setup"; +import { + addAtlassianCredential, + changeAtlassianCredentialName, + changeAtlassianCredentialToken, + deleteAtlassianCredential, + getMyAtlassianCredentials, +} from "../../../src/services/sources/atlassianService"; + +describe("atlassianService credential endpoints", () => { + it("addAtlassianCredential posts the credential payload", async () => { + expect.assertions(1); + + server.use( + http.post("/api/v1/atlassian/credentials", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + authToken: "secret", + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await addAtlassianCredential({ + userEmail: "pm@example.com", + tokenName: "token-a", + authToken: "secret", + }); + }); + + it("getMyAtlassianCredentials lists the authenticated user's credentials", async () => { + server.use( + http.get("/api/v1/atlassian/credentials", () => + HttpResponse.json([{ userEmail: "pm+user@example.com", displayName: "token-a" }]), + ), + ); + + const credentials = await getMyAtlassianCredentials(); + + expect(credentials).toHaveLength(1); + expect(credentials[0]).toEqual({ + userEmail: "pm+user@example.com", + displayName: "token-a", + }); + }); + + it("deleteAtlassianCredential sends a DELETE with the credential identity", async () => { + expect.assertions(1); + + server.use( + http.delete("/api/v1/atlassian/credentials", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + }); + + return new HttpResponse(null, { status: 204 }); + }), + ); + + await deleteAtlassianCredential({ + userEmail: "pm@example.com", + tokenName: "token-a", + }); + }); + + it("deleteAtlassianCredential rejects with an ApiError on 404", async () => { + server.use( + http.delete("/api/v1/atlassian/credentials", () => + HttpResponse.json({ message: "unknown credential" }, { status: 404 }), + ), + ); + + await expect( + deleteAtlassianCredential({ userEmail: "pm@example.com", tokenName: "gone" }), + ).rejects.toThrow(); + }); + + it("changeAtlassianCredentialName patches the name and returns the credential", async () => { + server.use( + http.patch("/api/v1/atlassian/credentials/patch/name", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + oldName: "token-a", + newName: "token-b", + }); + + return HttpResponse.json({ + userEmail: "pm@example.com", + displayName: "token-b", + }); + }), + ); + + const credential = await changeAtlassianCredentialName({ + userEmail: "pm@example.com", + oldName: "token-a", + newName: "token-b", + }); + + expect(credential.displayName).toBe("token-b"); + }); + + it("changeAtlassianCredentialToken patches the token secret", async () => { + server.use( + http.patch("/api/v1/atlassian/credentials/patch/token", async ({ request }) => { + expect(await request.json()).toEqual({ + userEmail: "pm@example.com", + tokenName: "token-a", + newToken: "new-secret", + }); + + return HttpResponse.json({ + userEmail: "pm@example.com", + displayName: "token-a", + }); + }), + ); + + const credential = await changeAtlassianCredentialToken({ + userEmail: "pm@example.com", + tokenName: "token-a", + newToken: "new-secret", + }); + + expect(credential.displayName).toBe("token-a"); + }); +}); diff --git a/tests/unit/services/jiraService.test.ts b/tests/unit/services/jiraService.test.ts index da376514..c619fed9 100644 --- a/tests/unit/services/jiraService.test.ts +++ b/tests/unit/services/jiraService.test.ts @@ -2,16 +2,11 @@ import { describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../setup/vitest.setup"; import { - addJiraCredential, - changeJiraCredentialName, - changeJiraCredentialToken, configureAllJiraInstances, configureJiraInstance, connectJiraInstance, - deleteJiraCredential, getAllJiraConfigs, getJiraConfig, - getMyJiraCredentials, getJiraInstances, removeJiraInstanceFromProject, updateAllJiraInstances, @@ -136,127 +131,6 @@ describe("jiraService instance endpoints", () => { }); }); -describe("jiraService credential endpoints", () => { - it("addJiraCredential posts the credential payload", async () => { - expect.assertions(1); - - server.use( - http.post("/api/v1/jira/credentials", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - authToken: "secret", - }); - - return new HttpResponse(null, { status: 204 }); - }), - ); - - await addJiraCredential({ - userEmail: "pm@example.com", - tokenName: "token-a", - authToken: "secret", - }); - }); - - it("getMyJiraCredentials lists the authenticated user's credentials", async () => { - server.use( - http.get("/api/v1/jira/credentials", () => - HttpResponse.json([{ userEmail: "pm+user@example.com", displayName: "token-a" }]), - ), - ); - - const credentials = await getMyJiraCredentials(); - - expect(credentials).toHaveLength(1); - expect(credentials[0]).toEqual({ - userEmail: "pm+user@example.com", - displayName: "token-a", - }); - }); - it("deleteJiraCredential sends a DELETE with the credential identity", async () => { - expect.assertions(1); - - server.use( - http.delete("/api/v1/jira/credentials", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - }); - - return new HttpResponse(null, { status: 204 }); - }), - ); - - await deleteJiraCredential({ - userEmail: "pm@example.com", - tokenName: "token-a", - }); - }); - - it("deleteJiraCredential rejects with an ApiError on 404", async () => { - server.use( - http.delete("/api/v1/jira/credentials", () => - HttpResponse.json({ message: "unknown credential" }, { status: 404 }), - ), - ); - - await expect( - deleteJiraCredential({ userEmail: "pm@example.com", tokenName: "gone" }), - ).rejects.toThrow(); - }); - - it("changeJiraCredentialName patches the name and returns the credential", async () => { - server.use( - http.patch("/api/v1/jira/credentials/patch/name", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - oldName: "token-a", - newName: "token-b", - }); - - return HttpResponse.json({ - userEmail: "pm@example.com", - displayName: "token-b", - }); - }), - ); - - const credential = await changeJiraCredentialName({ - userEmail: "pm@example.com", - oldName: "token-a", - newName: "token-b", - }); - - expect(credential.displayName).toBe("token-b"); - }); - - it("changeJiraCredentialToken patches the token secret", async () => { - server.use( - http.patch("/api/v1/jira/credentials/patch/token", async ({ request }) => { - expect(await request.json()).toEqual({ - userEmail: "pm@example.com", - tokenName: "token-a", - newToken: "new-secret", - }); - - return HttpResponse.json({ - userEmail: "pm@example.com", - displayName: "token-a", - }); - }), - ); - - const credential = await changeJiraCredentialToken({ - userEmail: "pm@example.com", - tokenName: "token-a", - newToken: "new-secret", - }); - - expect(credential.displayName).toBe("token-a"); - }); -}); - describe("jiraService config endpoints", () => { it("getAllJiraConfigs lists every instance config", async () => { server.use( diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts index 064f1b56..efadf190 100644 --- a/tests/unit/setup/msw-handlers.ts +++ b/tests/unit/setup/msw-handlers.ts @@ -117,7 +117,7 @@ export const handlers = [ // management view mounts every connector at once, so both are needed even in // tests that are not about credentials at all. http.get("/api/v1/github/pat", () => HttpResponse.json([])), - http.get("/api/v1/jira/credentials", () => HttpResponse.json([])), + http.get("/api/v1/atlassian/credentials", () => HttpResponse.json([])), http.get("/api/v1/projects/:projectId/artifacts", () => HttpResponse.json({ From 1e767fe90579942883e974f7ee75b9487aba6ee9 Mon Sep 17 00:00:00 2001 From: kiranfin <4finjas@online.de> Date: Sat, 5 Sep 2026 23:38:31 +0200 Subject: [PATCH 10/14] Show the readable space name and credential on Confluence sources - add spaceName and credentialName to ConfluenceConnectionDto and ConfluenceSpaceSourceDetails - name Confluence source cards from spaceName, falling back to spaceKey then spaceId - show the credential name in the Confluence source details drawer --- .../components/SourceDetailsPanel.tsx | 3 +++ src/features/data-ingestion/data.ts | 4 +++- src/features/data-ingestion/types.ts | 2 ++ src/services/sources/confluenceService.ts | 2 ++ .../unit/features/data-ingestion/data.test.ts | 22 +++++++++++++++++++ tests/unit/services/confluenceService.test.ts | 2 ++ tests/unit/setup/msw-handlers.ts | 2 ++ 7 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx index 1312ab77..76299203 100644 --- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx +++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx @@ -418,6 +418,9 @@ export function SourceDetailsPanel({ {confluence?.spaceKey && } {confluence?.baseUrl && } + {confluence?.credentialName && ( + + )} )} diff --git a/src/features/data-ingestion/data.ts b/src/features/data-ingestion/data.ts index 458b715e..6deae11f 100644 --- a/src/features/data-ingestion/data.ts +++ b/src/features/data-ingestion/data.ts @@ -332,7 +332,7 @@ export function createConfluenceSourceFromConnection( return { sourceId: connection.id, sourceSystem: "CONFLUENCE", - name: connection.spaceKey || connection.spaceId, + name: connection.spaceName ?? connection.spaceKey ?? connection.spaceId, type: meta.type, icon: meta.icon, status: getSourceStatusFromBackend(backendStatus), @@ -369,6 +369,8 @@ export function createConfluenceSourceFromConnection( baseUrl: connection.baseUrl, spaceId: connection.spaceId, spaceKey: connection.spaceKey, + spaceName: connection.spaceName, + credentialName: connection.credentialName, }, lastCommitsSyncAt: null, lastIssuesSyncAt: null, diff --git a/src/features/data-ingestion/types.ts b/src/features/data-ingestion/types.ts index 77f5ca92..38c24bca 100644 --- a/src/features/data-ingestion/types.ts +++ b/src/features/data-ingestion/types.ts @@ -197,6 +197,8 @@ export type ConfluenceSpaceSourceDetails = { baseUrl: string; spaceId: string; spaceKey: string; + spaceName?: string | null; + credentialName?: string; }; export type ActiveTab = "sources" | "artifacts" | "runs" | "connectors"; diff --git a/src/services/sources/confluenceService.ts b/src/services/sources/confluenceService.ts index a315c478..b7a41468 100644 --- a/src/services/sources/confluenceService.ts +++ b/src/services/sources/confluenceService.ts @@ -25,6 +25,8 @@ export type ConfluenceConnectionDto = { baseUrl: string; spaceId: string; spaceKey: string; + spaceName: string | null; + credentialName: string; pageAllowlist: string[]; pageDenylist: string[]; credentialsConfigured: boolean; diff --git a/tests/unit/features/data-ingestion/data.test.ts b/tests/unit/features/data-ingestion/data.test.ts index 126f5d72..625b3bc3 100644 --- a/tests/unit/features/data-ingestion/data.test.ts +++ b/tests/unit/features/data-ingestion/data.test.ts @@ -390,6 +390,8 @@ describe("data-ingestion data helpers", () => { baseUrl: "https://myteam.atlassian.net", spaceId: "123456", spaceKey: "DOCS", + spaceName: null, + credentialName: "default", pageAllowlist: [], pageDenylist: [], credentialsConfigured: true, @@ -457,6 +459,26 @@ describe("data-ingestion data helpers", () => { expect(source.artifacts).toBe(6); }); + it("names the card from spaceName, falling back to spaceKey when there is none", () => { + expect( + createConfluenceSourceFromConnection({ ...confluenceConn, spaceName: "Docs Space" }).name, + ).toBe("Docs Space"); + expect( + createConfluenceSourceFromConnection({ ...confluenceConn, spaceName: null }).name, + ).toBe("DOCS"); + }); + + it("carries spaceName and credentialName onto the source's confluenceSpace details", () => { + const source = createConfluenceSourceFromConnection({ + ...confluenceConn, + spaceName: "Docs Space", + credentialName: "team-cred", + }); + + expect(source.confluenceSpace?.spaceName).toBe("Docs Space"); + expect(source.confluenceSpace?.credentialName).toBe("team-cred"); + }); + it("creates Confluence source from status instance", () => { const status: SourceInstanceIngestionStatus = { sourceSystem: "CONFLUENCE", diff --git a/tests/unit/services/confluenceService.test.ts b/tests/unit/services/confluenceService.test.ts index 327d3f2d..f0b2757a 100644 --- a/tests/unit/services/confluenceService.test.ts +++ b/tests/unit/services/confluenceService.test.ts @@ -13,6 +13,8 @@ const mockConnection: ConfluenceConnectionDto = { baseUrl: "https://example.atlassian.net/wiki", spaceId: "SPACE-1", spaceKey: "SP", + spaceName: "Sprint Planning", + credentialName: "default", pageAllowlist: [], pageDenylist: [], credentialsConfigured: true, diff --git a/tests/unit/setup/msw-handlers.ts b/tests/unit/setup/msw-handlers.ts index efadf190..f6b081f2 100644 --- a/tests/unit/setup/msw-handlers.ts +++ b/tests/unit/setup/msw-handlers.ts @@ -530,6 +530,8 @@ export const handlers = [ baseUrl: "https://example.atlassian.net/wiki", spaceId: "123456", spaceKey: "EX", + spaceName: "Example Space", + credentialName: "default", pageAllowlist: [], pageDenylist: [], credentialsConfigured: true, From e8601b8b0a45513d3eccaddcb1c98ffbe9c1e345 Mon Sep 17 00:00:00 2001 From: kiranfin <4finjas@online.de> Date: Sun, 6 Sep 2026 00:39:53 +0200 Subject: [PATCH 11/14] Connect spaces through a stored Atlassian credential - replace email/apiToken with credentialName in the Confluence connect request, draft, and both connect forms - share one Atlassian credential list between the Jira and Confluence pickers in both source wizards - surface a dedicated message when a picked credential was deleted before connecting --- .../admin/components/CreateProjectWizard.tsx | 53 +++++++--- .../wizard/sources/AddSourceFlow.tsx | 57 ++++++++--- src/features/admin/projectSourcesDraft.ts | 33 ++++--- .../components/ConfluenceConnectStep.tsx | 97 +++++++++++------- .../components/AddSourceModal.tsx | 51 +++++++--- .../components/ConfluenceConnectStep.tsx | 96 +++++++++++------- src/services/sources/confluenceService.ts | 7 +- .../components/CreateProjectWizard.test.tsx | 98 +++++++++++++++++++ .../connectors/ConfluenceConnectStep.test.tsx | 45 ++++++++- .../ConfluenceConnectStep.test.tsx | 72 +++++++++++--- tests/unit/services/confluenceService.test.ts | 6 +- 11 files changed, 459 insertions(+), 156 deletions(-) diff --git a/src/features/admin/components/CreateProjectWizard.tsx b/src/features/admin/components/CreateProjectWizard.tsx index 6bb33fa4..b2ff52ec 100644 --- a/src/features/admin/components/CreateProjectWizard.tsx +++ b/src/features/admin/components/CreateProjectWizard.tsx @@ -142,8 +142,7 @@ export function CreateProjectWizard({ const [confluenceBaseUrl, setConfluenceBaseUrl] = useState(""); const [confluenceSpaceId, setConfluenceSpaceId] = useState(""); - const [confluenceEmail, setConfluenceEmail] = useState(""); - const [confluenceApiToken, setConfluenceApiToken] = useState(""); + const [confluenceCredentialName, setConfluenceCredentialName] = useState(""); // Upload files staged in memory; uploaded during provisioning once a project // id exists. @@ -185,6 +184,9 @@ export function CreateProjectWizard({ }; const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA"; + const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE"; + // Jira and Confluence share the same Atlassian credential store, so one + // instance of the hook backs both detail screens' pickers. const { credentials: jiraCredentials, loaded: jiraCredentialsLoaded, @@ -192,7 +194,7 @@ export function CreateProjectWizard({ isRefreshing: jiraCredentialsLoading, reload: reloadJiraCredentials, addCredentialLocally, - } = useAtlassianCredentials(isOpen && isJiraDetail); + } = useAtlassianCredentials(isOpen && (isJiraDetail || isConfluenceDetail)); // The token list arrives asynchronously; adopt the first token as soon as it // does (and heal a stale selection) so discovery is usable on the first open. @@ -206,8 +208,10 @@ export function CreateProjectWizard({ }); }, [effectiveTokenNames]); - // Same adoption pattern for the Jira credential picker: select the first - // stored credential once the list arrives, keeping a still-valid choice. + // Same adoption pattern for the Jira and Confluence credential pickers: + // select the first stored credential once the list arrives, keeping a + // still-valid choice. Both fields share the list, so a credential just added + // from either detail screen is adopted here too. useEffect(() => { if (!jiraCredentialsLoaded || jiraCredentialsLoading) return; @@ -218,6 +222,12 @@ export function CreateProjectWizard({ ? current : jiraCredentials[0].displayName; }); + setConfluenceCredentialName((current) => { + if (jiraCredentials.length === 0) return ""; + return current && jiraCredentials.some((credential) => credential.displayName === current) + ? current + : jiraCredentials[0].displayName; + }); }); }, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]); @@ -247,8 +257,7 @@ export function CreateProjectWizard({ const resetConfluenceDraftFields = () => { setConfluenceBaseUrl(""); setConfluenceSpaceId(""); - setConfluenceEmail(""); - setConfluenceApiToken(""); + setConfluenceCredentialName(""); }; const resetWizard = () => { @@ -399,10 +408,20 @@ export function CreateProjectWizard({ await reloadJiraCredentials(); }; + const handleConfluenceCredentialSaved = async (credential: AtlassianCredentialDto) => { + addCredentialLocally(credential); + setConfluenceCredentialName(credential.displayName); + await reloadJiraCredentials(); + }; + const selectedJiraCredential = jiraCredentials.find( (credential) => credential.displayName === jiraCredentialName, ); + const selectedConfluenceCredential = jiraCredentials.find( + (credential) => credential.displayName === confluenceCredentialName, + ); + const canAddSource = addType === "GITHUB" ? githubSelection.length > 0 @@ -414,8 +433,7 @@ export function CreateProjectWizard({ ? Boolean( confluenceBaseUrl.trim() && confluenceSpaceId.trim() && - confluenceEmail.trim() && - confluenceApiToken.trim(), + selectedConfluenceCredential, ) : false; @@ -445,15 +463,14 @@ export function CreateProjectWizard({ } else if (addType === "UPLOAD") { const displayName = uploadFiles.length === 1 ? uploadFiles[0].name : "Uploaded documents"; setSources((current) => addDraftSource(current, createUploadDraft(displayName, uploadFiles))); - } else if (addType === "CONFLUENCE") { + } else if (addType === "CONFLUENCE" && selectedConfluenceCredential) { setSources((current) => addDraftSource( current, createConfluenceDraft({ baseUrl: confluenceBaseUrl.trim(), spaceId: confluenceSpaceId.trim(), - email: confluenceEmail.trim(), - apiToken: confluenceApiToken.trim(), + credentialName: selectedConfluenceCredential.displayName, }), ), ); @@ -822,13 +839,17 @@ export function CreateProjectWizard({ confluence={{ baseUrl: confluenceBaseUrl, spaceId: confluenceSpaceId, - email: confluenceEmail, - apiToken: confluenceApiToken, + credentialName: confluenceCredentialName, + credentials: jiraCredentials, + credentialsLoaded: jiraCredentialsLoaded, + credentialsLoading: jiraCredentialsLoading, + credentialsError: jiraCredentialsError, + defaultUserEmail: null, onBaseUrlChange: setConfluenceBaseUrl, onSpaceIdChange: setConfluenceSpaceId, - onEmailChange: setConfluenceEmail, - onApiTokenChange: setConfluenceApiToken, + onCredentialNameChange: setConfluenceCredentialName, onSubmit: commitAddSource, + onCredentialSaved: handleConfluenceCredentialSaved, }} /> ) : ( diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx index b5ca862c..5532a1c5 100644 --- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx +++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx @@ -87,14 +87,20 @@ type UploadDetailProps = { type ConfluenceDetailProps = { baseUrl: string; spaceId: string; - email: string; - apiToken: string; + credentialName: string; + credentials: AtlassianCredentialDto[]; + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; + /** Prefill for the inline "add credential" form's account-email field. */ + defaultUserEmail: string | null; onBaseUrlChange: (value: string) => void; onSpaceIdChange: (value: string) => void; - onEmailChange: (value: string) => void; - onApiTokenChange: (value: string) => void; + onCredentialNameChange: (value: string) => void; /** Enter in a field stages the source (guarded), matching "Add to list". */ onSubmit: () => void; + /** Adds the new credential to the list, selects it, and reconciles with the server. */ + onCredentialSaved: (credential: AtlassianCredentialDto) => Promise; }; type AddSourceFlowProps = { @@ -470,27 +476,51 @@ function UploadDetail({ files, onAddFiles, onRemoveFile }: UploadDetailProps) { ); } -/** Confluence detail form for connecting spaces. */ +/** Confluence detail with an "add credential" trigger above the form. */ function ConfluenceDetail({ isBusy, confluence, + onCompanionOpenChange, }: { isBusy: boolean; confluence: ConfluenceDetailProps; + onCompanionOpenChange?: (open: boolean) => void; }) { + // Only hint "nothing stored" once the list has loaded, so the chip does not + // flash while credentials are still being fetched. + const missingCredential = confluence.credentialsLoaded && confluence.credentials.length === 0; + return (
+ ( + + )} + /> +
); @@ -501,8 +531,7 @@ const DETAIL_SUBTITLE: Record = { GITHUB: "Pick the repositories to index, then add them to your source list.", JIRA: "Point to your Jira instance and pick a credential, then add it to the list.", UPLOAD: "Files are staged now and uploaded right after the project is created.", - CONFLUENCE: - "Enter your Confluence Cloud details and credentials, then add the space to the list.", + CONFLUENCE: "Point to your Confluence space and pick a credential, then add it to the list.", }; /** @@ -575,7 +604,11 @@ export function AddSourceFlow({ onRemoveFile={upload.onRemoveFile} /> ) : selectedType === "CONFLUENCE" ? ( - + ) : ( ); diff --git a/src/features/admin/projectSourcesDraft.ts b/src/features/admin/projectSourcesDraft.ts index 7c5b0997..039e6e1f 100644 --- a/src/features/admin/projectSourcesDraft.ts +++ b/src/features/admin/projectSourcesDraft.ts @@ -5,6 +5,7 @@ import { import { connectJiraInstance } from "../../services/sources/jiraService"; import { confluenceService } from "../../services/sources/confluenceService"; import { knowledgeService } from "../../services/knowledgeService"; +import { ApiError } from "../../services/apiClient"; import type { DiscoverySelection } from "../data-ingestion/components/GithubRepositoryDiscovery"; /** @@ -68,8 +69,8 @@ export type ConfluenceDraftSource = DraftSourceBase & { displayName: string; baseUrl: string; spaceId: string; - email: string; - apiToken: string; + /** Name of a stored Atlassian credential, shared with the Jira connector. */ + credentialName: string; }; export type DraftSource = @@ -151,8 +152,7 @@ export function createConfluenceDraft(params: { displayName?: string; baseUrl: string; spaceId: string; - email: string; - apiToken: string; + credentialName: string; }): ConfluenceDraftSource { return { id: nextDraftSourceId(), @@ -160,8 +160,7 @@ export function createConfluenceDraft(params: { displayName: params.displayName || `Confluence Space ${params.spaceId}`, baseUrl: params.baseUrl, spaceId: params.spaceId, - email: params.email, - apiToken: params.apiToken, + credentialName: params.credentialName, status: "pending", errorMessage: "", }; @@ -258,14 +257,20 @@ async function connectOneDraftSource(source: DraftSource, projectId: string): Pr } if (source.type === "CONFLUENCE") { - await confluenceService.createConnection(projectId, { - baseUrl: source.baseUrl, - spaceId: source.spaceId, - email: source.email, - apiToken: source.apiToken, - pageAllowlist: [], - pageDenylist: [], - }); + try { + await confluenceService.createConnection(projectId, { + baseUrl: source.baseUrl, + spaceId: source.spaceId, + credentialName: source.credentialName, + pageAllowlist: [], + pageDenylist: [], + }); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + throw new Error("That Atlassian credential no longer exists. Pick another one and retry."); + } + throw error; + } return; } diff --git a/src/features/connectors/components/ConfluenceConnectStep.tsx b/src/features/connectors/components/ConfluenceConnectStep.tsx index 310daadc..8065ef0c 100644 --- a/src/features/connectors/components/ConfluenceConnectStep.tsx +++ b/src/features/connectors/components/ConfluenceConnectStep.tsx @@ -1,14 +1,17 @@ -import { useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { X } from "lucide-react"; import { Button } from "../../../components/ui/Button.tsx"; +import { DropdownSelect } from "../../../components/ui/DropdownSelect.tsx"; import { Field } from "../../../components/ui/Field.tsx"; import { Input } from "../../../components/ui/Input.tsx"; import { useToast } from "../../../context/useToast.ts"; +import { ApiError } from "../../../services/apiClient.ts"; import { parseApiError } from "../../../services/apiError.ts"; import { confluenceService, type ConfluenceConnectionDto, } from "../../../services/sources/confluenceService.ts"; +import { useAtlassianCredentials } from "../../settings/hooks/useAtlassianCredentials.ts"; type ConfluenceConnectStepProps = { projectId: string; @@ -20,17 +23,39 @@ const ADD_FALLBACK = "Failed to connect Confluence space."; /** * Inline form for creating a new Confluence Cloud space connection inside - * the Connectors modal. + * the Connectors modal, backed by a stored Atlassian credential instead of a + * raw email/token pair. */ export function ConfluenceConnectStep({ projectId, onClose, onSaved }: ConfluenceConnectStepProps) { const [baseUrl, setBaseUrl] = useState(""); const [spaceId, setSpaceId] = useState(""); - const [email, setEmail] = useState(""); - const [apiToken, setApiToken] = useState(""); + const [credentialName, setCredentialName] = useState(""); const [isSaving, setIsSaving] = useState(false); const savingRef = useRef(false); const toast = useToast(); + const { + credentials, + loaded: credentialsLoaded, + isRefreshing: credentialsLoading, + } = useAtlassianCredentials(); + const hasCredentials = credentials.length > 0; + + // Adopt the first stored credential once the list arrives, keeping a + // still-valid choice — there is no host wizard here to do it instead. + useEffect(() => { + if (!credentialsLoaded || credentialsLoading) return; + + void Promise.resolve().then(() => { + setCredentialName((current) => { + if (credentials.length === 0) return ""; + return current && credentials.some((credential) => credential.displayName === current) + ? current + : credentials[0].displayName; + }); + }); + }, [credentials, credentialsLoaded, credentialsLoading]); + const handleClose = () => { if (savingRef.current) return; onClose(); @@ -42,10 +67,8 @@ export function ConfluenceConnectStep({ projectId, onClose, onSaved }: Confluenc const trimmedBaseUrl = baseUrl.trim(); const trimmedSpaceId = spaceId.trim(); - const trimmedEmail = email.trim(); - const trimmedToken = apiToken.trim(); - if (!trimmedBaseUrl || !trimmedSpaceId || !trimmedEmail || !trimmedToken) { + if (!trimmedBaseUrl || !trimmedSpaceId || !credentialName) { toast.error("Please fill in all required fields."); return; } @@ -57,19 +80,22 @@ export function ConfluenceConnectStep({ projectId, onClose, onSaved }: Confluenc const created = await confluenceService.createConnection(projectId, { baseUrl: trimmedBaseUrl, spaceId: trimmedSpaceId, - email: trimmedEmail, - apiToken: trimmedToken, + credentialName, pageAllowlist: [], pageDenylist: [], }); toast.success("Confluence space connected", { - description: `Connected space key: ${created.spaceKey}`, + description: `Connected space: ${created.spaceName ?? created.spaceKey}`, }); onSaved(created); onClose(); } catch (error) { - toast.error(parseApiError(error, ADD_FALLBACK)); + if (error instanceof ApiError && error.status === 404) { + toast.error("That credential no longer exists. Pick another one and try again."); + } else { + toast.error(parseApiError(error, ADD_FALLBACK)); + } } finally { savingRef.current = false; setIsSaving(false); @@ -130,34 +156,28 @@ export function ConfluenceConnectStep({ projectId, onClose, onSaved }: Confluenc /> - - setEmail(e.target.value)} - placeholder="user@example.com" - required - autoComplete="email" - /> - - - - setApiToken(e.target.value)} - placeholder="Atlassian API token" - required - autoComplete="off" +
+ Credential + ({ + value: credential.displayName, + label: `${credential.displayName} - ${credential.userEmail}`, + })) + : [ + { + value: "", + label: credentialsLoading ? "Loading credentials..." : "No credentials", + }, + ] + } + onChange={setCredentialName} + disabled={isSaving || !hasCredentials} /> - +
diff --git a/src/features/data-ingestion/components/AddSourceModal.tsx b/src/features/data-ingestion/components/AddSourceModal.tsx index d3206b75..bfcfec7f 100644 --- a/src/features/data-ingestion/components/AddSourceModal.tsx +++ b/src/features/data-ingestion/components/AddSourceModal.tsx @@ -110,13 +110,15 @@ export function AddSourceModal({ // Confluence detail state. const [confluenceBaseUrl, setConfluenceBaseUrl] = useState(""); const [confluenceSpaceId, setConfluenceSpaceId] = useState(""); - const [confluenceEmail, setConfluenceEmail] = useState(""); - const [confluenceApiToken, setConfluenceApiToken] = useState(""); + const [confluenceCredentialName, setConfluenceCredentialName] = useState(""); // Upload detail state — files staged in memory until the list is connected. const [uploadFiles, setUploadFiles] = useState([]); const isJiraDetail = isAddingSource && addStep === "detail" && addType === "JIRA"; + const isConfluenceDetail = isAddingSource && addStep === "detail" && addType === "CONFLUENCE"; + // Jira and Confluence share the same Atlassian credential store, so one + // instance of the hook backs both detail screens' pickers. const { credentials: jiraCredentials, loaded: jiraCredentialsLoaded, @@ -124,7 +126,7 @@ export function AddSourceModal({ isRefreshing: jiraCredentialsLoading, reload: reloadJiraCredentials, addCredentialLocally, - } = useAtlassianCredentials(isJiraDetail); + } = useAtlassianCredentials(isJiraDetail || isConfluenceDetail); // Adopt the first token as soon as the list arrives (and heal a stale // selection) so discovery is usable on the first open. @@ -138,8 +140,8 @@ export function AddSourceModal({ }); }, [effectiveTokenNames]); - // Adopt the first stored Jira credential once the list arrives, keeping a - // still-valid choice. + // Adopt the first stored Atlassian credential once the list arrives, keeping + // a still-valid choice — shared by the Jira and Confluence pickers. useEffect(() => { if (!jiraCredentialsLoaded || jiraCredentialsLoading) return; @@ -150,6 +152,12 @@ export function AddSourceModal({ ? current : jiraCredentials[0].displayName; }); + setConfluenceCredentialName((current) => { + if (jiraCredentials.length === 0) return ""; + return current && jiraCredentials.some((credential) => credential.displayName === current) + ? current + : jiraCredentials[0].displayName; + }); }); }, [jiraCredentials, jiraCredentialsLoaded, jiraCredentialsLoading]); @@ -161,8 +169,7 @@ export function AddSourceModal({ setUploadFiles([]); setConfluenceBaseUrl(""); setConfluenceSpaceId(""); - setConfluenceEmail(""); - setConfluenceApiToken(""); + setConfluenceCredentialName(""); }; // --- Add-source sub-flow --- @@ -205,10 +212,20 @@ export function AddSourceModal({ await reloadJiraCredentials(); }; + const handleConfluenceCredentialSaved = async (credential: AtlassianCredentialDto) => { + addCredentialLocally(credential); + setConfluenceCredentialName(credential.displayName); + await reloadJiraCredentials(); + }; + const selectedJiraCredential = jiraCredentials.find( (credential) => credential.displayName === jiraCredentialName, ); + const selectedConfluenceCredential = jiraCredentials.find( + (credential) => credential.displayName === confluenceCredentialName, + ); + const canAddSource = addType === "GITHUB" ? githubSelection.length > 0 @@ -220,8 +237,7 @@ export function AddSourceModal({ ? Boolean( confluenceBaseUrl.trim() && confluenceSpaceId.trim() && - confluenceEmail.trim() && - confluenceApiToken.trim(), + selectedConfluenceCredential, ) : false; @@ -255,13 +271,12 @@ export function AddSourceModal({ return [createUploadDraft(displayName, uploadFiles)]; } - if (addType === "CONFLUENCE") { + if (addType === "CONFLUENCE" && selectedConfluenceCredential) { return [ createConfluenceDraft({ baseUrl: confluenceBaseUrl.trim(), spaceId: confluenceSpaceId.trim(), - email: confluenceEmail.trim(), - apiToken: confluenceApiToken.trim(), + credentialName: selectedConfluenceCredential.displayName, }), ]; } @@ -537,13 +552,17 @@ export function AddSourceModal({ confluence={{ baseUrl: confluenceBaseUrl, spaceId: confluenceSpaceId, - email: confluenceEmail, - apiToken: confluenceApiToken, + credentialName: confluenceCredentialName, + credentials: jiraCredentials, + credentialsLoaded: jiraCredentialsLoaded, + credentialsLoading: jiraCredentialsLoading, + credentialsError: jiraCredentialsError, + defaultUserEmail: null, onBaseUrlChange: setConfluenceBaseUrl, onSpaceIdChange: setConfluenceSpaceId, - onEmailChange: setConfluenceEmail, - onApiTokenChange: setConfluenceApiToken, + onCredentialNameChange: setConfluenceCredentialName, onSubmit: commitAddSource, + onCredentialSaved: handleConfluenceCredentialSaved, }} />
diff --git a/src/features/data-ingestion/components/ConfluenceConnectStep.tsx b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx index 0d3cdd1a..2ac10f3b 100644 --- a/src/features/data-ingestion/components/ConfluenceConnectStep.tsx +++ b/src/features/data-ingestion/components/ConfluenceConnectStep.tsx @@ -1,9 +1,12 @@ import { AlertTriangle } from "lucide-react"; +import { DropdownSelect } from "../../../components/ui/DropdownSelect.tsx"; import { Field } from "../../../components/ui/Field.tsx"; import { Input } from "../../../components/ui/Input.tsx"; +import type { AtlassianCredentialDto } from "../../../services/sources/atlassianService.ts"; /** - * Controlled Confluence connect form for base URL, space ID, and credentials. + * Controlled Confluence connect form for a base URL, space ID, and a stored + * Atlassian credential. * * Shared between the Data Ingestion "Add source" wizard and the project-creation * wizard so the Confluence connect experience is identical in both places. @@ -11,32 +14,47 @@ import { Input } from "../../../components/ui/Input.tsx"; export function ConfluenceConnectStep({ baseUrl, spaceId, - email, - apiToken, + credentialName, + credentials, + credentialsLoaded, + credentialsLoading, + credentialsError, isBusy = false, canIngest = true, ingestBlockedReason, errorMessage, onBaseUrlChange, onSpaceIdChange, - onEmailChange, - onApiTokenChange, + onCredentialNameChange, onSubmit, + suppressMissingCredentialNotice = false, }: { baseUrl: string; spaceId: string; - email: string; - apiToken: string; + credentialName: string; + credentials: AtlassianCredentialDto[]; + credentialsLoaded: boolean; + credentialsLoading: boolean; + credentialsError: string | null; isBusy?: boolean; canIngest?: boolean; ingestBlockedReason?: string; errorMessage?: string | null; onBaseUrlChange: (value: string) => void; onSpaceIdChange: (value: string) => void; - onEmailChange: (value: string) => void; - onApiTokenChange: (value: string) => void; + onCredentialNameChange: (value: string) => void; onSubmit?: () => void; + /** + * Hides the built-in "no stored credential" banner. Set when the parent shows + * its own missing-credential hint (e.g. the wizard's compact notice next to + * its inline "Add credential" button) so the message is not duplicated. + */ + suppressMissingCredentialNotice?: boolean; }) { + const hasCredentials = credentials.length > 0; + const showNoCredentials = + credentialsLoaded && !credentialsLoading && !hasCredentials && !suppressMissingCredentialNotice; + return ( )} + {showNoCredentials && ( +
+ No Atlassian credentials are stored for your account. Add one under Settings, Access + Tokens, Atlassian first, then come back to connect. +
+ )} + - - onEmailChange(event.target.value)} - placeholder="user@example.com" - required - autoComplete="email" +
+ Credential + ({ + value: credential.displayName, + label: `${credential.displayName} - ${credential.userEmail}`, + })) + : [ + { + value: "", + label: credentialsLoading ? "Loading credentials..." : "No credentials", + }, + ] + } + onChange={onCredentialNameChange} + disabled={isBusy || !hasCredentials} /> - +
- - onApiTokenChange(event.target.value)} - placeholder="Atlassian API token" - required - autoComplete="off" - /> - + {credentialsError && ( +
+ + {credentialsError} +
+ )} {errorMessage && (
diff --git a/src/services/sources/confluenceService.ts b/src/services/sources/confluenceService.ts index b7a41468..36d03410 100644 --- a/src/services/sources/confluenceService.ts +++ b/src/services/sources/confluenceService.ts @@ -3,8 +3,8 @@ import { apiClient } from "../apiClient.ts"; export type CreateConfluenceConnectionRequest = { baseUrl: string; spaceId: string; - email: string; - apiToken: string; + /** Name of a stored Atlassian credential, shared with the Jira connector. */ + credentialName: string; pageAllowlist?: string[]; pageDenylist?: string[]; }; @@ -83,8 +83,7 @@ export const confluenceService = { body: JSON.stringify({ baseUrl: request.baseUrl.trim(), spaceId: request.spaceId.trim(), - email: request.email.trim(), - apiToken: request.apiToken.trim(), + credentialName: request.credentialName.trim(), pageAllowlist: request.pageAllowlist ?? [], pageDenylist: request.pageDenylist ?? [], }), diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx index 63c15997..335fe135 100644 --- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx +++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx @@ -41,6 +41,12 @@ vi.mock("../../../../../src/services/knowledgeService", () => ({ knowledgeService: { uploadDocuments: vi.fn() }, })); +vi.mock("../../../../../src/services/sources/confluenceService", () => ({ + confluenceService: { + createConnection: vi.fn(), + }, +})); + import { projectService } from "../../../../../src/services/projectService"; import { addGithubPat, @@ -54,6 +60,7 @@ import { addAtlassianCredential, getMyAtlassianCredentials, } from "../../../../../src/services/sources/atlassianService"; +import { confluenceService } from "../../../../../src/services/sources/confluenceService"; import { knowledgeService } from "../../../../../src/services/knowledgeService"; import { getIngestionSourceStatuses } from "../../../../../src/services/ingestionService"; @@ -201,6 +208,22 @@ describe("CreateProjectWizard", () => { vi.mocked(knowledgeService.uploadDocuments).mockResolvedValue([ { filename: "spec.md", status: "success" }, ]); + vi.mocked(confluenceService.createConnection).mockResolvedValue({ + id: "conn-1", + projectId: "proj-new", + baseUrl: "https://acme.atlassian.net/wiki", + spaceId: "123456", + spaceKey: "DOCS", + spaceName: "Docs", + credentialName: "Team token", + pageAllowlist: [], + pageDenylist: [], + credentialsConfigured: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + version: 1, + sourceEnabled: true, + }); }); /** From the sources step, stage a Jira board through the add-source sub-flow. */ @@ -217,6 +240,24 @@ describe("CreateProjectWizard", () => { await user.click(screen.getByRole("button", { name: /add to list/i })); } + /** From the sources step, stage a Confluence space through the add-source sub-flow. */ + async function stageConfluenceSpace(user: ReturnType) { + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click( + screen.getByRole("button", { name: /indexes pages, hierarchical documents/i }), + ); + + // The stored credential is shared with Jira and adopted automatically. + await screen.findByText(/Team token - me@example.com/i); + + await user.type( + screen.getByLabelText("Confluence base URL"), + "https://acme.atlassian.net/wiki", + ); + await user.type(screen.getByLabelText("Space ID"), "123456"); + await user.click(screen.getByRole("button", { name: /add to list/i })); + } + it("keeps details on a blank name and explains why instead of a dead button", async () => { const user = userEvent.setup(); renderWizard(); @@ -538,6 +579,63 @@ describe("CreateProjectWizard", () => { ); }); + it("stages a Confluence space and connects it against the new project on Create", async () => { + const user = userEvent.setup(); + renderWizard(); + + await goToSources(user); + await stageConfluenceSpace(user); + + // Back on the sources list, the space is staged under its space ID (the + // draft has no readable name yet — that only exists after the backend + // resolves the connection). + expect(screen.getByText("Confluence Space 123456")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: /create project/i })); + + await waitFor(() => + expect(vi.mocked(confluenceService.createConnection)).toHaveBeenCalledWith("proj-new", { + baseUrl: "https://acme.atlassian.net/wiki", + spaceId: "123456", + credentialName: "Team token", + pageAllowlist: [], + pageDenylist: [], + }), + ); + }); + + it("adds an Atlassian credential inline while staging a Confluence space and selects the new one", async () => { + vi.mocked(getMyAtlassianCredentials) + .mockResolvedValueOnce([]) // initial load: none stored + .mockResolvedValue([{ userEmail: "new@example.com", displayName: "Fresh cred" }]); + const user = userEvent.setup(); + renderWizard(); + + await goToSources(user); + await user.click(screen.getByRole("button", { name: /add source/i })); + await user.click( + screen.getByRole("button", { name: /indexes pages, hierarchical documents/i }), + ); + + await user.click(screen.getByRole("button", { name: /add atlassian credential/i })); + await user.type(screen.getByTestId("settings-atlassian-add-email"), "new@example.com"); + await user.type(screen.getByTestId("settings-atlassian-add-name"), "Fresh cred"); + await user.type(screen.getByTestId("settings-atlassian-add-token"), "confluence-token"); + await user.click(screen.getByTestId("settings-atlassian-add-submit")); + + await waitFor(() => + expect(vi.mocked(addAtlassianCredential)).toHaveBeenCalledWith({ + userEmail: "new@example.com", + tokenName: "Fresh cred", + authToken: "confluence-token", + }), + ); + // The refreshed credential is adopted and shown as the picker's label. + await screen.findByText(/Fresh cred - new@example.com/i); + expect(screen.getByLabelText("Credential")).toHaveTextContent("Fresh cred"); + }); + it("stages uploaded files and uploads them against the new project on Create", async () => { const user = userEvent.setup(); renderWizard(); diff --git a/tests/unit/features/connectors/ConfluenceConnectStep.test.tsx b/tests/unit/features/connectors/ConfluenceConnectStep.test.tsx index 5eddc78b..db8d539e 100644 --- a/tests/unit/features/connectors/ConfluenceConnectStep.test.tsx +++ b/tests/unit/features/connectors/ConfluenceConnectStep.test.tsx @@ -8,6 +8,12 @@ import { ConfluenceConnectStep } from "../../../../src/features/connectors/compo const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: ToastProvider }); +function atlassianCredentialsHandler(names: string[], email = "user@example.com") { + return http.get("/api/v1/atlassian/credentials", () => + HttpResponse.json(names.map((displayName) => ({ userEmail: email, displayName }))), + ); +} + describe("ConfluenceConnectStep (connectors)", () => { it("submits form and calls onSaved when API succeeds", async () => { const user = userEvent.setup(); @@ -15,13 +21,18 @@ describe("ConfluenceConnectStep (connectors)", () => { const onClose = vi.fn(); server.use( - http.post("/api/v1/confluence/projects/proj-1/connections", () => { + atlassianCredentialsHandler(["default"]), + http.post("/api/v1/confluence/projects/proj-1/connections", async ({ request }) => { + expect(await request.json()).toMatchObject({ credentialName: "default" }); + return HttpResponse.json({ id: "conn-1", projectId: "proj-1", baseUrl: "https://example.atlassian.net/wiki", spaceId: "123456", spaceKey: "SP", + spaceName: "Sprint Planning", + credentialName: "default", pageAllowlist: [], pageDenylist: [], credentialsConfigured: true, @@ -35,10 +46,11 @@ describe("ConfluenceConnectStep (connectors)", () => { render(); + // The single stored credential is adopted automatically once it loads. + await waitFor(() => expect(screen.getByLabelText("Credential")).toHaveTextContent("default")); + await user.type(screen.getByLabelText(/confluence base url/i), "https://example.atlassian.net"); await user.type(screen.getByLabelText(/space id/i), "123456"); - await user.type(screen.getByLabelText(/account email/i), "user@example.com"); - await user.type(screen.getByLabelText(/api token/i), "my-secret-token"); const connectButton = screen.getByRole("button", { name: /^connect space$/i }); expect(connectButton).not.toBeDisabled(); @@ -51,6 +63,33 @@ describe("ConfluenceConnectStep (connectors)", () => { }); }); + it("disables submit and the picker when no credentials are stored", async () => { + render(); + + await waitFor(() => expect(screen.getByLabelText("Credential")).toBeDisabled()); + expect(screen.getByRole("button", { name: /^connect space$/i })).toBeDisabled(); + }); + + it("shows a dedicated message when the picked credential no longer exists", async () => { + const user = userEvent.setup(); + + server.use( + atlassianCredentialsHandler(["default"]), + http.post("/api/v1/confluence/projects/proj-1/connections", () => + HttpResponse.json({ message: "credential not found" }, { status: 404 }), + ), + ); + + render(); + + await waitFor(() => expect(screen.getByLabelText("Credential")).toHaveTextContent("default")); + await user.type(screen.getByLabelText(/confluence base url/i), "https://example.atlassian.net"); + await user.type(screen.getByLabelText(/space id/i), "123456"); + await user.click(screen.getByRole("button", { name: /^connect space$/i })); + + expect(await screen.findByText(/credential no longer exists/i)).toBeInTheDocument(); + }); + it("calls onClose when cancel button is clicked", async () => { const user = userEvent.setup(); const onSaved = vi.fn(); diff --git a/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx b/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx index 90ec290c..46a4ec27 100644 --- a/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx +++ b/tests/unit/features/data-ingestion/ConfluenceConnectStep.test.tsx @@ -2,39 +2,43 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import { ConfluenceConnectStep } from "../../../../src/features/data-ingestion/components/ConfluenceConnectStep"; +import type { AtlassianCredentialDto } from "../../../../src/services/sources/atlassianService"; + +const credentials: AtlassianCredentialDto[] = [ + { userEmail: "user@test.com", displayName: "default" }, + { userEmail: "other@test.com", displayName: "ci" }, +]; describe("ConfluenceConnectStep (data-ingestion)", () => { it("renders form fields and handles input changes", async () => { const user = userEvent.setup(); const onBaseUrlChange = vi.fn(); const onSpaceIdChange = vi.fn(); - const onEmailChange = vi.fn(); - const onApiTokenChange = vi.fn(); + const onCredentialNameChange = vi.fn(); const onSubmit = vi.fn(); render( , ); const baseUrlInput = screen.getByLabelText(/confluence base url/i); const spaceIdInput = screen.getByLabelText(/space id/i); - const emailInput = screen.getByLabelText(/account email/i); - const tokenInput = screen.getByLabelText(/api token/i); expect(baseUrlInput).toBeInTheDocument(); expect(spaceIdInput).toBeInTheDocument(); - expect(emailInput).toBeInTheDocument(); - expect(tokenInput).toBeInTheDocument(); + expect(screen.getByLabelText("Credential")).toBeInTheDocument(); await user.type(baseUrlInput, "https://test.atlassian.net"); expect(onBaseUrlChange).toHaveBeenCalled(); @@ -42,10 +46,50 @@ describe("ConfluenceConnectStep (data-ingestion)", () => { await user.type(spaceIdInput, "123456"); expect(onSpaceIdChange).toHaveBeenCalled(); - await user.type(emailInput, "user@test.com"); - expect(onEmailChange).toHaveBeenCalled(); + await user.click(screen.getByLabelText("Credential")); + await user.click(await screen.findByRole("option", { name: "ci - other@test.com" })); + expect(onCredentialNameChange).toHaveBeenCalledWith("ci"); + }); + + it("shows a warning when no credentials are stored", () => { + render( + , + ); + + expect(screen.getByText(/no atlassian credentials are stored/i)).toBeInTheDocument(); + expect(screen.getByLabelText("Credential")).toBeDisabled(); + }); + + it("suppresses the missing-credential banner when asked", () => { + render( + , + ); - await user.type(tokenInput, "token123"); - expect(onApiTokenChange).toHaveBeenCalled(); + expect(screen.queryByText(/no atlassian credentials are stored/i)).not.toBeInTheDocument(); }); }); diff --git a/tests/unit/services/confluenceService.test.ts b/tests/unit/services/confluenceService.test.ts index f0b2757a..af6cad96 100644 --- a/tests/unit/services/confluenceService.test.ts +++ b/tests/unit/services/confluenceService.test.ts @@ -34,8 +34,7 @@ describe("confluenceService", () => { expect(body).toEqual({ baseUrl: "https://example.atlassian.net/wiki", spaceId: "SPACE-1", - email: "user@example.com", - apiToken: "secret-token", + credentialName: "default", pageAllowlist: [], pageDenylist: [], }); @@ -47,8 +46,7 @@ describe("confluenceService", () => { const result = await confluenceService.createConnection("proj-1", { baseUrl: "https://example.atlassian.net/wiki", spaceId: "SPACE-1", - email: "user@example.com", - apiToken: "secret-token", + credentialName: "default", }); expect(result).toEqual(mockConnection); From d231dd1ffebb5491d75a507f6179c59f220a5412 Mon Sep 17 00:00:00 2001 From: kiranfin <4finjas@online.de> Date: Sun, 6 Sep 2026 12:43:06 +0200 Subject: [PATCH 12/14] Show the Confluence base URL under the space name on data ingestion source cards - add a base URL subtitle line to Confluence cards in SourceList, mirroring the Jira instance line - rename formatJiraInstanceDomain to formatInstanceDomain since it now serves Jira and Confluence - update the RunDetailsPanel call site to the new name --- .../data-ingestion/components/RunDetailsPanel.tsx | 4 ++-- src/features/data-ingestion/components/SourceList.tsx | 10 ++++++++-- src/features/data-ingestion/data.ts | 6 ++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/features/data-ingestion/components/RunDetailsPanel.tsx b/src/features/data-ingestion/components/RunDetailsPanel.tsx index 90208878..ed693cc7 100644 --- a/src/features/data-ingestion/components/RunDetailsPanel.tsx +++ b/src/features/data-ingestion/components/RunDetailsPanel.tsx @@ -14,7 +14,7 @@ import { DetailsSideDrawer } from "../../../components/layout/DetailsSideDrawer" import { DrawerCard } from "../../admin/components/DrawerCard"; import { formatDateTime, - formatJiraInstanceDomain, + formatInstanceDomain, formatNumber, getAiSyncStatusLabel, getRunStatusLabel, @@ -181,7 +181,7 @@ export function RunDetailsPanel({ run, sourceLabel, onClose }: RunDetailsPanelPr */ function buildOriginRow(run: IngestionRun): { label: string; value: string } | null { if (run.sourceSystem === "JIRA") { - return run.sourceId ? { label: "Domain", value: formatJiraInstanceDomain(run.sourceId) } : null; + return run.sourceId ? { label: "Domain", value: formatInstanceDomain(run.sourceId) } : null; } if (run.sourceSystem === "GITHUB") { diff --git a/src/features/data-ingestion/components/SourceList.tsx b/src/features/data-ingestion/components/SourceList.tsx index f6d6471f..773beee5 100644 --- a/src/features/data-ingestion/components/SourceList.tsx +++ b/src/features/data-ingestion/components/SourceList.tsx @@ -11,7 +11,7 @@ import { Button } from "../../../components/ui/Button.tsx"; import { deriveConnectionStatus, deriveSyncStatus, - formatJiraInstanceDomain, + formatInstanceDomain, formatNumber, } from "../data.ts"; import type { DataSource } from "../types.ts"; @@ -107,7 +107,13 @@ export function SourceList({ {source.jiraInstance?.instanceUrl && (

- {formatJiraInstanceDomain(source.jiraInstance.instanceUrl)} + {formatInstanceDomain(source.jiraInstance.instanceUrl)} +

+ )} + + {source.confluenceSpace?.baseUrl && ( +

+ {formatInstanceDomain(source.confluenceSpace.baseUrl)}

)}
diff --git a/src/features/data-ingestion/data.ts b/src/features/data-ingestion/data.ts index 6deae11f..23259cc8 100644 --- a/src/features/data-ingestion/data.ts +++ b/src/features/data-ingestion/data.ts @@ -673,11 +673,13 @@ export function getSourceLabel(sourceSystem: SourceSystem) { } /** - * The host of a Jira instance URL without the scheme, e.g. + * The host of an instance URL without the scheme, e.g. * `"acme.atlassian.net"` for `"https://acme.atlassian.net"`. Falls back to * stripping the scheme/trailing slash by hand if the value is not a valid URL. + * + * Used for the Jira instance and the Confluence base URL alike. */ -export function formatJiraInstanceDomain(instanceUrl: string): string { +export function formatInstanceDomain(instanceUrl: string): string { try { return new URL(instanceUrl).host; } catch { From b6e466424e16c1f14fe15675b96e12baa6c69d33 Mon Sep 17 00:00:00 2001 From: kiranfin <4finjas@online.de> Date: Tue, 8 Sep 2026 11:10:13 +0200 Subject: [PATCH 13/14] Align the Confluence connector modal and add remove-from-project - Drop the Confluence-only add and sync actions so the connector modal only toggles sources in and out of scope - Delete the now-unused connectors ConfluenceConnectStep and its test - Add remove-from-project for Confluence spaces via a new deleteConnection call --- .../chatbot/hooks/useAvailableSources.ts | 8 +- src/features/chatbot/types.ts | 2 +- .../components/ConfluenceConnectStep.tsx | 200 ----------------- .../components/ConnectorSourcesSection.tsx | 122 ++++------- .../components/useConfluenceSync.ts | 14 +- .../components/SourceDetailsPanel.tsx | 81 +++++-- .../components/SourceTypeStep.tsx | 10 +- src/pages/DataIngestionPage.tsx | 202 ++++++++++++++---- src/services/connectorService.ts | 10 +- src/services/sources/confluenceService.ts | 27 ++- .../chatbot/useAvailableSources.test.tsx | 51 +++++ .../connectors/ConfluenceConnectStep.test.tsx | 105 --------- .../ConnectorSourcesSection.test.tsx | 57 +++++ .../components/SourceDetailsPanel.test.tsx | 127 ++++++++++- tests/unit/pages/DataIngestionPage.test.tsx | 72 +++++++ tests/unit/services/confluenceService.test.ts | 14 ++ tests/unit/services/connectorService.test.ts | 20 ++ tests/unit/setup/msw-handlers.ts | 46 ++++ 18 files changed, 704 insertions(+), 464 deletions(-) delete mode 100644 src/features/connectors/components/ConfluenceConnectStep.tsx create mode 100644 tests/unit/features/chatbot/useAvailableSources.test.tsx delete mode 100644 tests/unit/features/connectors/ConfluenceConnectStep.test.tsx diff --git a/src/features/chatbot/hooks/useAvailableSources.ts b/src/features/chatbot/hooks/useAvailableSources.ts index 89a84d0f..0f22df66 100644 --- a/src/features/chatbot/hooks/useAvailableSources.ts +++ b/src/features/chatbot/hooks/useAvailableSources.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { connectorService } from "../../../services/connectorService"; -import type { SourceSystem } from "../types"; +import { SOURCE_SYSTEMS, type SourceSystem } from "../types"; /** * Uploads are not a connector: there is nothing to configure or enable, and the backend @@ -10,13 +10,11 @@ const ALWAYS_AVAILABLE: readonly SourceSystem[] = ["UPLOAD"]; /** * Maps a backend connector id onto the source system the chat filters by. Connector ids are - * lowercase (`github`, `jira`); the filter values are the uppercase enum constants. + * lowercase (`github`, `jira`, `confluence`); the filter values are the uppercase enum constants. */ function toSourceSystem(connectorId: string): SourceSystem | null { const candidate = connectorId.toUpperCase(); - return candidate === "GITHUB" || candidate === "JIRA" || candidate === "UPLOAD" - ? candidate - : null; + return SOURCE_SYSTEMS.includes(candidate as SourceSystem) ? (candidate as SourceSystem) : null; } /** diff --git a/src/features/chatbot/types.ts b/src/features/chatbot/types.ts index ce204c4e..f599ffc1 100644 --- a/src/features/chatbot/types.ts +++ b/src/features/chatbot/types.ts @@ -148,6 +148,6 @@ export type StreamHandlers = { onError?: (message: string) => void; }; -export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA"] as const; +export const SOURCE_SYSTEMS = ["GITHUB", "UPLOAD", "JIRA", "CONFLUENCE"] as const; export type SourceSystem = (typeof SOURCE_SYSTEMS)[number]; diff --git a/src/features/connectors/components/ConfluenceConnectStep.tsx b/src/features/connectors/components/ConfluenceConnectStep.tsx deleted file mode 100644 index 8065ef0c..00000000 --- a/src/features/connectors/components/ConfluenceConnectStep.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; -import { X } from "lucide-react"; -import { Button } from "../../../components/ui/Button.tsx"; -import { DropdownSelect } from "../../../components/ui/DropdownSelect.tsx"; -import { Field } from "../../../components/ui/Field.tsx"; -import { Input } from "../../../components/ui/Input.tsx"; -import { useToast } from "../../../context/useToast.ts"; -import { ApiError } from "../../../services/apiClient.ts"; -import { parseApiError } from "../../../services/apiError.ts"; -import { - confluenceService, - type ConfluenceConnectionDto, -} from "../../../services/sources/confluenceService.ts"; -import { useAtlassianCredentials } from "../../settings/hooks/useAtlassianCredentials.ts"; - -type ConfluenceConnectStepProps = { - projectId: string; - onClose: () => void; - onSaved: (connection: ConfluenceConnectionDto) => void; -}; - -const ADD_FALLBACK = "Failed to connect Confluence space."; - -/** - * Inline form for creating a new Confluence Cloud space connection inside - * the Connectors modal, backed by a stored Atlassian credential instead of a - * raw email/token pair. - */ -export function ConfluenceConnectStep({ projectId, onClose, onSaved }: ConfluenceConnectStepProps) { - const [baseUrl, setBaseUrl] = useState(""); - const [spaceId, setSpaceId] = useState(""); - const [credentialName, setCredentialName] = useState(""); - const [isSaving, setIsSaving] = useState(false); - const savingRef = useRef(false); - const toast = useToast(); - - const { - credentials, - loaded: credentialsLoaded, - isRefreshing: credentialsLoading, - } = useAtlassianCredentials(); - const hasCredentials = credentials.length > 0; - - // Adopt the first stored credential once the list arrives, keeping a - // still-valid choice — there is no host wizard here to do it instead. - useEffect(() => { - if (!credentialsLoaded || credentialsLoading) return; - - void Promise.resolve().then(() => { - setCredentialName((current) => { - if (credentials.length === 0) return ""; - return current && credentials.some((credential) => credential.displayName === current) - ? current - : credentials[0].displayName; - }); - }); - }, [credentials, credentialsLoaded, credentialsLoading]); - - const handleClose = () => { - if (savingRef.current) return; - onClose(); - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - if (savingRef.current) return; - - const trimmedBaseUrl = baseUrl.trim(); - const trimmedSpaceId = spaceId.trim(); - - if (!trimmedBaseUrl || !trimmedSpaceId || !credentialName) { - toast.error("Please fill in all required fields."); - return; - } - - savingRef.current = true; - setIsSaving(true); - - try { - const created = await confluenceService.createConnection(projectId, { - baseUrl: trimmedBaseUrl, - spaceId: trimmedSpaceId, - credentialName, - pageAllowlist: [], - pageDenylist: [], - }); - - toast.success("Confluence space connected", { - description: `Connected space: ${created.spaceName ?? created.spaceKey}`, - }); - onSaved(created); - onClose(); - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - toast.error("That credential no longer exists. Pick another one and try again."); - } else { - toast.error(parseApiError(error, ADD_FALLBACK)); - } - } finally { - savingRef.current = false; - setIsSaving(false); - } - }; - - return ( - void handleSubmit(e)} - aria-label="Connect Confluence space" - className="mb-4 overflow-hidden rounded-2xl border border-app-border bg-app-surface p-4 sm:p-5" - > -
- Connect Confluence space - -
- -
- - setBaseUrl(e.target.value)} - placeholder="https://your-domain.atlassian.net" - required - /> - - - - setSpaceId(e.target.value)} - placeholder="e.g. 123456" - required - /> - - -
- Credential - ({ - value: credential.displayName, - label: `${credential.displayName} - ${credential.userEmail}`, - })) - : [ - { - value: "", - label: credentialsLoading ? "Loading credentials..." : "No credentials", - }, - ] - } - onChange={setCredentialName} - disabled={isSaving || !hasCredentials} - /> -
- -
- - -
-
- - ); -} diff --git a/src/features/connectors/components/ConnectorSourcesSection.tsx b/src/features/connectors/components/ConnectorSourcesSection.tsx index 526a60a2..93789d1e 100644 --- a/src/features/connectors/components/ConnectorSourcesSection.tsx +++ b/src/features/connectors/components/ConnectorSourcesSection.tsx @@ -1,12 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { CheckCircle2, ExternalLink, Plus, RefreshCw, Search, XCircle } from "lucide-react"; +import { CheckCircle2, ExternalLink, RefreshCw, Search, XCircle } from "lucide-react"; import { Button } from "../../../components/ui/Button.tsx"; import { Input } from "../../../components/ui/Input.tsx"; import { useToast } from "../../../context/useToast.ts"; import { connectorService } from "../../../services/connectorService.ts"; import { buildSourceKey } from "../data.ts"; -import { ConfluenceConnectStep } from "./ConfluenceConnectStep.tsx"; -import { useConfluenceSync } from "./useConfluenceSync.ts"; import type { ConnectorListItem, ConnectorSourceRow, @@ -46,10 +44,7 @@ export function ConnectorSourcesSection({ const [draft, setDraft] = useState(EMPTY_DRAFT); const [saveState, setSaveState] = useState("idle"); const [query, setQuery] = useState(""); - const [isAddingConfluence, setIsAddingConfluence] = useState(false); const toast = useToast(); - const { syncConnection, syncingId } = useConfluenceSync(projectId); - const isConfluence = connector.id === "confluence"; const sourceKey = useMemo(() => buildSourceKey(sources), [sources]); @@ -142,7 +137,14 @@ export function ConnectorSourcesSection({ .map((source) => ({ sourceId: source.id, enabled: source.enabled })); try { - const response = await connectorService.patchConnectorSources(connector.id, patches); + // The same project scope the sources were loaded with: a project-scoped + // connector (Confluence) rejects a patch without it, and the others + // ignore it. + const response = await connectorService.patchConnectorSources( + connector.id, + patches, + projectId ?? undefined, + ); setSources(response.sources); setDraft(EMPTY_DRAFT); @@ -207,37 +209,13 @@ export function ConnectorSourcesSection({

Sources

-
- {isConfluence && projectId && !isAddingConfluence && ( - - )} - - {!isLoading && draftSources.length > 0 && ( - - {inScopeCount} / {draftSources.length} in scope - - )} -
+ {!isLoading && draftSources.length > 0 && ( + + {inScopeCount} / {draftSources.length} in scope + + )}
- {isAddingConfluence && projectId && ( - setIsAddingConfluence(false)} - onSaved={() => { - retryLoadSources(); - onSourcesSaved?.(); - }} - /> - )} - {hasLoadedSelectedConnector && errorMessage && (

{errorMessage}

@@ -301,55 +279,31 @@ export function ConnectorSourcesSection({
-
- {isConfluence && ( - + -
+ {source.enabled ? "In scope" : "Excluded"} + ); })} diff --git a/src/features/connectors/components/useConfluenceSync.ts b/src/features/connectors/components/useConfluenceSync.ts index 401c18a7..f7c8b736 100644 --- a/src/features/connectors/components/useConfluenceSync.ts +++ b/src/features/connectors/components/useConfluenceSync.ts @@ -9,6 +9,11 @@ import { /** * Hook to trigger a manual sync for a Confluence space connection and surface * result toasts based on the ingestion status. + * + * The sync endpoint runs synchronously, so the toasts raised here are the final + * word on the outcome. Failures are reported *and* rethrown: the caller still + * needs to know that the run failed (to set its own error state), but it must + * not report the failure a second time. */ export function useConfluenceSync(projectId?: string | null) { const [syncingId, setSyncingId] = useState(null); @@ -17,10 +22,11 @@ export function useConfluenceSync(projectId?: string | null) { const syncConnection = async ( connectionId: string, onSuccess?: (result: ConfluenceIngestionResult) => void, - ) => { + ): Promise => { if (!projectId) { - toast.error("Project ID is required to sync Confluence spaces."); - return; + const error = new Error("Project ID is required to sync Confluence spaces."); + toast.error(error.message); + throw error; } setSyncingId(connectionId); @@ -42,8 +48,10 @@ export function useConfluenceSync(projectId?: string | null) { } onSuccess?.(result); + return result; } catch (error) { toast.error(parseApiError(error, "Failed to synchronize Confluence space.")); + throw error; } finally { setSyncingId(null); } diff --git a/src/features/data-ingestion/components/SourceDetailsPanel.tsx b/src/features/data-ingestion/components/SourceDetailsPanel.tsx index 76299203..cdef3a5a 100644 --- a/src/features/data-ingestion/components/SourceDetailsPanel.tsx +++ b/src/features/data-ingestion/components/SourceDetailsPanel.tsx @@ -35,7 +35,10 @@ import { SOURCE_META, } from "../data.ts"; import type { DataSource, LoadingState } from "../types.ts"; -import { GithubRepositorySyncSettings } from "./GithubRepositorySyncSettings.tsx"; +import { + GithubRepositorySyncSettings, + type SyncScheduleConfig, +} from "./GithubRepositorySyncSettings.tsx"; import { SourceStatusChip } from "./SourceStatusChip.tsx"; import { SourceTypeBadge } from "./SourceTypeBadge.tsx"; @@ -58,6 +61,13 @@ type SourceDetailsPanelProps = { instanceUrl: string, request: Omit, ) => Promise; + /** Loads the sync schedule of a Confluence space (by connection id). */ + onLoadConfluenceConfig?: (connectionId: string) => Promise; + /** Saves the sync schedule of a Confluence space (by connection id). */ + onSaveConfluenceConfig?: ( + connectionId: string, + request: ConfigureGithubRepositoryRequest, + ) => Promise; /** Enables/disables the source in the connector (allow/deny for ingestion). */ onSetSourceEnabled?: ( repository: NonNullable, @@ -86,6 +96,8 @@ export function SourceDetailsPanel({ onSaveRepositoryConfig, onLoadJiraConfig, onSaveJiraConfig, + onLoadConfluenceConfig, + onSaveConfluenceConfig, onSetSourceEnabled, onSetJiraSourceEnabled, onUnlinkSource, @@ -112,6 +124,11 @@ export function SourceDetailsPanel({ ((source.sourceSystem === "GITHUB" && repository !== null) || (isJira && jira !== null) || (isConfluence && Boolean(confluence?.connectionId))); + // GitHub and Jira start an asynchronous run, so "Update started" is the whole + // story here. Confluence ingests synchronously and its caller already reports + // the outcome (completed, partial or failed) — a second toast from here would + // duplicate it and, on a failed run, contradict it. + const reportsUpdateItself = isConfluence; const canManageRepositoryConfig = canManageSyncSettings && source.sourceSystem === "GITHUB" && @@ -124,6 +141,12 @@ export function SourceDetailsPanel({ jira !== null && onLoadJiraConfig !== undefined && onSaveJiraConfig !== undefined; + const canManageConfluenceConfig = + canManageSyncSettings && + isConfluence && + Boolean(confluence?.connectionId) && + onLoadConfluenceConfig !== undefined && + onSaveConfluenceConfig !== undefined; const canToggleEnabled = canManageSyncSettings && source.sourceSystem === "GITHUB" && @@ -137,17 +160,28 @@ export function SourceDetailsPanel({ const isTogglingEnabled = enabledState === "loading"; // Authorization is presence-based — the parent only passes onUnlinkSource when // the caller may manage the project's sources. GitHub needs the connection's - // repositoryId; Jira is identified by its instance URL. + // repositoryId; Jira is identified by its instance URL, Confluence by its + // connection id. const canUnlinkSource = onUnlinkSource !== undefined && ((source.sourceSystem === "GITHUB" && repository !== null && repository.repositoryId !== null) || - (isJira && jira !== null)); + (isJira && jira !== null) || + (isConfluence && Boolean(confluence?.connectionId))); const isUnlinking = unlinkState === "loading"; // Noun for the unlink copy: GitHub sources are repositories, Jira sources are - // instances. Keeps each connector's wording accurate. - const removableNoun = isJira ? "instance" : "repository"; + // instances, Confluence sources are spaces. Keeps each connector's wording + // accurate. + const removableNoun = isJira ? "instance" : isConfluence ? "space" : "repository"; + // What removal actually costs, per connector. A GitHub repository and a Jira + // instance are shared between projects and only lose the project association, + // so re-linking restores the source as it was. A Confluence connection belongs + // to a single project, so removing it deletes the connection itself — the + // pages already ingested stay, but the space has to be set up again. + const removalHint = isConfluence + ? "The pages it already ingested are kept. Connecting the space again sets it up from scratch." + : `The ${removableNoun} and its artifacts are kept. You can re-link it later.`; // GitHub exposes one timestamp per resource type; Jira refreshes issue data // (including comments and change history) as one combined resource. const hasResourceSyncTimes = @@ -222,14 +256,18 @@ export function SourceDetailsPanel({ try { await onUpdateSource(source); setUpdateState("success"); - toast.success("Update started", { - description: "Details refresh while ingestion runs.", - }); + if (!reportsUpdateItself) { + toast.success("Update started", { + description: "Details refresh while ingestion runs.", + }); + } } catch (error) { setUpdateState("error"); - toast.error(error instanceof Error ? error.message : "Couldn't start the update."); + if (!reportsUpdateItself) { + toast.error(error instanceof Error ? error.message : "Couldn't start the update."); + } } - }, [canUpdate, onUpdateSource, source, toast]); + }, [canUpdate, onUpdateSource, reportsUpdateItself, source, toast]); const handleConfirmUnlink = useCallback(async () => { if (!onUnlinkSource) return; @@ -507,6 +545,24 @@ export function SourceDetailsPanel({ )} + {canManageConfluenceConfig && + confluence?.connectionId && + onLoadConfluenceConfig && + onSaveConfluenceConfig && ( + + {/* Same control again: Confluence connections carry the identical + schedule contract, only the load/save endpoints differ. */} + onLoadConfluenceConfig(confluence.connectionId)} + onSave={(request) => onSaveConfluenceConfig(confluence.connectionId, request)} + autoUpdateOnText="Due checks update this Confluence space." + autoUpdateOffText="Due checks only mark this Confluence space out of date." + toggleAriaLabel="Toggle Confluence space auto update" + /> + + )} + {source.failedItems.length > 0 && (
@@ -534,8 +590,7 @@ export function SourceDetailsPanel({ className="mt-4 sm:mt-5" >

- Remove this {removableNoun} from the current project. The {removableNoun} and its - artifacts are kept. You can re-link it later. + Remove this {removableNoun} from the current project. {removalHint}

- {hasGithubSources || hasJiraSources ? ( + {syncSettingsProviders.length > 0 ? (