Skip to content
Merged
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
14 changes: 14 additions & 0 deletions module/services/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ manifest — `id`, `nav`, `frontend.manifestUrl` + `exposedModule`, optional
`src/solutions/registry.ts`, which then writes the frontend half **through the
gateway** (`POST /solutions/_frontend`).

`sources` is the optional declaration of what a solution is **built on**:
`[{ provider: github, repo: owner/name, paths, ref, label }]`. `repo` is held
to `AddGitHubSourceRequest`'s own pattern, and the paths and ref to its bounds,
at the far end of the same journey — the declaration is submitted unedited by
the kit's `<DeclaredSourceCard>`, so a repository the connect RPC would refuse
is refused when it is *declared* rather than weeks later when somebody presses
Connect. A malformed declaration fails the registration whole, as a dashboard
or a surface does, and one repository declared twice is refused outright:
nothing downstream could say which of the two a card renders. It is a
statement, not an authority — declaring a source connects nothing and grants
no read of its contents. The solution page reads it in process and hands it to
the remote it mounts as `SolutionBinding.declaredSources`, so it is on neither
the public nor the internal HTTP projection and no new RPC exists to serve it.

The route **relays the registry's own answer**: `409` for a revision conflict,
`403` when the id belongs to another publisher, `422` (`registration_rejected`)
when the registry will not admit the manifest, `503` when the registry cannot be
Expand Down
8 changes: 4 additions & 4 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.

4 changes: 2 additions & 2 deletions module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"@base-ui/react": "^1.3.0",
"@bufbuild/protobuf": "^2.11.0",
"@codefly-dev/saas-sdk": "0.3.15",
"@codefly-dev/saas-ui": "0.11.4",
"@codefly-dev/ui": "0.11.4",
"@codefly-dev/saas-ui": "0.11.5",
"@codefly-dev/ui": "0.11.5",
"@codefly/saas-plugin-contract": "2.3.0",
"@codefly/saas-plugin-manifest": "0.1.0",
"@codefly/saas-plugin-react": "0.4.1",
Expand Down
27 changes: 27 additions & 0 deletions module/services/frontend/code/packages/codefly-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md).
`allowImages`, and no `innerHTML` anywhere; colour only through the host's
token utilities.

### Status, headers and empty states

Three props exist because a consuming solution reported reaching past the kit
for them (the same report that asked for a standalone stylesheet, a `Chip` and
list primitives — those are **not** here yet):

- `<Badge dot>` prefixes a small filled circle in the variant's own colour.
It is `aria-hidden` and decorative: the badge's text is what a reader and a
screen reader get, so the dot adds a glanceable mark without making colour
the only channel carrying the meaning. Use it when several badges sit in one
row and have to be told apart at a glance. **There is no `success` or
`warning` variant**, because the appearance layer defines no such token —
`destructive` is the only semantic colour the skin ladder carries. A healthy
state therefore uses `default` (solid) against `outline` for "not started";
giving status its own tones means adding appearance tokens, which is a design
decision for the skin's owner, not something a component may invent.
- `<Card description>` puts a line under the title, matching `Section`'s prop
of the same name. Without it a caller needing one either mixes `CardRoot` +
`CardDescription` into a page of plain `Card`s, which is how a page stops
looking like one page, or drops the sentence into the body where it reads as
content and loses the `card-description` slot.
- `<EmptyState description>` takes nodes, not just a string, so an empty state
whose explanation lives elsewhere on the page can **point at it** — "the card
above says where this stands", with a link if there is one. `children` is the
action slot; a cross-reference is not an action, so prose there (or
hand-written outside the block) loses the slot and its width bound.

## Entry points

| Import | Contents |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
"version": "0.11.4",
"version": "0.11.5",
"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
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ const files = sourceFiles(srcDir);
// when its file reaches zero. Anything else in that package is default-deny.
const SAAS_UI_BASELINE: Record<string, number> = {
"datasources/collection-access.tsx": 2,
"datasources/connect-github-form.tsx": 16,
// Lowered from 16 when the credential block moved into
// `datasources/credential-mode.tsx`, which carries none: the shared copy
// names its slots, so the debt left behind shrank rather than moving.
"datasources/connect-github-form.tsx": 11,
"datasources/datasources-panel.tsx": 17,
};
const saasUiDir = resolve(srcDir, "../../saas-ui/src");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,41 @@ const badgeVariants = cva(
},
);

/**
* `dot` prefixes a small filled circle that inherits the variant's text colour.
*
* It is decorative and `aria-hidden`: the badge's own text is what a reader and
* a screen reader get, so the dot adds a glanceable mark without adding a
* second, colour-only channel that carries meaning of its own. That is the
* point of keeping it here rather than letting each caller prepend its own —
* a hand-rolled dot is where a status ends up encoded in colour alone.
*/
function Badge({
className,
variant = "default",
dot = false,
render,
children,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
}: useRender.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { dot?: boolean }) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
children: dot ? (
<>
<span
aria-hidden
data-slot="badge-dot"
className="size-1.5 shrink-0 rounded-full bg-current"
/>
{children}
</>
) : (
children
),
},
props,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,59 @@ import { CardRoot } from "./card-root.js";

export interface CardProps {
title?: ReactNode;
/**
* A line under the title saying what this card is, matching `Section`'s
* prop of the same name.
*
* Without it a caller needing one either reaches for `CardRoot` +
* `CardDescription` — mixing two card shapes into one page, which is how a
* page stops looking like one page — or drops the sentence into the body as
* a muted paragraph, where it reads as content rather than as the card's
* own description and no longer carries the `card-description` slot.
*/
description?: ReactNode;
/** Trailing controls rendered opposite the title (buttons, menus). */
actions?: ReactNode;
children?: ReactNode;
className?: string;
}

/** A bordered surface with an optional title row and trailing actions. */
export function Card({ title, actions, children, className }: CardProps) {
export function Card({
title,
description,
actions,
children,
className,
}: CardProps) {
const hasHeader = title != null || description != null || actions != null;
return (
<CardRoot
className={cn("gap-0 rounded-lg border p-4 shadow-sm ring-0", className)}
>
{(title || actions) && (
<div className="mb-3 flex items-center justify-between gap-4">
{title && <h3 data-slot="card-heading" className="type-card-heading">
{title}
</h3>}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{hasHeader && (
// `items-start`, not `items-center`: with a description the heading
// block is two lines tall and centring would float the actions
// against the middle of it. Matches `Section`'s header.
<div className="mb-3 flex items-start justify-between gap-4">
<div className="space-y-1">
{title != null && (
<h3 data-slot="card-heading" className="type-card-heading">
{title}
</h3>
)}
{description != null && (
<p
data-slot="card-description"
className="type-card-description text-muted-foreground"
>
{description}
</p>
)}
</div>
{actions != null && (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
)}
</div>
)}
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,20 @@ export interface EmptyStateProps {
/** Leading glyph; defaults to a generic inbox so the block always has one. */
icon?: ReactNode;
heading: ReactNode;
/**
* The line under the heading. Takes nodes, not just a string, so an empty
* state whose explanation lives elsewhere on the page can **point at it**
* — "the source card above says where this stands", with a link if there is
* one to give. That is what this prop is for; `children` below is the
* action slot, and a cross-reference is not an action, so putting a
* sentence there (or hand-writing one outside the block) loses the
* `empty-state-description` slot and its width bound.
*/
description?: ReactNode;
/** Optional trailing content, e.g. a call-to-action button. */
/**
* Optional trailing content: a call to action, i.e. something to press.
* Prose belongs in `description`.
*/
children?: ReactNode;
className?: string;
variant?: "default" | "illustrated";
Expand Down
67 changes: 63 additions & 4 deletions module/services/frontend/code/packages/saas-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,22 @@ from the package root and from `@codefly-dev/saas-ui/solution`, which imports
React, Connect and the SDK's service descriptors but none of the datasource
components:

- `SolutionBinding` — the backend half of the props the host injects into every
solution page (`solutionId`, `apiBase`, `getAccessToken`, `subscribeToken`,
`refreshAccessToken`, `authedFetch`). The host's `SolutionPageProps` extends
it, so a remote types its props against the one definition. `subscribeToken`
- `SolutionBinding` — the props the host injects into every solution page: the
backend half (`solutionId`, `apiBase`, `getAccessToken`, `subscribeToken`,
`refreshAccessToken`, `authedFetch`) plus `declaredSources`. The host's
`SolutionPageProps` extends it, so a remote types its props against the one
definition. `subscribeToken`
is optional but a host that can notify should pass it: the getter stays stable
while the token rotates underneath it, so without a subscription the kit
re-reads on a short interval — one timer per observer, for as long as the page
is open.
- `declaredSources: DeclaredSource[]` — what this solution declared it is built
on (`sources:` in its registration manifest), validated by the host at
registration and handed straight back, so the repository is stated once
rather than once in the manifest an operator reads and again in the bundle.
Feed an entry to `<DeclaredSourceCard>`. Optional, and absent from an older
host: treat absence as "the host does not tell me", never as "this solution
declares nothing".
- `solutionFetch(binding, path, init)` / `solutionJson<T>(binding, path, init)` —
a request to the solution's own backend at `apiBase + path`, same-origin,
through the host's `authedFetch` (refresh-then-retry on a 401) with the bearer
Expand Down Expand Up @@ -151,6 +159,57 @@ components:
GitHub's unauthenticated limit of 60 requests an hour per IP address. When the
App is installed on that repository for the organization, the host uses the
App instead.
- `<DeclaredSourceCard gateway={{ apiBase, getAccessToken }} orgId={…}
declared={binding.declaredSources[0]} />` — one source a solution **declares**
it is built on, in one of three states. A solution built on one known
repository must not ask the person which repository: it declares it, and all
that is left to supply is the credential.
- **Set up** — nothing connected serves the declaration. The three credential
modes (App / public / PAT) and nothing else; `repo`, `paths` and `ref` come
from the declaration, are rendered as text, and are submitted unedited to
`AddGitHubSource`. There is no repository control at all, which is the
point: a field for it would let the person connect something other than
what the solution reads. The entries land in a collection named by the
declaration's `label`, falling back to the repository.
- **Connected** — the matched source is `ACTIVE`: its last ingest and commit,
how live updates reach it, the reconcile interval that bounds its
staleness, **Sync now**, and the same `renderSourceDetail` slot the panel
offers so the consumer can show the ingesting module's progress.
- **Error** — `DEGRADED` or `PAUSED`: the host's `status_reason` verbatim,
and Reconnect, which asks for the credential again (a replacement PAT, or
nothing on the App and public paths) and enqueues a sync.
There is no fourth state. A source whose status an **older host does not
report** (the field decodes to its proto default, which the gateway maps to
`unknown` rather than guessing) reads as **Connected** and says its state is
not reported: it matched the declaration, so it *is* connected, and calling
the absence of a status an error would paint a healthy source red with no
`status_reason` to show for it and offer to Reconnect — asking a person for a
credential again to fix nothing. Sync is still offered; Reconnect is not.
A viewer without `canManage` still sees the state and the reason, and is told
who acts; the host refuses the calls either way. The card takes no query or
auth context of its own — the same `client | gateway` binding
`DatasourcesPanel` takes — and adds **no RPC**: it reads `ListSources` for the
org and matches on `provider` + `repo` (case-insensitively, as GitHub treats
`owner/name`) plus `paths` when the declaration names any.
Two connected sources matching one declaration are **reported, never silently
picked**: they can disagree about credential, branch, scope and health, so
rendering the first would sync one and leave the other ingesting invisibly.
`matchDeclaredSources(declared, sources)` is that rule as a pure function, for
a consumer rendering the states in its own shell; `declaredCollectionLabel`
resolves where connected entries land.
- `CredentialMethodField` / `AccessTokenField` / `WebhookSecretField` /
`AppInstallPrompt` (with `CredentialMethod`, `credentialMethodFrom`,
`fieldErrorClass`) — the credential block `ConnectGitHubForm` and
`<DeclaredSourceCard>` both render, presentational and controlled, exported
so a consumer building its own shell around `matchDeclaredSources` can **ask
for the credential without copying the form**. Compose these; do not copy
`ConnectGitHubForm` — `solutions/README.md` refuses a copied capability
wrapped in a shared card, and these three modes are the whole of what a
person decides between, so two surfaces that word them differently teach two
different products.
Do not mount this and `<DatasourcesPanel>` on the same page: both redeem the
GitHub App's single-use return state, and the loser reports a rejection for an
installation that in fact succeeded.
- `createDatasourceClient({ apiBase, getAccessToken, refreshAccessToken })` — builds the
gateway-bound `DatasourceClient` (with 401 refresh-and-retry) directly, for driving the
hooks outside the panel. `datasourceClientOverTransport(transport)` does the same over a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
"version": "0.11.4",
"version": "0.11.5",
"description": "Reusable SaaS-domain frontend components (datasources, \u2026) 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 @@ -77,7 +77,12 @@ describe("the README install line matches peerDependencies", () => {
});

describe("SaaS presentation composes shared controls", () => {
for (const file of ["connect-github-form.tsx", "datasources-panel.tsx"]) {
for (const file of [
"connect-github-form.tsx",
"credential-mode.tsx",
"datasources-panel.tsx",
"declared-source-card.tsx",
]) {
it(file, () => {
const source = readFileSync(
join(packageRoot, "src/datasources", file),
Expand Down
Loading
Loading