feat: add action resp viz (#39690)

## Description


Fixes #39554 
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13811044062>
> Commit: 1a5b458e43a338ad74eb48908a16ce695a6f53e2
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13811044062&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity`
> Spec:
> <hr>Wed, 12 Mar 2025 12:57:34 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
  - Added a new forward arrow icon for enhanced design consistency.
- Expanded sidebar functionality to support an optional extra title
button for additional actions.
- Introduced a comprehensive visualization experience, including
interactive components for generating, saving, and displaying
visualizations, along with prompt inputs, suggestions, and a results
view.
- Enhanced action capabilities to support visualization data and
debugging with a new visualization tab.
- Enabled new release functionalities via an updated feature flag
system.

- **Style**
  - Refined sidebar title spacing and layout for improved presentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ilia 2025-03-12 13:59:36 +01:00 committed by GitHub
parent c417c3058b
commit 73ee6e9ba7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 589 additions and 23 deletions

View File

@ -8,6 +8,9 @@ const AddMoreIcon = importRemixIcon(
const AddMoreFillIcon = importRemixIcon(
async () => import("remixicon-react/AddCircleFillIcon"),
);
const ArrowGoForwardLineIcon = importRemixIcon(
async () => import("remixicon-react/ArrowGoForwardLineIcon"),
);
const ArrowGoBackLineIcon = importRemixIcon(
async () => import("remixicon-react/ArrowGoBackLineIcon"),
);
@ -1178,6 +1181,7 @@ const ICON_LOOKUP = {
"add-line": AddLineIcon,
"add-more": AddMoreIcon,
"add-more-fill": AddMoreFillIcon,
"arrow-go-forward": ArrowGoForwardLineIcon,
"alert-fill": AlertFillIcon,
"alert-line": AlertLineIcon,
"align-center": AlignCenter,

View File

@ -14,6 +14,7 @@ const _Sidebar = (props: SidebarProps, ref: Ref<HTMLDivElement>) => {
children,
className,
collapsible = "offcanvas",
extraTitleButton,
onEnter: onEnterProp,
onEntered: onEnteredProp,
onExit: onExitProp,
@ -47,7 +48,7 @@ const _Sidebar = (props: SidebarProps, ref: Ref<HTMLDivElement>) => {
};
const content = (
<SidebarContent title={title}>
<SidebarContent extraTitleButton={extraTitleButton} title={title}>
{typeof children === "function"
? children({ isAnimating, state })
: children}

View File

@ -1,5 +1,5 @@
import clsx from "clsx";
import React, { type Ref } from "react";
import React, { type ReactNode, type Ref } from "react";
import { Flex } from "../../Flex";
import { Text } from "../../Text";
@ -9,6 +9,7 @@ import { useSidebar } from "./use-sidebar";
interface SidebarContentProps {
title?: string;
extraTitleButton?: ReactNode;
className?: string;
children: React.ReactNode;
}
@ -17,7 +18,7 @@ const _SidebarContent = (
props: SidebarContentProps,
ref: Ref<HTMLDivElement>,
) => {
const { children, className, title, ...rest } = props;
const { children, className, extraTitleButton, title, ...rest } = props;
const { isMobile, setState, state } = useSidebar();
return (
@ -39,26 +40,29 @@ const _SidebarContent = (
className={styles.sidebarTitle}
fontWeight={500}
lineClamp={1}
textAlign="center"
>
{title}
</Text>
)}
{!isMobile && (
<Button
className={styles.sidebarHeaderExpandButton}
color="neutral"
icon={
state === "full-width"
? "arrows-diagonal-minimize"
: "arrows-diagonal-2"
}
onPress={() =>
setState(state === "full-width" ? "expanded" : "full-width")
}
variant="ghost"
/>
)}
<Flex>
{extraTitleButton}
{!isMobile && (
<Button
className={styles.sidebarHeaderExpandButton}
color="neutral"
icon={
state === "full-width"
? "arrows-diagonal-minimize"
: "arrows-diagonal-2"
}
onPress={() =>
setState(state === "full-width" ? "expanded" : "full-width")
}
variant="ghost"
/>
)}
</Flex>
</Flex>
<div className={styles.sidebarContentInner}>{children}</div>
</Flex>

View File

@ -170,10 +170,7 @@
}
.sidebarTitle {
position: absolute;
left: 0;
right: 0;
margin: 0 var(--sizing-14);
margin: 0 var(--sizing-4);
}
.sidebarHeaderExpandButton {

View File

@ -24,6 +24,7 @@ export interface SidebarProviderProps {
export interface SidebarProps {
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
extraTitleButton?: ReactNode;
onEnter?: () => void;
onExit?: () => void;
onEntered?: () => void;

View File

@ -0,0 +1,111 @@
import { Button, Flex } from "@appsmith/ads";
import type { ActionResponse } from "api/ActionAPI";
import { type Action } from "entities/Action";
import React, { useState } from "react";
import { parseActionResponse } from "../Response/utils";
import { EmptyVisualization } from "./components/EmptyVisualization";
import { LoadingOverlay } from "./components/LoadingOverlay";
import { PromptInput } from "./components/PromptInput";
import { Result } from "./components/Result";
import { SuggestionButtons } from "./components/SuggestionButtons";
import { useGenerateVisualization } from "./useGenerateVisualization";
import { useSaveVisualization } from "./useSaveVisualization";
import { ErrorBoundary } from "@sentry/react";
interface VisualizationProps {
action: Action;
actionResponse?: ActionResponse;
}
const BOTTOM_BAR_HEIGHT = 37;
export const Visualization = (props: VisualizationProps) => {
const { action, actionResponse } = props;
const [prompt, setPrompt] = useState("");
const { response } = parseActionResponse(actionResponse);
const generateVisualization = useGenerateVisualization(
action.id,
action.visualization?.result,
);
const saveVisualization = useSaveVisualization(action.id);
return (
// TODO: Remove the hardcoded height
<Flex
flexDirection="column"
height={`calc(100% - ${BOTTOM_BAR_HEIGHT}px)`}
position="relative"
>
<Flex
borderBottom="1px solid var(--ads-v2-color-border-muted)"
flexDirection="column"
gap="spaces-3"
padding="spaces-3"
>
<SuggestionButtons onApply={setPrompt} />
<Flex gap="spaces-3">
<PromptInput
isDisabled={!response || saveVisualization.isLoading}
isLoading={generateVisualization.isLoading}
onChange={setPrompt}
onSubmit={async () =>
generateVisualization.execute(prompt, response)
}
value={prompt}
/>
<Button
isDisabled={
generateVisualization.isLoading ||
saveVisualization.isLoading ||
!generateVisualization.hasPrevious
}
isIconButton
kind="secondary"
onClick={generateVisualization.previous}
size="md"
startIcon="arrow-go-back"
/>
<Button
isDisabled={
generateVisualization.isLoading ||
saveVisualization.isLoading ||
!generateVisualization.hasNext
}
isIconButton
kind="secondary"
onClick={generateVisualization.next}
size="md"
startIcon="arrow-go-forward"
/>
<Button
isDisabled={
generateVisualization.isLoading ||
saveVisualization.isLoading ||
!generateVisualization.elements
}
isLoading={saveVisualization.isLoading}
kind="secondary"
onClick={async () =>
generateVisualization.elements &&
saveVisualization.execute(generateVisualization.elements)
}
size="md"
>
Save
</Button>
</Flex>
</Flex>
<Flex flexDirection="column" flexGrow={1} position="relative">
{generateVisualization.elements ? (
<ErrorBoundary fallback="Visualization failed. Please try again.">
<Result data={response} elements={generateVisualization.elements} />
</ErrorBoundary>
) : (
<EmptyVisualization />
)}
{generateVisualization.isLoading && <LoadingOverlay />}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,18 @@
import { Flex, Text } from "@appsmith/ads";
import NoVisualizationSVG from "assets/images/no-visualization.svg";
import React from "react";
export const EmptyVisualization = () => {
return (
<Flex
alignItems="center"
flexDirection="column"
gap="spaces-7"
height="100%"
justifyContent="center"
>
<img alt="No visualization" src={NoVisualizationSVG} />
<Text>The response visualization will be shown here</Text>
</Flex>
);
};

View File

@ -0,0 +1,22 @@
import { Flex, Text } from "@appsmith/ads";
import React from "react";
export const LoadingOverlay = () => {
return (
<Flex
alignItems="center"
backgroundColor="color-mix(in srgb, var(--ads-v2-color-gray-800) 80%, transparent);"
bottom="0"
flexDirection="column"
justifyContent="center"
left="0"
position="absolute"
right="0"
top="0"
>
<Text color="var(--ads-v2-color-white)" kind="heading-m">
Generating visualization...
</Text>
</Flex>
);
};

View File

@ -0,0 +1,47 @@
import { Button, Input } from "@appsmith/ads";
import React from "react";
import styled from "styled-components";
interface PromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isLoading: boolean;
isDisabled: boolean;
}
const PromptForm = styled.form`
display: flex;
flex: 1;
gap: var(--ads-v2-spaces-3);
`;
export const PromptInput = (props: PromptInputProps) => {
const { isDisabled, isLoading, onChange, onSubmit, value } = props;
return (
<PromptForm
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Input
isDisabled={isDisabled}
onChange={onChange}
placeholder="Describe the data visualisation you want"
size="md"
value={value}
/>
<Button
isDisabled={isDisabled || !value}
isIconButton
isLoading={isLoading}
kind="primary"
size="md"
startIcon="arrow-up-line"
type="submit"
/>
</PromptForm>
);
};

View File

@ -0,0 +1,138 @@
/* eslint-disable react-perf/jsx-no-new-function-as-prop */
import { transform } from "@babel/standalone";
import type { VisualizationElements } from "entities/Action";
import React, { memo, useEffect, useRef, useState } from "react";
// @ts-expect-error - Type error due to raw loader
import customWidgetScript from "!!raw-loader!../../../../../../widgets/CustomWidget/component/customWidgetscript.js";
// @ts-expect-error - Type error due to raw loader
import appsmithConsole from "!!raw-loader!../../../../../../widgets/CustomWidget/component/appsmithConsole.js";
import { EVENTS } from "../../../../../../widgets/CustomWidget/component/customWidgetscript";
interface ResultProps {
elements: VisualizationElements;
data: unknown;
}
const theme = {
primaryColor: "#000000",
backgroundColor: "#FFFFFF",
borderRadius: 0,
boxShadow: "none",
fontFamily: "Arial",
};
export const Result = memo(({ data, elements }: ResultProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isIframeReady, setIsIframeReady] = useState(false);
const compiledJs = transform(elements.js, {
sourceType: "module",
presets: ["react"],
targets: {
esmodules: true,
},
});
useEffect(
function setupIframeMessageHandler() {
const handler = (event: MessageEvent) => {
const iframeWindow =
iframeRef.current?.contentWindow ||
iframeRef.current?.contentDocument?.defaultView;
if (event.source === iframeWindow) {
iframeRef.current?.contentWindow?.postMessage(
{
type: EVENTS.CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK,
key: event.data.key,
success: true,
},
"*",
);
const message = event.data;
switch (message.type) {
case EVENTS.CUSTOM_WIDGET_READY:
setIsIframeReady(true);
iframeRef.current?.contentWindow?.postMessage(
{
type: EVENTS.CUSTOM_WIDGET_READY_ACK,
model: {
data,
},
ui: {
width: 0,
height: 0,
},
theme,
},
"*",
);
break;
case "CUSTOM_WIDGET_CONSOLE_EVENT":
if (message.data.type === "error") {
throw new Error(message.data.args);
}
break;
}
}
};
window.addEventListener("message", handler, false);
return () => window.removeEventListener("message", handler, false);
},
[data],
);
useEffect(
function updateModel() {
if (
iframeRef.current &&
iframeRef.current.contentWindow &&
isIframeReady
) {
iframeRef.current.contentWindow.postMessage(
{
type: EVENTS.CUSTOM_WIDGET_MODEL_CHANGE,
model: { model: data },
},
"*",
);
}
},
[data],
);
const srcDoc = `
<html>
<head>
<style>${elements.css}</style>
</head>
<body>
<script type="text/javascript">${appsmithConsole}</script>
<script type="module">
${customWidgetScript}
main();
</script>
<script type="module">
${compiledJs.code}
</script>
${elements.html}
</body>
</html>
`;
return (
<iframe
data-testid="t--visualization-result"
ref={iframeRef}
sandbox="allow-scripts allow-same-origin"
srcDoc={srcDoc}
style={{ width: "100%", height: "100%" }}
/>
);
});

View File

@ -0,0 +1,43 @@
import { Button, Flex } from "@appsmith/ads";
import React, { memo } from "react";
interface SuggestionButtonsProps {
onApply: (suggestion: string) => void;
}
const suggestions = [
{
label: "Table",
text: "Create a table",
},
{
label: "Chart",
text: "Create a chart",
},
{
label: "List",
text: "Create a list",
},
{
label: "Form",
text: "Create a form",
},
];
export const SuggestionButtons = memo((props: SuggestionButtonsProps) => {
const { onApply } = props;
return (
<Flex gap="spaces-2">
{suggestions.map((suggestion) => (
<Button
key={suggestion.label}
kind="secondary"
onClick={() => onApply(suggestion.text)}
>
{suggestion.label}
</Button>
))}
</Flex>
);
});

View File

@ -0,0 +1 @@
export * from "./Visualization";

View File

@ -0,0 +1,77 @@
import ActionAPI from "api/ActionAPI";
import type { VisualizationElements } from "entities/Action";
import { useCallback, useEffect, useState } from "react";
export const useGenerateVisualization = (
actionId: string,
elements?: VisualizationElements,
) => {
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [results, setResults] = useState<VisualizationElements[]>(
elements ? [elements] : [],
);
const [currIndex, setCurrIndex] = useState(0);
const clampIndex = useCallback(
(index: number) => {
return Math.max(0, Math.min(index, results.length - 1));
},
[results],
);
useEffect(
function updateCurrIndex() {
setCurrIndex(clampIndex(results.length - 1));
},
[results, clampIndex, setCurrIndex],
);
const execute = useCallback(
async (prompt: string, data: unknown) => {
setIsLoading(true);
setHasError(false);
try {
const response = await ActionAPI.generateVisualization(
actionId,
prompt,
data,
);
setResults((result) => [
...result,
{
js: response.data.result.js,
css: response.data.result.css,
html: response.data.result.html,
},
]);
} catch (error) {
setHasError(true);
} finally {
setIsLoading(false);
}
},
[actionId],
);
const previous = useCallback(() => {
setCurrIndex((i) => clampIndex(i - 1));
}, [clampIndex, setCurrIndex]);
const next = useCallback(() => {
setCurrIndex((i) => clampIndex(i + 1));
}, [clampIndex, setCurrIndex]);
return {
execute,
isLoading,
hasError,
elements: results.length > 0 ? results[currIndex] : undefined,
hasPrevious: currIndex > 0,
hasNext: currIndex < results.length - 1,
previous,
next,
};
};

View File

@ -0,0 +1,41 @@
import { updateActionProperty } from "actions/pluginActionActions";
import ActionAPI from "api/ActionAPI";
import type { VisualizationElements } from "entities/Action";
import { useCallback, useState } from "react";
import { useDispatch } from "react-redux";
export const useSaveVisualization = (actionId: string) => {
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const dispatch = useDispatch();
const execute = useCallback(
async (elements: VisualizationElements) => {
setIsLoading(true);
setHasError(false);
try {
const response = await ActionAPI.saveVisualization(actionId, elements);
dispatch(
updateActionProperty({
id: actionId,
field: "visualization",
value: response.data,
}),
);
} catch (error) {
setHasError(true);
} finally {
setIsLoading(false);
}
},
[actionId],
);
return {
execute,
isLoading,
hasError,
};
};

View File

@ -162,6 +162,49 @@ class ActionAPI extends API {
return API.get(`${ActionAPI.url}/view`, { applicationId });
}
static async generateVisualization(
actionId: string,
prompt: string,
data: unknown,
): Promise<
AxiosPromise<{
result: { html: string; css: string; js: string; error: string };
}>
> {
return API.post(
`${ActionAPI.url}/${actionId}/visualize`,
{
prompt,
data,
},
{},
{
timeout: 60000, // 1 minute
},
);
}
static async saveVisualization(
actionId: string,
elements: {
html: string;
css: string;
js: string;
},
): Promise<
AxiosPromise<
ApiResponse<{
result: { html: string; css: string; js: string; error: string };
}>
>
> {
return API.patch(`${ActionAPI.url}/${actionId}/visualize`, {
existing: {
result: elements,
},
});
}
static async fetchActionsByPageId(
pageId: string,
): Promise<AxiosPromise<ApiResponse<Action[]>>> {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -54,6 +54,7 @@ export const FEATURE_FLAG = {
"release_external_saas_plugins_enabled",
release_tablev2_infinitescroll_enabled:
"release_tablev2_infinitescroll_enabled",
release_fn_calling_enabled: "release_fn_calling_enabled",
} as const;
export type FeatureFlag = keyof typeof FEATURE_FLAG;
@ -99,6 +100,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = {
release_ads_entity_item_enabled: false,
release_external_saas_plugins_enabled: false,
release_tablev2_infinitescroll_enabled: false,
release_fn_calling_enabled: false,
};
export const AB_TESTING_EVENT_KEYS = {

View File

@ -6,4 +6,5 @@ export enum DEBUGGER_TAB_KEYS {
ERROR_TAB = "ERROR_TAB",
LOGS_TAB = "LOGS_TAB",
STATE_TAB = "STATE_TAB",
VISUALIZATION_TAB = "VISUALIZATION_TAB",
}

View File

@ -124,6 +124,12 @@ export interface StoredDatasource {
datasourceConfiguration?: { url?: string };
}
export interface VisualizationElements {
css: string;
js: string;
html: string;
}
export interface BaseAction {
id: string;
baseId: string;
@ -157,6 +163,9 @@ export interface BaseAction {
// will always be undefined for non js actions
isMainJSCollection?: boolean;
source?: ActionCreationSourceTypeEnum;
visualization?: {
result: VisualizationElements;
};
}
interface BaseApiAction extends BaseAction {

View File

@ -10,6 +10,10 @@ export const getIsAnvilLayoutEnabled = (state: AppState) => {
return selectFeatureFlagCheck(state, FEATURE_FLAG.release_anvil_enabled);
};
export const getReleaseFnCallingEnabled = (state: AppState) => {
return selectFeatureFlagCheck(state, FEATURE_FLAG.release_fn_calling_enabled);
};
/**
* A selector to verify if the current application is an Anvil application.
* This is done by getting the layout system type of the current application (getLayoutSystemType)

View File

@ -16,6 +16,7 @@ export const AvailableFeaturesToOverride: FeatureFlag[] = [
"release_anvil_enabled",
"release_layout_conversion_enabled",
"release_anvil_toggle_enabled",
"release_fn_calling_enabled",
];
export type OverriddenFeatureFlags = Partial<Record<FeatureFlag, boolean>>;