feat(app): introduce Enable App setting (#8521)

This commit is contained in:
naman-bruno
2026-07-08 21:46:11 +05:30
committed by GitHub
parent 139d71a8f8
commit d0863e2a43
27 changed files with 472 additions and 133 deletions

View File

@@ -43,9 +43,11 @@ const AppPreviewKeepAlive = () => {
continue;
}
const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
const itemSource = item.draft ? item.draft : item;
const appEnabled = get(itemSource, 'app.enabled', false) === true
&& tab.appPreview !== false;
if (appEnabled) {
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
const code = get(itemSource, 'app.code', '');
out.push({ tabUid: tab.uid, collection, item, kind: 'request', code });
}
}

View File

@@ -100,6 +100,32 @@ describe('AppPreviewKeepAlive', () => {
expect(hiddenSlot).toHaveAttribute('aria-hidden', 'true');
});
it('does not mount request apps when the app is not enabled', () => {
const gatedItem = {
...requestAppItem,
uid: 'item-gated',
app: { enabled: false, code: '<div>hi</div>' }
};
const gatedCollection = { uid: 'coll-1', items: [gatedItem] };
const { store } = makeStore({
tabs: [tabFor(gatedItem)],
activeTabUid: gatedItem.uid,
collections: [gatedCollection]
});
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
});
it('does not mount request apps whose tab has preview explicitly off', () => {
const { store } = makeStore({
tabs: [{ ...tabFor(requestAppItem), appPreview: false }],
activeTabUid: requestAppItem.uid,
collections: [collection]
});
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
});
it('does not mount app tabs that were never activated', () => {
const { store } = makeStore({
tabs: [tabFor(requestAppItem), tabFor(standaloneAppItem)],

View File

@@ -10,10 +10,9 @@ import {
import {
responseReceived,
appSetRuntimeVariable,
toggleAppMode,
initRunRequestEvent
} from 'providers/ReduxStore/slices/collections';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import { updateRequestPaneTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import { useTheme } from 'providers/Theme';
import Button from 'ui/Button';
@@ -286,13 +285,13 @@ const AppView = ({ item, collection, code }) => {
}, [variables, pushToGuest]);
const disableApp = useCallback(() => {
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
}, [dispatch, item.uid, collection.uid]);
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
}, [dispatch, item.uid]);
const goToAppTab = useCallback(() => {
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'app' }));
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
}, [dispatch, item.uid, collection.uid]);
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
}, [dispatch, item.uid]);
const openAppsDocs = useCallback(() => {
window?.ipcRenderer?.openExternal('https://link.usebruno.com/apps');

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.app-toggle-row {
.app-toolbar {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}

View File

@@ -1,13 +1,15 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { IconAppWindow } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor';
import ToggleSwitch from 'components/ToggleSwitch';
import AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { updateAppCode } from 'providers/ReduxStore/slices/collections';
import { setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const AppCodeEditor = ({ item, collection }) => {
@@ -16,13 +18,12 @@ const AppCodeEditor = ({ item, collection }) => {
const preferences = useSelector((state) => state.app.preferences);
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
const enabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
const onEdit = (value) =>
dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid }));
const onToggle = () =>
dispatch(toggleAppMode({ enabled: !enabled, itemUid: item.uid, collectionUid: collection.uid }));
const onPreview = () =>
dispatch(setTabAppPreview({ uid: item.uid, appPreview: true }));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
@@ -33,14 +34,19 @@ const AppCodeEditor = ({ item, collection }) => {
return (
<StyledWrapper className="w-full h-full flex flex-col">
<div className="app-toggle-row mb-3 px-1 pb-3 flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium">Enable App</label>
<p className="text-xs opacity-70">
When enabled, replaces the request/response panes with the app view for this request.
</p>
</div>
<ToggleSwitch isOn={enabled} handleToggle={onToggle} size="xs" data-testid="app-enable-toggle" />
<div className="app-toolbar mb-3 pb-3 flex items-center justify-between gap-4">
<p className="text-xs text-muted min-w-0">
The app view replaces the request/response panes for this request.
</p>
<Button
size="sm"
icon={<IconAppWindow size={14} strokeWidth={1.5} />}
onClick={onPreview}
className="flex-shrink-0"
data-testid="app-preview-btn"
>
Preview
</Button>
</div>
<div className="flex-1 app-editor relative" data-testid="app-code-editor">

View File

@@ -59,7 +59,7 @@ const HttpRequestPane = ({ item, collection }) => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const getProperty = useCallback(
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
(key, defaultValue = []) => (item.draft ? get(item, `draft.${key}`, defaultValue) : get(item, key, defaultValue)),
[item.draft, item]
);
@@ -74,7 +74,10 @@ const HttpRequestPane = ({ item, collection }) => {
const responseVars = getProperty('request.vars.res');
const auth = getProperty('request.auth');
const tags = getProperty('tags');
const app = item.draft ? get(item, 'draft.app') : get(item, 'app');
const app = getProperty('app', null);
const appTabEnabled = app?.enabled === true;
// A previously selected App tab may be restored while apps are disabled in settings.
const effectiveTab = requestPaneTab === 'app' && !appTabEnabled ? 'params' : requestPaneTab;
const activeCounts = useMemo(() => ({
params: params.filter((p) => p.enabled).length,
@@ -116,14 +119,16 @@ const HttpRequestPane = ({ item, collection }) => {
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, tags]);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
[indicators]
() => TAB_CONFIG
.filter(({ key }) => key !== 'app' || appTabEnabled)
.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),
[indicators, appTabEnabled]
);
const tabPanel = useMemo(() => {
const Component = TAB_PANELS[requestPaneTab];
const Component = TAB_PANELS[effectiveTab];
return Component ? <Component key={item.uid} item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
}, [effectiveTab, item, collection]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
@@ -143,7 +148,7 @@ const HttpRequestPane = ({ item, collection }) => {
<div className="flex flex-col h-full relative">
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
activeTab={effectiveTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContent ? rightContentRef : null}

View File

@@ -5,7 +5,8 @@ import { IconTag } from '@tabler/icons';
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
import SettingsInput from 'components/SettingsInput';
import InheritableSettingsInput from 'components/InheritableSettingsInput';
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
import { updateItemSettings, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import Tags from './Tags/index';
@@ -27,6 +28,7 @@ const Settings = ({ item, collection }) => {
const rawSettings = getPropertyFromDraftOrRequest('settings');
const settings = { ...DEFAULT_SETTINGS, ...rawSettings };
const { encodeUrl, followRedirects, maxRedirects, timeout } = settings;
const enableApp = getPropertyFromDraftOrRequest('app.enabled') === true;
// Reusable function to update settings
const updateSetting = useCallback((settingUpdate) => {
@@ -45,6 +47,14 @@ const Settings = ({ item, collection }) => {
const onToggleFollowRedirects = useCallback(() =>
updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]);
const onToggleEnableApp = useCallback(() => {
const next = !enableApp;
dispatch(toggleAppMode({ enabled: next, itemUid: item.uid, collectionUid: collection.uid }));
if (next) {
dispatch(setTabAppPreview({ uid: item.uid, appPreview: false }));
}
}, [enableApp, dispatch, item.uid, collection.uid]);
const onMaxRedirectsChange = useCallback((e) => {
const value = e.target.value;
// Only allow empty string or digits
@@ -131,6 +141,17 @@ const Settings = ({ item, collection }) => {
/>
</div>
<div className="flex flex-col gap-4">
<ToggleSelector
checked={enableApp}
onChange={onToggleEnableApp}
label="Enable App"
description="Show the App tab and app view mode for this request"
size="medium"
data-testid="enable-app-toggle"
/>
</div>
<SettingsInput
id="maxRedirects"
label="Max Redirects"

View File

@@ -578,8 +578,12 @@ const RequestTabPanel = () => {
);
}
const itemSource = item.draft ? item.draft : item;
// Preview state is runtime-only, kept on the tab; unset means "preview on" so
// an app-enabled request opens in preview mode by default.
const appEnabled = item.type !== 'app'
&& (item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false));
&& get(itemSource, 'app.enabled', false) === true
&& focusedTab.appPreview !== false;
if (item.type === 'app' || appEnabled) {
return <StyledWrapper className="flex flex-col flex-grow relative overflow-hidden" data-testid="app-tab-placeholder" />;
}

View File

@@ -24,13 +24,13 @@ import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat';
import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal';
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
import find from 'lodash/find';
import get from 'lodash/get';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { addTab, focusTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown';
@@ -82,13 +82,15 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|| (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null))
: null;
const isHttpRequestActive = activeItem?.type === 'http-request';
const appEnabled = activeItem
? (activeItem.draft ? get(activeItem, 'draft.app.enabled', false) : get(activeItem, 'app.enabled', false))
: false;
const activeItemSource = activeItem ? activeItem.draft || activeItem : null;
// The "Enable App" request setting (persisted as app.enabled) gates the whole
// Request/App/File mode toggle.
const appAvailable = isHttpRequestActive && get(activeItemSource, 'app.enabled', false) === true;
const appEnabled = appAvailable && focusedTab?.appPreview !== false;
const handleToggleAppMode = (enabled) => {
if (isHttpRequestActive) {
dispatch(toggleAppMode({ enabled, itemUid: activeItem.uid, collectionUid: collection.uid }));
dispatch(setTabAppPreview({ uid: focusedTab.uid, appPreview: enabled }));
}
};
@@ -296,9 +298,9 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
// Build overflow menu items for the "..." dropdown
const overflowMenuItems = [
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
// File mode is exposed via the Request/App/File view-mode toggle when a request is active;
// keep it in the overflow as a fallback for non-request contexts.
...(!isHttpRequestActive
// File mode is exposed via the Request/App/File view-mode toggle when the active
// request has apps enabled; keep it in the overflow as a fallback everywhere else.
...(!appAvailable
? [{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }]
: []),
...(!hasOpenApiSyncConfigured
@@ -653,7 +655,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
<div className="flex flex-grow gap-1.5 items-center justify-end">
{!isScratchCollection && (
<>
{isHttpRequestActive && (
{appAvailable && (
<div className="mode-toggle" data-testid="view-mode-toggle">
<ToolHint text="Request" toolhintId="ViewModeRequestToolhintId" place="bottom">
<button

View File

@@ -2997,16 +2997,7 @@ export const collectionsSlice = createSlice({
item.request = mergeRequestWithPreservedUids(item.request, file.data.request);
item.settings = file.data.settings;
item.examples = file.data.examples;
// app.enabled is runtime-only and not persisted, so preserve it across file reloads
// even when the file no longer has an `app` block on disk.
const currentEnabled = item.draft?.app?.enabled ?? item.app?.enabled ?? false;
if (file.data.app) {
item.app = { ...file.data.app, enabled: currentEnabled };
} else if (currentEnabled) {
item.app = { code: null, enabled: true };
} else {
item.app = null;
}
item.app = file.data.app ? { ...file.data.app } : null;
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.raw = file.data.raw;
@@ -3428,12 +3419,11 @@ export const collectionsSlice = createSlice({
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
item.app = item.app || {};
item.app.enabled = action.payload.enabled;
if (item.draft) {
item.draft.app = item.draft.app || {};
item.draft.app.enabled = action.payload.enabled;
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.app = item.draft.app || {};
item.draft.app.enabled = action.payload.enabled;
}
},
appSetRuntimeVariable: (state, action) => {

View File

@@ -507,6 +507,13 @@ export const tabsSlice = createSlice({
tab.tabState = { ...tab.tabState, ...tabState };
}
},
setTabAppPreview: (state, action) => {
const { uid, appPreview } = action.payload;
const tab = find(state.tabs, (t) => t.uid === uid);
if (tab) {
tab.appPreview = appPreview;
}
},
reopenLastClosedTab: (state, action) => {
const collectionUid = action.payload?.collectionUid;
// Find the last closed tab for this collection (LIFO). If no collectionUid is
@@ -558,6 +565,7 @@ export const {
syncTabUid,
restoreTabs,
updateTabState,
setTabAppPreview,
reopenLastClosedTab,
updateQueryBuilderOpen,
updateQueryBuilderWidth,

View File

@@ -724,8 +724,8 @@ export const transformRequestToSaveToFilesystem = (item) => {
}));
};
const appToSave = _item.app && _item.app.code && _item.app.code.length
? { code: _item.app.code }
const appToSave = _item.app && (_item.app.enabled === true || (_item.app.code && _item.app.code.length))
? { code: _item.app.code || null, enabled: _item.app.enabled === true }
: null;
const itemToSave = {

View File

@@ -54,7 +54,9 @@ export const parseBruRequest = (data: string | any, parsed: boolean = false): an
};
const appData = _.get(json, 'app');
const app = appData ? { code: _.get(appData, 'code', null) } : null;
const app = appData
? { code: _.get(appData, 'code', null), enabled: _.get(appData, 'enabled', false) === true }
: null;
const transformedJson = {
type: requestType,
@@ -257,8 +259,8 @@ export const stringifyBruRequest = (json: any): string => {
bruJson.examples = _.get(json, 'examples', []).map((e: any) => jsonExampleToBru(e));
const app = _.get(json, 'app');
if (app && app.code && app.code.length) {
bruJson.app = { code: app.code };
if (app && (app.enabled === true || (app.code && app.code.length))) {
bruJson.app = { code: app.code || null, enabled: app.enabled === true };
}
const bru = jsonToBruV2(bruJson);

View File

@@ -1,18 +1,27 @@
import type { App as BrunoApp } from '@usebruno/schema-types/collection/item';
export interface OpenCollectionApp {
enabled?: boolean;
code?: string;
}
export const toOpenCollectionApp = (app: BrunoApp | null | undefined): OpenCollectionApp | undefined => {
if (!app || !app.code) return undefined;
return { code: app.code };
// The app block persists when enabled even before any code is written.
if (!app || (!app.code && app.enabled !== true)) return undefined;
const ocApp: OpenCollectionApp = {};
if (app.enabled === true) {
ocApp.enabled = true;
}
if (app.code) {
ocApp.code = app.code;
}
return ocApp;
};
export const toBrunoApp = (app: OpenCollectionApp | null | undefined): BrunoApp | null => {
if (!app) return null;
return {
enabled: false,
enabled: app.enabled === true,
code: app.code || null
};
};

View File

@@ -338,6 +338,9 @@ const createGetNumFromRecord = (obj) => (key, { fallback } = {}) => {
return asNumber;
};
// Dictionary values arrive as strings; booleans may already be coerced upstream.
const toBool = (value) => (typeof value === 'boolean' ? value : value === 'true');
// Parse example content using dedicated example parser
const parseExampleContent = (content) => {
try {
@@ -538,7 +541,8 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const appData = mapPairListToKeyValPair(dictionary.ast);
return {
app: {
code: appData.code || null
code: appData.code || null,
enabled: toBool(appData.enabled)
}
};
},
@@ -550,7 +554,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const parsedSettings = {};
if (settings.followRedirects !== undefined) {
parsedSettings.followRedirects = typeof settings.followRedirects === 'boolean' ? settings.followRedirects : settings.followRedirects === 'true';
parsedSettings.followRedirects = toBool(settings.followRedirects);
}
// Parse maxRedirects as number
@@ -574,7 +578,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
const _settings = {
encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true',
encodeUrl: toBool(settings.encodeUrl),
timeout: parsedSettings.timeout !== undefined ? parsedSettings.timeout : 0
};

View File

@@ -770,10 +770,15 @@ ${indentString(tests)}
`;
}
if (app && app.code && app.code.length) {
if (app && (app.enabled === true || (app.code && app.code.length))) {
bru += `app {\n`;
bru += ` code: '''\n${indentString(app.code, 2)}\n '''`;
bru += '\n}\n\n';
if (app.enabled === true) {
bru += ` enabled: true\n`;
}
if (app.code && app.code.length) {
bru += ` code: '''\n${indentString(app.code, 2)}\n '''\n`;
}
bru += '}\n\n';
}
if (settings && Object.keys(settings).length) {

View File

@@ -0,0 +1,49 @@
const bruToJson = require('../src/bruToJson');
const jsonToBru = require('../src/jsonToBru');
describe('app block', () => {
const appBruEnabled = `app {
enabled: true
code: '''
<div>hello</div>
'''
}
`;
const appBruDisabled = `app {
code: '''
<div>hello</div>
'''
}
`;
it('should parse an app block without enabled as disabled', () => {
const output = bruToJson(appBruDisabled);
expect(output.app).toEqual({ code: '<div>hello</div>', enabled: false });
});
it('should parse enabled: true', () => {
const output = bruToJson(appBruEnabled);
expect(output.app).toEqual({ code: '<div>hello</div>', enabled: true });
});
it('should stringify enabled only when enabled', () => {
expect(jsonToBru({ app: { code: '<div>hello</div>', enabled: true } })).toEqual(appBruEnabled);
expect(jsonToBru({ app: { code: '<div>hello</div>', enabled: false } })).toEqual(appBruDisabled);
});
it('should round-trip the enabled flag', () => {
expect(bruToJson(jsonToBru({ app: { code: '<x/>', enabled: true } })).app.enabled).toBe(true);
expect(bruToJson(jsonToBru({ app: { code: '<x/>', enabled: false } })).app.enabled).toBe(false);
});
it('should serialize an enabled app block even without code', () => {
const bru = jsonToBru({ app: { code: null, enabled: true } });
expect(bru).toEqual('app {\n enabled: true\n}\n');
expect(bruToJson(bru).app).toEqual({ code: null, enabled: true });
});
it('should omit the app block when disabled and without code', () => {
expect(jsonToBru({ app: { code: null, enabled: false } })).toEqual('');
});
});

View File

@@ -677,7 +677,7 @@ const itemSchema = Yup.object({
encodeUrl: Yup.boolean().nullable(),
followRedirects: Yup.boolean().nullable(),
maxRedirects: Yup.number().min(0).max(50).nullable(),
timeout: Yup.mixed().nullable(),
timeout: Yup.mixed().nullable()
}).noUnknown(true)
.strict()
.nullable()
@@ -702,7 +702,8 @@ const itemSchema = Yup.object({
otherwise: Yup.array().strip()
}),
app: Yup.object({
code: Yup.string().nullable()
code: Yup.string().nullable(),
enabled: Yup.boolean().nullable()
})
.noUnknown(true)
.nullable(),