diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b8ba2435b..e41fa055a 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15369,7 +15369,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.15", + "version": "0.12.16", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15518,7 +15518,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.15", + "version": "0.12.16", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "1.9.1", diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index af8fac509..aa55415be 100644 --- a/module/services/frontend/code/packages/codefly-ui/package.json +++ b/module/services/frontend/code/packages/codefly-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/ui", - "version": "0.12.15", + "version": "0.12.16", "description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 428c2bcca..ecd5ec0ee 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -203,7 +203,7 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed - `` or `` — lists an org's connected sources (repo · status · paths · branch · boundary · live updates · last sync) with per-row **Sync**/**Delete** and - a **Connect GitHub** action. The last-sync cell shows whichever clock applies: + the connect actions the host's catalog admits. The last-sync cell shows whichever clock applies: `last_synced_at` for a provider that pulls, or `last ingest · ` for a github source, whose change sets the compiler enqueues (a webhook delivery, or a reconcile pass — a declared schedule's occurrence or @@ -268,6 +268,16 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed it keeps syncing, but the host connects no new source of that provider. The flag is keyed on `conformance_gap`, so an older host that sends no conformance fields flags nothing. + What the panel offers to connect is the host's connector catalog + (`GetDatasourceCatalog`, read through the client's optional `getCatalog`): a + provider whose `accepts_new_sources` is true gets its connect action (today + **Connect GitHub**; object storage's form joins once the host admits it) — and every provider that takes no new source is + named under the header with the host's gap sentence (and, for a provider that + still accepts operations, that those can be connected). An empty catalog offers + nothing, because that is the host admitting nothing. A client without + `getCatalog`, or whose catalog read failed, offers **Connect GitHub** alone, as + before the catalog was read; the host refuses a provider it does not admit + either way. - `` — the connect form (repo, paths, branch, target collection, webhook secret, and an access token only on the PAT path). Its Authentication choice always offers **Public repository (no token)**: the @@ -386,8 +396,8 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed phase names and getting the terminal cases wrong. `` is the card, and `useSourceSync(client, orgId, source)` the read. - Hooks over a `DatasourceClient`: `useListSources`, `useAddGitHubSource`, - `useUpdateSource`, `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, - `useMyAccountLinks`. + `useDatasourceCatalog`, `useUpdateSource`, + `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, `useMyAccountLinks`. - ``, behind the row's **Edit**, changes what a connected source fetches: its branch and its path and file-type filters, bound by the same schema the connect form uses. It offers the connect diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 89b66f66f..02bb811d6 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.15", + "version": "0.12.16", "description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx index 9d7190749..590b0ba07 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx @@ -1745,3 +1745,61 @@ describe("DatasourcesPanel for a viewer who manages nothing", () => { expect(link.getAttribute("href")).toBe("/admin/datasources"); }); }); + +describe("DatasourcesPanel connector catalog", () => { + const github = { + connector: "github", + displayName: "GitHub", + description: "A GitHub repository.", + acceptsNewSources: true, + gap: "", + acceptsOperations: false, + }; + const objectStorage = { + connector: "upload", + displayName: "Object storage", + description: "A bucket under a key prefix.", + acceptsNewSources: false, + gap: "no cursor, so every sync re-sends every object", + acceptsOperations: false, + }; + const api = { + connector: "api", + displayName: "HTTP API", + description: "An HTTP API.", + acceptsNewSources: false, + gap: "one untyped response", + acceptsOperations: true, + }; + + it("offers what the catalog accepts and names the rest with the host's reason", async () => { + const client = fakeClient({ + getCatalog: vi.fn(async () => [github, objectStorage, api]), + }); + renderWithClient(); + + const refused = await screen.findByRole("list", { + name: "Providers that take no new sources", + }); + expect( + [...refused.querySelectorAll("li")].map((item) => item.textContent), + ).toEqual([ + "Object storage takes no new sources to sync yet: no cursor, so every sync re-sends every object.", + // An API takes no source to sync, and is still connected for calls. + "HTTP API takes no new sources to sync yet: one untyped response. Its operations can still be connected.", + ]); + expect(screen.getByRole("button", { name: "Connect GitHub" })).toBeTruthy(); + }); + + it("withholds GitHub when the catalog does not admit it", async () => { + const client = fakeClient({ + getCatalog: vi.fn(async () => [ + { ...github, acceptsNewSources: false, gap: "not configured here" }, + ]), + }); + renderWithClient(); + + await screen.findByText(/GitHub takes no new sources to sync yet/); + expect(screen.queryByRole("button", { name: "Connect GitHub" })).toBeNull(); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx index c9114c216..f1abe1e73 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx @@ -97,6 +97,17 @@ function stubFetch(responseBody: unknown): { calls: FetchCall[] } { return stubFetchSequence([reply(responseBody)]); } +const githubCatalog = { + providers: [ + { + connector: "github", + displayName: "GitHub", + description: "A GitHub repository.", + acceptsNewSources: true, + }, + ], +}; + const oneSource = { datasources: [ { @@ -177,6 +188,7 @@ describe("createDatasourceClient", () => { expected: { branch: "main", paths: [], fileExtensions: [] }, }), deleteSource: () => client.deleteSource("org-1", "ds-1"), + getCatalog: () => client.getCatalog!(), // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. beginAccountLink: () => @@ -558,7 +570,9 @@ describe("DatasourcesPanel gateway binding", () => { ])( "offers %s the management controls only when their credential names the tier", async (_who, claims, manages) => { - stubFetch(oneSource); + // One body answers every call, so it carries the catalog too: GitHub + // is offered because the host's catalog admits it. + stubFetch({ ...oneSource, ...githubCatalog }); render( + !!catalog.data?.some( + (provider) => + provider.connector === connector && provider.acceptsNewSources, + ); + const offerGitHub = catalog.isSuccess ? accepts("github") : true; + const refusedProviders = catalog.isSuccess + ? catalog.data.filter((provider) => !provider.acceptsNewSources) + : []; const reconcileMutation = useReconcileSource(client); const deleteMutation = useDeleteSource(client); const updateMutation = useUpdateSource(client); @@ -599,12 +615,27 @@ function DatasourcesPanelView({ Repositories this organization ingests from.

- {canManage && ( + {canManage && offerGitHub && ( )} + {canManage && refusedProviders.length > 0 && ( +
    + {refusedProviders.map((provider) => ( +
  • + {provider.displayName} takes no new sources to sync yet + {provider.gap ? `: ${provider.gap}.` : "."} + {provider.acceptsOperations && + " Its operations can still be connected."} +
  • + ))} +
+ )} {scopes.isError ? (

diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts index b7d8b8a65..180b9e668 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts @@ -265,6 +265,17 @@ export function datasourceClientOverTransport( // The host starts a GitHub source's first sync as it connects it. notifySourceSyncRequested(response.datasource?.id ?? ""); }, + async getCatalog() { + const response = await client.getDatasourceCatalog({}); + return response.providers.map((provider) => ({ + connector: provider.connector, + displayName: provider.displayName, + description: provider.description, + acceptsNewSources: provider.acceptsNewSources, + gap: provider.acceptsNewSources ? "" : provider.conformanceGap, + acceptsOperations: provider.acceptsOperations, + })); + }, async addPersonalApiSource(input) { // No credential and no OAuth client: the authorization-code grant // stores only its selector on the source, and the host resolves the diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts index b249c566a..676fecdf8 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts @@ -38,6 +38,21 @@ export function useListSources(client: DatasourceClient, orgId: string) { }); } +/** + * The host's connector catalog, which providers a surface may offer. Disabled + * for a client that does not read it; the catalog changes only with a release. + */ +export function useDatasourceCatalog(client: DatasourceClient) { + const getCatalog = client.getCatalog?.bind(client); + return useQuery({ + queryKey: ["datasource-catalog"], + queryFn: () => getCatalog?.() ?? [], + enabled: !!getCatalog, + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }); +} + export function useAddGitHubSource(client: DatasourceClient) { const queryClient = useQueryClient(); return useMutation({ diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts index 08640df4f..0b31217bf 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts @@ -202,6 +202,26 @@ export interface ConnectGitHubInput { webhookSecret: string; } +/** + * One provider of the host's connector catalog (`GetDatasourceCatalog`). A + * surface offers a provider whose `acceptsNewSources` is true and flags the + * rest with their `gap`, which is the host's sentence for why it takes none. + */ +export interface DatasourceProviderView { + /** The connector's registry key, e.g. "github" or "upload". */ + connector: string; + displayName: string; + description: string; + acceptsNewSources: boolean; + /** Empty when the provider accepts new sources. */ + gap: string; + /** + * Whether a source of it may be connected for its operations even though it + * takes none to sync (the `api` provider). + */ + acceptsOperations: boolean; +} + /** * The person's own `api` source, ready for `addPersonalApiSource`: an API base * URL the person signs in to through the deployment's provider registration. @@ -419,6 +439,11 @@ export interface DatasourceClient { listActivity?(orgId: string, sourceId: string): Promise; listSources(orgId: string): Promise; addGitHubSource(input: ConnectGitHubInput): Promise; + /** + * The host's connector catalog. Optional, and without it a surface offers + * GitHub alone, as it did before the catalog was read. + */ + getCatalog?(): Promise; /** * Creates the caller's own `api` source with the OAuth 2.0 * authorization-code grant (`API_CREDENTIAL_KIND_OAUTH2` + diff --git a/module/services/frontend/code/packages/saas-ui/src/index.ts b/module/services/frontend/code/packages/saas-ui/src/index.ts index 3f9b2d2f9..338d40d46 100644 --- a/module/services/frontend/code/packages/saas-ui/src/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/index.ts @@ -57,6 +57,7 @@ export { export { useAccessibleScopes, useAddGitHubSource, + useDatasourceCatalog, useDeleteSource, useListSources, useReconcileSource, @@ -120,6 +121,7 @@ export type { DatasourceClient, DatasourceDirectoryView, DatasourceProviderName, + DatasourceProviderView, DatasourceStatusName, DatasourceView, DomainView,