Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 13 additions & 3 deletions module/services/frontend/code/packages/saas-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed
- `<DatasourcesPanel gateway={{ apiBase, getAccessToken }} orgId={…} />` or
`<DatasourcesPanel client={…} orgId={…} />` — 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 <date, time> ·
<short commit>` for a github source, whose change sets the compiler enqueues
(a webhook delivery, or a reconcile pass — a declared schedule's occurrence or
Expand Down Expand Up @@ -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.
- `<ConnectGitHubForm onSubmit={…} … />` — 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
Expand Down Expand Up @@ -386,8 +396,8 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed
phase names and getting the terminal cases wrong. `<SourceSyncProgress source
report />` 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`.
- `<EditSourceForm source onSubmit … />`, 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DatasourcesPanel client={client} orgId="org-1" />);

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(<DatasourcesPanel client={client} orgId="org-1" />);

await screen.findByText(/GitHub takes no new sources to sync yet/);
expect(screen.queryByRole("button", { name: "Connect GitHub" })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: () =>
Expand Down Expand Up @@ -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(
<DatasourcesPanel
orgId="org-1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { readAppSetupReturn, scrubAppSetupReturn } from "./github-app-setup.js";
import {
useAccessibleScopes,
useAddGitHubSource,
useDatasourceCatalog,
useDeleteSource,
useListSources,
useReconcileSource,
Expand Down Expand Up @@ -327,6 +328,21 @@ function DatasourcesPanelView({
)
: undefined;
const addMutation = useAddGitHubSource(client);
// The catalog decides what is offered: a provider that accepts new sources
// gets its connect form, if this kit has one, and every other provider is
// named with the host's reason it takes none. A client that does not read
// the catalog — or whose read failed — offers GitHub alone, as before the
// catalog was read; the host refuses a provider it does not admit either way.
const catalog = useDatasourceCatalog(client);
const accepts = (connector: string) =>
!!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);
Expand Down Expand Up @@ -599,12 +615,27 @@ function DatasourcesPanelView({
Repositories this organization ingests from.
</p>
</div>
{canManage && (
{canManage && offerGitHub && (
<Button type="button" onClick={() => setShowConnect(true)}>
Connect GitHub
</Button>
)}
</div>
{canManage && refusedProviders.length > 0 && (
<ul
aria-label="Providers that take no new sources"
className="type-body space-y-1 text-muted-foreground"
>
{refusedProviders.map((provider) => (
<li key={provider.connector}>
{provider.displayName} takes no new sources to sync yet
{provider.gap ? `: ${provider.gap}.` : "."}
{provider.acceptsOperations &&
" Its operations can still be connected."}
</li>
))}
</ul>
)}

{scopes.isError ? (
<p role="alert" className="type-body text-destructive">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -419,6 +439,11 @@ export interface DatasourceClient {
listActivity?(orgId: string, sourceId: string): Promise<SourceActivity[]>;
listSources(orgId: string): Promise<DatasourceView[]>;
addGitHubSource(input: ConnectGitHubInput): Promise<void>;
/**
* The host's connector catalog. Optional, and without it a surface offers
* GitHub alone, as it did before the catalog was read.
*/
getCatalog?(): Promise<DatasourceProviderView[]>;
/**
* Creates the caller's own `api` source with the OAuth 2.0
* authorization-code grant (`API_CREDENTIAL_KIND_OAUTH2` +
Expand Down
2 changes: 2 additions & 0 deletions module/services/frontend/code/packages/saas-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
export {
useAccessibleScopes,
useAddGitHubSource,
useDatasourceCatalog,
useDeleteSource,
useListSources,
useReconcileSource,
Expand Down Expand Up @@ -120,6 +121,7 @@ export type {
DatasourceClient,
DatasourceDirectoryView,
DatasourceProviderName,
DatasourceProviderView,
DatasourceStatusName,
DatasourceView,
DomainView,
Expand Down
Loading