Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
4 changes: 4 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,8 @@ boxui.metadataInstanceEditor.editTooltip = Edit Metadata
boxui.metadataInstanceEditor.enableAIAutofill = Box AI Autofill
# Label for enable cascade policy toggle switch
boxui.metadataInstanceEditor.enableCascadePolicy = Enable Cascade Policy
# Name of the enhanced AI agent
boxui.metadataInstanceEditor.enhancedAgentName = Enhanced
# Message for users who may attempt to remove a custom metadata instance for a file. Also non-recoverable
boxui.metadataInstanceEditor.fileMetadataRemoveCustomTemplateConfirm = Are you sure you want to delete this custom metadata and all of its values from this file?
# Message for users who may attempt to remove a metadata instance for a file, which is non-recoverable
Expand All @@ -1288,6 +1290,8 @@ boxui.metadataInstanceEditor.noMetadataAddTemplate = Click 'Add' in the top righ
boxui.metadataInstanceEditor.operationNotImmediate = This operation is not immediate and may take some time.
# Label to remove a template
boxui.metadataInstanceEditor.removeTemplate = Remove
# Name of the standard AI agent
boxui.metadataInstanceEditor.standardAgentName = Standard
# Label to add a template
boxui.metadataInstanceEditor.templateAdd = Add
# Placeholder to search for all templates
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
"@babel/types": "^7.24.7",
"@box/blueprint-web": "^12.7.1",
"@box/blueprint-web-assets": "^4.60.0",
"@box/box-ai-agent-selector": "^0.41.10",
"@box/box-ai-agent-selector": "^0.48.5",
"@box/box-ai-content-answers": "^0.124.1",
"@box/cldr-data": "^34.2.0",
"@box/combobox-with-api": "^0.34.9",
Expand Down Expand Up @@ -289,7 +289,7 @@
"peerDependencies": {
"@box/blueprint-web": "^12.7.1",
"@box/blueprint-web-assets": "^4.60.0",
"@box/box-ai-agent-selector": "^0.41.10",
"@box/box-ai-agent-selector": "^0.48.5",
"@box/box-ai-content-answers": "^0.124.1",
"@box/cldr-data": ">=34.2.0",
"@box/combobox-with-api": "^0.34.9",
Expand Down
6 changes: 6 additions & 0 deletions src/common/types/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,15 @@ type MetadataCascadePolicy = {
cascadePolicyType?: string,
};

type MetadataCascadePolicyConfiguration = {
agent: string,
};

type MetadataCascadingPolicyData = {
id?: string,
isEnabled: boolean,
overwrite: boolean,
cascadePolicyConfiguration?: MetadataCascadePolicyConfiguration | null,
};

type MetadataInstance = {
Expand Down Expand Up @@ -187,6 +192,7 @@ export type {
MetadataQueryInstanceTypeField,
MetadataType,
MetadataCascadePolicy,
MetadataCascadePolicyConfiguration,
MetadataCascadingPolicyData,
MetadataInstanceV2,
MetadataEditor,
Expand Down
75 changes: 51 additions & 24 deletions src/features/metadata-instance-editor/CascadePolicy.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
// @flow
import * as React from 'react';
import { FormattedMessage, useIntl } from 'react-intl';

import { InlineNotice } from '@box/blueprint-web';
import { BoxAiAgentSelector } from '@box/box-ai-agent-selector';
import { useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';

// $FlowFixMe
import BoxAiLogo from '@box/blueprint-web-assets/icons/Logo/BoxAiLogo';
import { BoxAiAdvancedColor, BoxAiColor } from '@box/blueprint-web-assets/icons/Medium';
import { type AgentType } from '@box/box-ai-agent-selector';

// $FlowFixMe
import { BoxAiAgentSelectorWithApiContainer } from '@box/box-ai-agent-selector';

import Toggle from '../../components/toggle';
import { RadioButton, RadioGroup } from '../../components/radio';
Expand All @@ -17,19 +22,6 @@ import './CascadePolicy.scss';
const COMMUNITY_LINK = 'https://support.box.com/hc/en-us/articles/360044195873-Cascading-metadata-in-folders';
const AI_LINK = 'https://www.box.com/ai';

const agents = [
{
id: '1',
name: 'Basic',
isEnterpriseDefault: true,
},
{
id: '2',
name: 'Enhanced (Gemini 2.5 Pro)',
isEnterpriseDefault: false,
},
];

type Props = {
canEdit: boolean,
canUseAIFolderExtraction: boolean,
Expand All @@ -40,6 +32,7 @@ type Props = {
isCustomMetadata: boolean,
isExistingCascadePolicy: boolean,
onAIFolderExtractionToggle: (value: boolean) => void,
onAIAgentSelect?: (agent: AgentType | null) => void,
onCascadeModeChange: (value: boolean) => void,
onCascadeToggle: (value: boolean) => void,
shouldShowCascadeOptions: boolean,
Expand All @@ -55,6 +48,7 @@ const CascadePolicy = ({
isAIFolderExtractionEnabled,
isExistingCascadePolicy,
onAIFolderExtractionToggle,
onAIAgentSelect,
onCascadeToggle,
onCascadeModeChange,
shouldShowCascadeOptions,
Expand All @@ -67,6 +61,39 @@ const CascadePolicy = ({
</div>
) : null;

const agents = React.useMemo(
() => [
{
id: '1',
name: formatMessage(messages.standardAgentName),
isEnterpriseDefault: true,
},
{
id: '2',
name: formatMessage(messages.enhancedAgentName),
isEnterpriseDefault: false,
customIcon: BoxAiAdvancedColor,
},
],
[formatMessage],
);

// BoxAiAgentSelectorWithApiContainer expects a function that returns a Promise<AgentListResponse>
// Since we're passing in our own agents, we don't need to make an API call,
// so we wrap the store data in a Promise to satisfy the component's interface requirements.
const agentFetcher = useCallback(() => {
return Promise.resolve({ agents });
}, [agents]);

const handleAgentSelect = useCallback(
(agent: AgentType | null) => {
if (onAIAgentSelect) {
onAIAgentSelect(agent);
}
},
[onAIAgentSelect],
);

return canEdit ? (
<>
{isExistingCascadePolicy && (
Expand All @@ -83,6 +110,7 @@ const CascadePolicy = ({
<FormattedMessage tagName="strong" {...messages.enableCascadePolicy} />
{!isCustomMetadata && (
<Toggle
aria-label={formatMessage(messages.enableCascadePolicy)}
className={`metadata-cascade-toggle ${
isCascadingEnabled ? 'cascade-on' : 'cascade-off'
}`}
Expand Down Expand Up @@ -141,9 +169,10 @@ const CascadePolicy = ({
<div className="metadata-cascade-editor" data-testid="ai-folder-extraction">
<div className="metadata-cascade-enable">
<div>
<BoxAiLogo className="metadata-cascade-ai-logo" width={16} height={16} />
<BoxAiColor className="metadata-cascade-ai-logo" width={16} height={16} />
<FormattedMessage tagName="strong" {...messages.enableAIAutofill} />
<Toggle
aria-label={formatMessage(messages.enableAIAutofill)}
className="metadata-cascade-toggle"
isOn={isAIFolderExtractionEnabled}
isDisabled={isExistingCascadePolicy}
Expand All @@ -158,15 +187,13 @@ const CascadePolicy = ({
<FormattedMessage {...messages.aiAutofillLearnMore} />
</Link>
</div>
{canUseAIFolderExtractionAgentSelector && (
{canUseAIFolderExtractionAgentSelector && isAIFolderExtractionEnabled && (
<div className="metadata-cascade-ai-agent-selector">
<BoxAiAgentSelector
agents={agents}
<BoxAiAgentSelectorWithApiContainer
disabled={isExistingCascadePolicy}
onErrorAction={() => {}}
requestState="success"
selectedAgent={agents[0]}
variant="sidebar"
fetcher={agentFetcher}
onSelectAgent={handleAgentSelect}
recordAction={() => {}}
/>
</div>
)}
Expand Down
27 changes: 26 additions & 1 deletion src/features/metadata-instance-editor/Instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual';
import cloneDeep from 'lodash/cloneDeep';
import noop from 'lodash/noop';

import type { AgentType } from '@box/box-ai-agent-selector';
import Collapsible from '../../components/collapsible/Collapsible';
import Form from '../../components/form-elements/form/Form';
import LoadingIndicatorWrapper from '../../components/loading-indicator/LoadingIndicatorWrapper';
Expand All @@ -24,7 +25,7 @@ import MetadataInstanceConfirmDialog from './MetadataInstanceConfirmDialog';
import Footer from './Footer';
import messages from './messages';
import { FIELD_TYPE_FLOAT, FIELD_TYPE_INTEGER } from '../metadata-instance-fields/constants';
import { CASCADE_POLICY_TYPE_AI_EXTRACT, TEMPLATE_CUSTOM_PROPERTIES } from './constants';
import { CASCADE_POLICY_TYPE_AI_EXTRACT, TEMPLATE_CUSTOM_PROPERTIES, ENHANCED_AGENT_CONFIGURATION } from './constants';
import {
JSON_PATCH_OP_REMOVE,
JSON_PATCH_OP_ADD,
Expand All @@ -38,6 +39,7 @@ import type {
MetadataFields,
MetadataTemplate,
MetadataCascadePolicy,
MetadataCascadePolicyConfiguration,
MetadataCascadingPolicyData,
MetadataTemplateField,
MetadataFieldValue,
Expand Down Expand Up @@ -72,6 +74,7 @@ type State = {
data: Object,
errors: { [string]: React.Node },
isAIFolderExtractionEnabled: boolean,
cascadePolicyConfiguration?: MetadataCascadePolicyConfiguration | null,
isBusy: boolean,
isCascadingEnabled: boolean,
isCascadingOverwritten: boolean,
Expand Down Expand Up @@ -217,6 +220,7 @@ class Instance extends React.PureComponent<Props, State> {
isAIFolderExtractionEnabled,
isCascadingEnabled,
isCascadingOverwritten,
cascadePolicyConfiguration,
}: State = this.state;

if (!this.isEditing() || !isDirty || !onSave || Object.keys(errors).length) {
Expand All @@ -229,6 +233,7 @@ class Instance extends React.PureComponent<Props, State> {
// reset state if cascading policy is removed
isAIFolderExtractionEnabled: isCascadingEnabled ? isAIFolderExtractionEnabled : false,
});

onSave(
id,
this.createJSONPatch(currentData, originalData),
Expand All @@ -239,6 +244,7 @@ class Instance extends React.PureComponent<Props, State> {
isEnabled: isCascadingEnabled,
overwrite: isCascadingOverwritten,
isAIFolderExtractionEnabled,
cascadePolicyConfiguration,
}
: undefined,
cloneDeep(currentData),
Expand Down Expand Up @@ -342,6 +348,23 @@ class Instance extends React.PureComponent<Props, State> {
this.setState({ isAIFolderExtractionEnabled: value }, this.setDirty);
};

/**
* Handles the selection of an AI agent
* @param {AgentType | null} agent - The selected agent
*/
onAIAgentSelect = (agent: AgentType | null): void => {
// '2' is the id for the enhanced agent
if (agent && agent.id === '2') {
this.setState({
cascadePolicyConfiguration: {
agent: ENHANCED_AGENT_CONFIGURATION,
},
});
} else {
this.setState({ cascadePolicyConfiguration: null });
}
};

/**
* Returns the state from props
*
Expand All @@ -354,6 +377,7 @@ class Instance extends React.PureComponent<Props, State> {
data: cloneDeep(props.data),
errors: {},
isAIFolderExtractionEnabled: this.isAIFolderExtractionEnabledThroughProps(props),
cascadePolicyConfiguration: null,
isBusy: false,
isCascadingEnabled,
isCascadingOverwritten: false,
Expand Down Expand Up @@ -684,6 +708,7 @@ class Instance extends React.PureComponent<Props, State> {
onAIFolderExtractionToggle={this.onAIFolderExtractionToggle}
onCascadeModeChange={this.onCascadeModeChange}
onCascadeToggle={this.onCascadeToggle}
onAIAgentSelect={this.onAIAgentSelect}
shouldShowCascadeOptions={shouldShowCascadeOptions}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,68 @@ describe('features/metadata-instance-editor/CascadePolicy', () => {
});

describe('AI Agent Selector', () => {
test('should render AI agent selector with default to basic when AI features are enabled', () => {
test('should render AI agent selector with default to basic when AI features are enabled', async () => {
render(
<CascadePolicy
canEdit
canUseAIFolderExtraction
canUseAIFolderExtractionAgentSelector
shouldShowCascadeOptions
isAIFolderExtractionEnabled
onAIFolderExtractionToggle={jest.fn()}
/>,
);
expect(screen.getByRole('combobox', { name: 'Basic' })).toBeInTheDocument();

const aiToggle = screen.getByRole('switch', { name: 'Box AI Autofill' });
await userEvent.click(aiToggle); // Enable AI

expect(aiToggle).toBeChecked();

expect(screen.getByRole('combobox', { name: 'Standard' })).toBeInTheDocument();
});

test('should not render AI agent selector when canUseAIFolderExtractionAgentSelector is false', () => {
render(<CascadePolicy canEdit canUseAIFolderExtraction shouldShowCascadeOptions />);
expect(screen.queryByRole('combobox', { name: 'Basic' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: 'Standard' })).not.toBeInTheDocument();
});

test('should call onAIAgentSelect when an agent is selected', async () => {
const onAIAgentSelect = jest.fn();
render(
<CascadePolicy
canEdit
canUseAIFolderExtraction
canUseAIFolderExtractionAgentSelector
shouldShowCascadeOptions
isAIFolderExtractionEnabled
onAIAgentSelect={onAIAgentSelect}
onAIFolderExtractionToggle={jest.fn()}
/>,
);

const aiToggle = screen.getByRole('switch', { name: 'Box AI Autofill' });
await userEvent.click(aiToggle); // Enable AI

expect(aiToggle).toBeChecked();

// Find the combobox by its accessible name
const combobox = screen.getByRole('combobox', { name: 'Standard' });

// Open the combobox (simulate user click)
await userEvent.click(combobox);

// Find the option for "Advanced" and select it
const option = await screen.findByRole('option', { name: 'Enhanced' });
await userEvent.click(option);

// The expected agent object (should match the one in CascadePolicy.js)
const expectedAgent = {
id: '1',
name: 'Standard',
isEnterpriseDefault: true,
};

expect(onAIAgentSelect).toHaveBeenCalledWith(expectedAgent);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,12 +863,30 @@ describe('Instance Component - React Testing Library', () => {

describe('Props passed to CascadePolicy', () => {
test('should pass canUseAIFolderExtractionAgentSelector to CascadePolicy', async () => {
render(<Instance {...getBaseProps({ canUseAIFolderExtractionAgentSelector: true })} />);
render(
<Instance
{...getBaseProps({
canUseAIFolderExtractionAgentSelector: true,
cascadePolicy: {
id: 'policy-1',
canEdit: true,
isEnabled: true,
cascadePolicyType: CASCADE_POLICY_TYPE_AI_EXTRACT,
},
})}
/>,
);

const editButton = screen.queryByRole('button', { name: 'Edit Metadata' });
if (editButton) await userEvent.click(editButton); // Enter edit mode to ensure CascadePolicy options are visible

expect(screen.getByRole('combobox', { name: 'Basic' })).toBeInTheDocument();
const cascadeToggle = screen.getByRole('switch', { name: 'Enable Cascade Policy' });
expect(cascadeToggle).toBeChecked();

const aiToggle = screen.getByRole('switch', { name: 'Box AI Autofill' });
expect(aiToggle).toBeChecked();

expect(screen.getByRole('combobox', { name: 'Standard' })).toBeInTheDocument();
});

test('should disable CascadePolicy options when a cascade already exists', async () => {
Expand Down
Loading