Compare commits

...

3 Commits

Author SHA1 Message Date
Brett Jephson 9f8cee669f Only open the dialog when the integration answers with a modal
A `block` output would render loose next to the button, and a `complete` means
the integration handled the click without any UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:47:27 +01:00
Brett Jephson 30e9be6d15 changeset 2026-09-09 14:44:27 +01:00
Brett Jephson b869c9c329 Render inline buttons that open an integration's UI
An integration block cannot live in a table cell, so a table can't carry the
ContentKit UI a feature-flag table needs. A button can, so a button carrying an
`integration` action now renders the integration's component in a dialog: the
integration answers the click and decides what the reader sees.

The `integration` variant of `DocumentAction` is not in the published
`@gitbook/api` yet, hence the cast in `getIntegrationAction`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:44:27 +01:00
5 changed files with 138 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Support buttons that open an integration's UI, so an integration can be used in places an integration block cannot go, such as a table cell.
@@ -7,10 +7,13 @@ import { Button, type ButtonProps } from '../primitives';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { getIntegrationAction } from './integrationAction';
import { IntegrationActionButton } from './IntegrationActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
import { getSelectAction } from './selectAction';
import { SelectActionButton } from './SelectActionButton';
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/lib/env';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
// Editor button sizes render one step smaller here; the editor default (`large`) keeps the previous `medium`.
@@ -41,6 +44,29 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
return <SelectActionButton value={selectAction.value} buttonProps={buttonProps} />;
}
// Skip in print/PDF: the integration renders into a dialog, which a static render can't show.
const integrationAction =
context.mode !== 'print' ? getIntegrationAction(inline.data) : null;
const spaceId = context.contentContext?.space?.id;
if (integrationAction && spaceId) {
return (
<IntegrationActionButton
integration={integrationAction.integration}
block={integrationAction.block}
spaceId={spaceId}
security={{
firstPartyDomains: [
...new Set([
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_INTEGRATIONS_CONTENT_HOST,
]),
],
}}
buttonProps={buttonProps}
/>
);
}
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
return (
@@ -0,0 +1,81 @@
'use client';
import React from 'react';
import type { ContentKitRenderOutputElement, RequestRenderIntegrationUI } from '@gitbook/api';
import { ContentKit, type ContentKitSecurity } from '@gitbook/react-contentkit/client';
import { Button, type ButtonProps } from '../primitives';
import { renderIntegrationUi } from './Integration/server-actions';
/**
* Button that hands the click to an integration: the integration renders its component in modal
* mode and decides what the reader sees. This is how an integration reaches places an integration
* block cannot go, such as a table cell.
*/
export function IntegrationActionButton(props: {
integration: string;
block: string;
spaceId: string;
security: ContentKitSecurity;
buttonProps: ButtonProps;
}) {
const { integration, block, spaceId, security, buttonProps } = props;
const [loading, setLoading] = React.useState(false);
const [modal, setModal] = React.useState<null | {
input: RequestRenderIntegrationUI;
output: ContentKitRenderOutputElement;
children: React.ReactNode;
}>(null);
const renderContext = React.useMemo(() => ({ integrationName: integration }), [integration]);
const onClick = async () => {
setLoading(true);
try {
const input: RequestRenderIntegrationUI = {
componentId: block,
props: {},
context: {
type: 'document',
spaceId,
editable: false,
theme: 'light', // Same limitation as the integration block: rendering is server-side.
},
};
const result = await renderIntegrationUi({ renderContext, request: input });
// Anything but a modal has no place to go here: a `block` would render loose next to
// the button, and a `complete` means the integration handled the click on its own.
if (result.output?.type === 'element' && result.output.element.type === 'modal') {
setModal({ input, output: result.output, children: result.children });
}
} finally {
setLoading(false);
}
};
return (
<>
<Button {...buttonProps} disabled={loading} onClick={onClick} />
{modal ? (
<ContentKit
renderContext={renderContext}
security={security}
initialInput={modal.input}
initialOutput={modal.output}
render={renderIntegrationUi}
onAction={(action) => {
if (action.action === '@ui.modal.close') {
setModal(null);
}
}}
onComplete={() => setModal(null)}
>
{modal.children}
</ContentKit>
) : null}
</>
);
}
@@ -0,0 +1,25 @@
import type * as api from '@gitbook/api';
/**
* Detect an "integration" action on a button's data, returning the integration and the component
* it targets, or `null` when the button is not an integration action.
*/
export function getIntegrationAction(data: api.DocumentInlineButton['data']) {
if (!('action' in data)) {
return null;
}
// TODO: drop the cast once `@gitbook/api` ships the `integration` variant of `DocumentAction`.
const action = data.action as { action: string; integration?: unknown; block?: unknown };
if (
action.action === 'integration' &&
typeof action.integration === 'string' &&
typeof action.block === 'string' &&
action.integration &&
action.block
) {
return { integration: action.integration, block: action.block };
}
return null;
}
+1 -1
View File
@@ -2,4 +2,4 @@
// Client-safe entrypoint: avoid pulling server rendering exports into Client Components.
export { ContentKit } from './ContentKit';
export type { ContentKitClientContextData } from './context';
export type { ContentKitClientContextData, ContentKitSecurity } from './context';