Skip to content

Commit 7374138

Browse files
feat: experimental: Interactivity for queries (#1025)
This is an experimental feature or adding something very similar to slack's Block kit, to queries to allow for a degree of interactivity and sending of signals. It should allow users to specify an arbitrary markdown and response and also buttons with some signals to the workflows.
1 parent ca0567d commit 7374138

File tree

10 files changed

+392
-20
lines changed

10 files changed

+392
-20
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { styled as createStyled } from 'baseui';
2+
3+
export const styled = {
4+
ViewContainer: createStyled('div', ({ $theme }) => ({
5+
display: 'flex',
6+
flexDirection: 'column',
7+
gap: $theme.sizing.scale600,
8+
})),
9+
10+
SectionContainer: createStyled('div', () => ({
11+
display: 'block',
12+
wordBreak: 'break-word',
13+
overflow: 'hidden',
14+
})),
15+
16+
DividerContainer: createStyled('hr', ({ $theme }) => ({
17+
border: 'none',
18+
borderTop: `2px solid ${$theme.colors.backgroundTertiary}`,
19+
margin: `${$theme.sizing.scale400} 0`,
20+
width: '100%',
21+
})),
22+
ActionsContainer: createStyled('div', ({ $theme }) => ({
23+
display: 'flex',
24+
flexDirection: 'row',
25+
gap: $theme.sizing.scale400,
26+
flexWrap: 'wrap',
27+
alignItems: 'center',
28+
})),
29+
};

src/components/blocks/blocks.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
'use client';
2+
import React, { useState } from 'react';
3+
4+
import { Button } from 'baseui/button';
5+
import { useSnackbar } from 'baseui/snackbar';
6+
7+
import Markdown from '@/components/markdown/markdown';
8+
import PrettyJson from '@/components/pretty-json/pretty-json';
9+
import losslessJsonStringify from '@/utils/lossless-json-stringify';
10+
import request from '@/utils/request';
11+
12+
import { styled } from './blocks.styles';
13+
import {
14+
type Props,
15+
type Block,
16+
type SectionBlock,
17+
type ActionsBlock,
18+
type ButtonElement,
19+
} from './blocks.types';
20+
21+
export default function Blocks({
22+
blocks,
23+
domain,
24+
cluster,
25+
workflowId,
26+
runId,
27+
}: Props) {
28+
const [loadingButtons, setLoadingButtons] = useState<Set<string>>(new Set());
29+
const { enqueue } = useSnackbar();
30+
31+
const handleButtonClick = async (button: ButtonElement, index: number) => {
32+
// Only handle signal actions
33+
if (button.action.type !== 'signal') {
34+
enqueue({
35+
message: `Button action type '${button.action.type}' is not supported. Only 'signal' actions are currently handled.`,
36+
actionMessage: 'OK',
37+
});
38+
return;
39+
}
40+
41+
const buttonKey = `${button.action.signal_name}-${index}`;
42+
43+
if (loadingButtons.has(buttonKey)) {
44+
return; // Prevent double clicks
45+
}
46+
47+
setLoadingButtons((prev) => new Set(prev).add(buttonKey));
48+
49+
try {
50+
const targetWorkflowId = button.action.workflow_id || workflowId;
51+
const targetRunId = button.action.run_id || runId;
52+
53+
const signalInput = button.action.signal_value
54+
? losslessJsonStringify(button.action.signal_value)
55+
: undefined;
56+
57+
const response = await request(
58+
`/api/domains/${encodeURIComponent(domain)}/${encodeURIComponent(cluster)}/workflows/${encodeURIComponent(targetWorkflowId)}/${encodeURIComponent(targetRunId)}/signal`,
59+
{
60+
method: 'POST',
61+
body: JSON.stringify({
62+
signalName: button.action.signal_name,
63+
signalInput,
64+
}),
65+
}
66+
);
67+
68+
if (!response.ok) {
69+
const errorData = await response.json();
70+
enqueue({
71+
message: errorData.message || 'Failed to signal workflow',
72+
actionMessage: 'OK',
73+
});
74+
return;
75+
}
76+
77+
// Optionally show success feedback here
78+
} catch (error: any) {
79+
enqueue({
80+
message: error.message || 'Failed to signal workflow',
81+
actionMessage: 'Dismiss',
82+
});
83+
} finally {
84+
setLoadingButtons((prev) => {
85+
const newSet = new Set(prev);
86+
newSet.delete(buttonKey);
87+
return newSet;
88+
});
89+
}
90+
};
91+
92+
const renderSection = (section: SectionBlock) => {
93+
const content = section.componentOptions.text;
94+
if (section.format === 'text/markdown') {
95+
return (
96+
<styled.SectionContainer>
97+
<Markdown markdown={content} />
98+
</styled.SectionContainer>
99+
);
100+
}
101+
102+
// Fallback to JSON rendering for other formats
103+
try {
104+
const jsonContent = JSON.parse(content);
105+
return (
106+
<styled.SectionContainer>
107+
<PrettyJson json={jsonContent} />
108+
</styled.SectionContainer>
109+
);
110+
} catch {
111+
// If it's not valid JSON, render as plain text
112+
return (
113+
<styled.SectionContainer>
114+
<pre>{content}</pre>
115+
</styled.SectionContainer>
116+
);
117+
}
118+
};
119+
120+
const renderActions = (actions: ActionsBlock) => {
121+
return (
122+
<styled.ActionsContainer>
123+
{actions.elements.map((element, index) => {
124+
if (element.type === 'button') {
125+
const buttonKey = `${element.action.type}-${element.action.signal_name}-${index}`;
126+
const isLoading = loadingButtons.has(buttonKey);
127+
128+
return (
129+
<Button
130+
key={buttonKey}
131+
disabled={isLoading}
132+
onClick={() => handleButtonClick(element, index)}
133+
isLoading={isLoading}
134+
>
135+
{element.componentOptions.text}
136+
</Button>
137+
);
138+
}
139+
return null;
140+
})}
141+
</styled.ActionsContainer>
142+
);
143+
};
144+
145+
const renderBlock = (block: Block, index: number) => {
146+
switch (block.type) {
147+
case 'section':
148+
return <div key={`section-${index}`}>{renderSection(block)}</div>;
149+
case 'divider':
150+
return <styled.DividerContainer key={`divider-${index}`} />;
151+
case 'actions':
152+
return <div key={`actions-${index}`}>{renderActions(block)}</div>;
153+
default:
154+
return null;
155+
}
156+
};
157+
158+
return (
159+
<styled.ViewContainer>
160+
{blocks.map((block, index) => renderBlock(block, index))}
161+
</styled.ViewContainer>
162+
);
163+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export type Block = SectionBlock | DividerBlock | ActionsBlock;
2+
3+
export type SectionBlock = {
4+
type: 'section';
5+
format: string;
6+
componentOptions: {
7+
text: string;
8+
};
9+
};
10+
11+
export type DividerBlock = {
12+
type: 'divider';
13+
};
14+
15+
export type ActionsBlock = {
16+
type: 'actions';
17+
elements: ButtonElement[];
18+
};
19+
20+
export type ButtonElement = {
21+
type: 'button';
22+
componentOptions: {
23+
type: string;
24+
text: string;
25+
};
26+
action: {
27+
type: 'signal';
28+
signal_name: string;
29+
signal_value?: Record<string, any>;
30+
workflow_id?: string;
31+
run_id?: string;
32+
};
33+
};
34+
35+
export type Props = {
36+
blocks: Block[];
37+
domain: string;
38+
cluster: string;
39+
workflowId: string;
40+
runId: string;
41+
};

src/views/workflow-queries/__tests__/workflow-queries.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ jest.mock('../workflow-queries-tile/workflow-queries-tile', () =>
2020
);
2121

2222
jest.mock('../workflow-queries-result/workflow-queries-result', () =>
23-
jest.fn(({ data }) => (
23+
jest.fn(({ data, domain, cluster, workflowId, runId }) => (
2424
<div>
2525
<div>Mock JSON</div>
2626
<div>{JSON.stringify(data)}</div>
27+
<div>
28+
Domain: {domain}, Cluster: {cluster}, WorkflowId: {workflowId}, RunId:{' '}
29+
{runId}
30+
</div>
2731
</div>
2832
))
2933
);

src/views/workflow-queries/workflow-queries-result/__tests__/workflow-queries-result-json.test.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ jest.mock('@/components/pretty-json/pretty-json', () =>
2626
))
2727
);
2828

29+
jest.mock('@/components/blocks/blocks', () =>
30+
jest.fn(({ blocks, domain, cluster, workflowId, runId }) => (
31+
<div>
32+
Blocks Mock: {domain}/{cluster}/{workflowId}/{runId} -{' '}
33+
{JSON.stringify(blocks)}
34+
</div>
35+
))
36+
);
37+
2938
describe(WorkflowQueriesResult.name, () => {
3039
it('renders json when the content type is json', () => {
3140
setup({
@@ -72,6 +81,30 @@ describe(WorkflowQueriesResult.name, () => {
7281
screen.getByText('Markdown Mock: test-markdown')
7382
).toBeInTheDocument();
7483
});
84+
85+
it('renders blocks when the content type is blocks', () => {
86+
setup({
87+
content: {
88+
contentType: 'blocks',
89+
content: [
90+
{
91+
type: 'section' as const,
92+
format: 'text/markdown',
93+
componentOptions: {
94+
text: '# Test',
95+
},
96+
},
97+
],
98+
isError: false,
99+
},
100+
});
101+
102+
expect(
103+
screen.getByText(
104+
/Blocks Mock: test-domain\/test-cluster\/test-workflow-id\/test-run-id/
105+
)
106+
).toBeInTheDocument();
107+
});
75108
});
76109

77110
function setup({
@@ -90,5 +123,15 @@ function setup({
90123
content?: QueryJsonContent;
91124
}) {
92125
(getQueryResultContent as jest.Mock).mockImplementation(() => content);
93-
render(<WorkflowQueriesResult data={data} error={error} loading={loading} />);
126+
render(
127+
<WorkflowQueriesResult
128+
data={data}
129+
error={error}
130+
loading={loading}
131+
domain="test-domain"
132+
cluster="test-cluster"
133+
workflowId="test-workflow-id"
134+
runId="test-run-id"
135+
/>
136+
);
94137
}

0 commit comments

Comments
 (0)