diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b3c828308059..d7c65c8e65b6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -649,6 +649,9 @@ const CONST = { GENERATED_RULE: { PROMPT_MAX_LENGTH: 6000, + + // The slowest answers in the eval take about 13 seconds. After this time, the agent rule saves without an answer. + AGENT_RULE_CHECK_TIMEOUT_MS: 20000, STATE: { RULE: 'rule', UNSUPPORTED: 'unsupported', diff --git a/src/languages/de.ts b/src/languages/de.ts index 5806be5b59ad..36e73f2cae8a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9317,6 +9317,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc describeRuleForConcierge: 'Beschreiben Sie Ihre Regel und Concierge erstellt sie', gotIt: 'Verstanden', createRule: 'Regel erstellen', + saveAnyway: 'Trotzdem speichern', + checkingRule: 'Ihre Regel wird geprüft...', fallbackBanner: 'Dies ist keine Standardregel, daher haben wir eine Agentenregel erstellt. Agentenregeln werden ausgeführt, wenn eine Ausgabe oder ein Bericht erstellt oder aktualisiert wird.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/el.ts b/src/languages/el.ts index 6b209fa6600e..dd578f273f68 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9766,6 +9766,8 @@ ${reportName}`, describeRuleHeadline: 'Περιγράψτε τον κανόνα σας', describeRuleForConcierge: 'Περιγράψτε τον κανόνα σας και το Concierge θα τον δημιουργήσει', createRule: 'Δημιουργία κανόνα', + saveAnyway: 'Αποθήκευση ούτως ή άλλως', + checkingRule: 'Γίνεται έλεγχος του κανόνα σας...', fallbackBanner: 'Αυτός δεν είναι τυπικός κανόνας, οπότε συντάξαμε έναν κανόνα πράκτορα. Οι κανόνες πράκτορα εκτελούνται όταν δημιουργείται ή ενημερώνεται ένα έξοδο ή μια αναφορά.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/en.ts b/src/languages/en.ts index 4d5422bd4b07..1eb5674af28d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10229,6 +10229,8 @@ const translations = { describeRuleForConcierge: 'Describe your rule and Concierge will build it', disclaimer: 'AI agents can make mistakes.', createRule: 'Create rule', + saveAnyway: 'Save anyway', + checkingRule: 'Checking your rule...', fallbackBanner: "This isn't a standard rule, so we drafted an agent rule. Agent rules run when an expense or report is created or updated.", fallbackBannerWithExplanation: (explanation: string) => `This isn't a standard rule, so we drafted an agent rule. Agent rules run when an expense or report is created or updated. ${explanation}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 6f48e854d409..b68e61fb2899 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9311,6 +9311,8 @@ ${reportName}`, describeRuleForConcierge: 'Describe tu regla y Concierge la creará', gotIt: 'Entendido', createRule: 'Crear regla', + saveAnyway: 'Guardar de todos modos', + checkingRule: 'Comprobando tu regla...', fallbackBanner: 'Esta no es una regla estándar, así que redactamos una regla de agente. Las reglas de agente se ejecutan cuando se crea o actualiza un gasto o un informe.', fallbackBannerWithExplanation: (explanation: string) => `Esta no es una regla estándar, así que redactamos una regla de agente. Las reglas de agente se ejecutan cuando se crea o actualiza un gasto o un informe. ${explanation}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6d658d4b6412..6a746104c9e3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9343,6 +9343,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e describeRuleForConcierge: 'Décrivez votre règle et Concierge la créera', gotIt: 'Compris', createRule: 'Créer une règle', + saveAnyway: 'Enregistrer quand même', + checkingRule: 'Vérification de votre règle...', fallbackBanner: 'Ce n’est pas une règle standard, nous avons donc rédigé une règle d’agent. Les règles d’agent s’exécutent lorsqu’une dépense ou une note de frais est créée ou mise à jour.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/it.ts b/src/languages/it.ts index 5d8b966b6df3..ea1c109e0a57 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9262,6 +9262,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, describeRuleForConcierge: 'Descrivi la tua regola e Concierge la creerà', gotIt: 'Capito', createRule: 'Crea regola', + saveAnyway: 'Salva comunque', + checkingRule: 'Verifica della tua regola in corso...', fallbackBanner: 'Questa non è una regola standard, quindi abbiamo creato una regola agente. Le regole agente vengono eseguite quando una spesa o un report viene creato o aggiornato.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cb1b10be8505..ab578d781744 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9127,6 +9127,8 @@ ${reportName}`, describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', createRule: 'ルールを作成', + saveAnyway: 'このまま保存', + checkingRule: 'ルールを確認しています...', fallbackBanner: 'これは標準ルールではないため、エージェントルールを作成しました。エージェントルールは、経費またはレポートが作成または更新されたときに実行されます。', fallbackBannerWithExplanation: (explanation: string) => `これは標準ルールではないため、エージェントルールを作成しました。エージェントルールは、経費またはレポートが作成または更新されたときに実行されます。${explanation}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 61ea5bc4fc23..5f649158bace 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9235,6 +9235,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, describeRuleForConcierge: 'Beschrijf je regel en Concierge bouwt hem voor je', gotIt: 'Begrepen', createRule: 'Regel maken', + saveAnyway: 'Toch opslaan', + checkingRule: 'Je regel wordt gecontroleerd...', fallbackBanner: 'Dit is geen standaardregel, dus hebben we een agentregel opgesteld. Agentregels worden uitgevoerd wanneer een declaratie of rapport wordt aangemaakt of bijgewerkt.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3834762a3543..9da0ea152b74 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9269,6 +9269,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, describeRuleForConcierge: 'Opisz swoją regułę, a Concierge ją utworzy', gotIt: 'Jasne', createRule: 'Utwórz regułę', + saveAnyway: 'Zapisz mimo to', + checkingRule: 'Sprawdzanie reguły...', fallbackBanner: 'To nie jest standardowa reguła, więc przygotowaliśmy regułę agenta. Reguły agenta są uruchamiane, gdy wydatek lub raport zostanie utworzony albo zaktualizowany.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b73e9845ab8b..f94cf4f1cf95 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9254,6 +9254,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, describeRuleForConcierge: 'Descreva sua regra e o Concierge vai criá-la', gotIt: 'Entendi', createRule: 'Criar regra', + saveAnyway: 'Salvar mesmo assim', + checkingRule: 'Verificando sua regra...', fallbackBanner: 'Esta não é uma regra padrão, então criamos uma regra de agente. As regras de agente são executadas quando uma despesa ou um relatório é criado ou atualizado.', fallbackBannerWithExplanation: (explanation: string) => diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8365b62afa9d..40774ce6ef0e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8911,6 +8911,8 @@ ${reportName}`, describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', createRule: '创建规则', + saveAnyway: '仍然保存', + checkingRule: '正在检查你的规则...', fallbackBanner: '这不是一条标准规则,所以我们起草了一条代理规则。代理规则会在报销或报表被创建或更新时运行。', fallbackBannerWithExplanation: (explanation: string) => `这不是一条标准规则,所以我们起草了一条代理规则。代理规则会在报销或报表被创建或更新时运行。${explanation}`, }, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 6cfb4e481b51..0c4865166aa9 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -369,6 +369,45 @@ function generateRule(policyID: string, prompt: string, ruleType?: GeneratedRule return generationID; } +type AgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM | typeof ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM; + +/** + * Asks Concierge whether RuleBot can carry out an agent rule before the rule saves. The agent rule form shows its loading + * state until the answer arrives under `ONYXKEYS.GENERATED_RULE`. A failed request gets a `failed` answer, so the rule + * still saves. + * @returns the generationID the answer carries + */ +function checkAgentRulePrompt(formID: AgentRuleFormID, policyID: string, prompt: string): string { + const generationID = rand64(); + type CheckAgentRuleKey = AgentRuleFormID | typeof ONYXKEYS.GENERATED_RULE; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: formID, + value: {isLoading: true}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.GENERATED_RULE, + value: {generationID, state: CONST.GENERATED_RULE.STATE.FAILED}, + }, + ]; + + const parameters: GenerateRuleParams = {policyID, generationID, prompt}; + + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, failureData}); + + return generationID; +} + +/** Stops the loading state of an agent rule form */ +function stopAgentRuleCheckLoading(formID: AgentRuleFormID) { + Onyx.merge(formID, {isLoading: false}); +} + /** Shows on the prompt form why a description did not become a rule */ function setNewRulePromptError(state: GeneratedRuleState) { setNewRulePromptErrorMessage(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'); @@ -682,9 +721,11 @@ export { clearGeneratedRule, clearNewRulePrompt, stopNewRulePromptLoading, + checkAgentRulePrompt, + stopAgentRuleCheckLoading, updatePolicyAgentRule, deletePolicyAgentRule, clearMerchantRuleErrors, clearPolicyAgentRuleErrors, }; -export type {ImportedMerchantRule}; +export type {AgentRuleFormID, ImportedMerchantRule}; diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index 2674e85ed312..fc89d85dcd3e 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -79,8 +79,19 @@ function AddAgentRulePage({ const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); const [selectedSuggestion, setSelectedSuggestion] = useState(); + + // A new suggestion replaces the text of the Write tab. Mount a new Write tab, so that a check of the old text stops + // and cannot save it. + const [suggestionPickCount, setSuggestionPickCount] = useState(0); const isDraftFromFallback = !!generationID && !selectedSuggestion; - const fallbackExplanation = generatedRule?.generationID === generationID ? generatedRule?.explanation : undefined; + const fallbackAnswer = generatedRule?.generationID === generationID ? generatedRule : undefined; + const fallbackExplanation = fallbackAnswer?.explanation; + + // A suggestion or an agent rule that Concierge wrote saves without a check while the admin leaves its text unchanged. + // The answer keeps the agent rule of Concierge after a reload. The draft can hold changes of the admin, and no other + // value keeps the description that the admin sent. Therefore only the agent rule of the answer counts as checked text. + const generatedAgentRulePrompt = fallbackAnswer?.state === CONST.GENERATED_RULE.STATE.AGENT_RULE ? fallbackAnswer.agentRulePrompt : undefined; + const checkedPrompt = selectedSuggestion?.prompt ?? generatedAgentRulePrompt; // The draft can load after the first render. Open Write one time only, because the admin must still be able to go // to Suggestions. @@ -132,6 +143,7 @@ function AddAgentRulePage({ } setDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {[INPUT_IDS.PROMPT]: suggestion.prompt}); setSelectedSuggestion(suggestion); + setSuggestionPickCount((count) => count + 1); setActiveTab(CONST.TAB.AGENT_RULE.WRITE); }; @@ -254,25 +266,30 @@ function AddAgentRulePage({ onSelectSuggestion={selectSuggestion} /> - {activeTab === CONST.TAB.AGENT_RULE.WRITE && ( - <> - {isDraftFromFallback && ( - - )} - - - )} + {/* The Write tab stays mounted on Suggestions, so that a running check and its answer stay with the admin's text */} + + {isDraftFromFallback && ( + + )} + + diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx index afb99b1578a8..bd40d08055db 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRuleWriteTab.tsx @@ -10,6 +10,9 @@ import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import ImpossibleRuleNotice from '@pages/workspace/rules/ImpossibleRuleNotice'; +import useSuggestionButtonRow from '@pages/workspace/rules/useSuggestionButtonRow'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; @@ -20,18 +23,35 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import useAgentPromptInputStyles from './useAgentPromptInputStyles'; +import useAgentRulePromptCheck from './useAgentRulePromptCheck'; type AddAgentRuleFormID = typeof ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM; type AddAgentRuleWriteTabProps = { + policyID: string; + + /** A rule with this text saves without a check, because Concierge already wrote it */ + checkedPrompt?: string; + + /** The tab stays mounted while hidden, and Enter must not submit a hidden form */ + isTabActive: boolean; + /** Called with the form values when the user saves the rule */ onSave: (values: FormOnyxValues) => void; }; -function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { +function AddAgentRuleWriteTab({policyID, checkedPrompt, isTabActive, onSave}: AddAgentRuleWriteTabProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const formRef = useRef(null); + const {refusal, isChecking, checkThenSavePrompt, trySuggestedPrompt, resetCheck} = useAgentRulePromptCheck({ + policyID, + formID: ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, + formRef, + checkedPrompt, + savePrompt: (prompt) => onSave({[INPUT_IDS.PROMPT]: prompt}), + }); + const suggestionButtonRow = useSuggestionButtonRow(refusal?.suggestedPrompt, trySuggestedPrompt); const describeRuleLabel = translate('workspace.rules.agentRules.describeRuleForConcierge'); const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { @@ -60,13 +80,18 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { ref={formRef} formID={ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM} validate={validate} - onSubmit={onSave} - submitButtonText={translate('workspace.rules.agentRules.createRule')} + onSubmit={(values) => checkThenSavePrompt(values[INPUT_IDS.PROMPT])} + submitButtonText={translate(refusal ? 'workspace.rules.newRule.impossible.createAnyway' : 'workspace.rules.agentRules.createRule')} style={[styles.flex1, styles.ph5]} submitFlexEnabled={false} shouldUseScrollView={false} enabledWhenOffline + disablePressOnEnter={!isTabActive} shouldHideFixErrorsAlert + isSubmitButtonNeutral={suggestionButtonRow.isSubmitButtonNeutral} + submitButtonAndFooterContainerStyles={suggestionButtonRow.containerStyles} + submitButtonInnerStyles={suggestionButtonRow.submitButtonStyles} + footerContent={suggestionButtonRow.footerContent} shouldValidateOnChange shouldValidateOnBlur keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} @@ -82,6 +107,7 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { type="markdown" excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} + onValueChange={resetCheck} multiline shouldSaveDraft shouldLabelStayOnSingleLine @@ -90,7 +116,10 @@ function AddAgentRuleWriteTab({onSave}: AddAgentRuleWriteTabProps) { inputStyle={[styles.flex1, styles.textAlignVerticalTop]} /> - {translate('workspace.rules.agentRules.disclaimer')} + {!!refusal && } + + {translate(isChecking ? 'workspace.rules.agentRules.checkingRule' : 'workspace.rules.agentRules.disclaimer')} + ); diff --git a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx index 55480fd16710..969aae961582 100644 --- a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx @@ -18,7 +18,9 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import ImpossibleRuleNotice from '@pages/workspace/rules/ImpossibleRuleNotice'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; +import useSuggestionButtonRow from '@pages/workspace/rules/useSuggestionButtonRow'; import {deletePolicyAgentRule, updatePolicyAgentRule} from '@userActions/Policy/Rules'; @@ -33,6 +35,7 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import useAgentPromptInputStyles from './useAgentPromptInputStyles'; +import useAgentRulePromptCheck from './useAgentRulePromptCheck'; import useShouldUseExpandedRevampFormLayout from './useShouldUseExpandedRevampFormLayout'; type EditAgentRulePageProps = PlatformStackScreenProps; @@ -70,8 +73,7 @@ function EditAgentRulePage({ return errors; }; - const saveRule = (values: FormOnyxValues): void => { - const newPrompt = values[INPUT_IDS.PROMPT]; + const saveRule = (newPrompt: string): void => { const previousPrompt = agentRule?.prompt ?? ''; if (newPrompt !== previousPrompt) { updatePolicyAgentRule(policyID, ruleID, newPrompt, previousPrompt, agentRule?.title); @@ -79,10 +81,20 @@ function EditAgentRulePage({ Navigation.goBack(); }; + const {refusal, isChecking, checkThenSavePrompt, trySuggestedPrompt, resetCheck} = useAgentRulePromptCheck({ + policyID, + formID: ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM, + formRef, + checkedPrompt: agentRule?.prompt, + savePrompt: saveRule, + }); + const suggestionButtonRow = useSuggestionButtonRow(refusal?.suggestedPrompt, trySuggestedPrompt); + const inputWrapperStyles = useAgentPromptInputStyles(); + // The save at the end of the check goes back one page. If the delete confirmation is open at that time, the delete goes back one more page. const {deleteIconButtonProps} = useRuleDeleteHeaderProps({ - canDelete: !!policy && !!agentRule && agentRule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + canDelete: !!policy && !!agentRule && agentRule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isChecking, onDelete: () => { if (!policy) { return false; @@ -120,13 +132,17 @@ function EditAgentRulePage({ ref={formRef} formID={ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM} validate={validate} - onSubmit={saveRule} - submitButtonText={translate('common.save')} + onSubmit={(values) => checkThenSavePrompt(values[INPUT_IDS.PROMPT])} + submitButtonText={translate(refusal ? 'workspace.rules.agentRules.saveAnyway' : 'common.save')} style={[styles.flex1, styles.ph5]} shouldUseScrollView={false} submitFlexEnabled={false} enabledWhenOffline shouldHideFixErrorsAlert + isSubmitButtonNeutral={suggestionButtonRow.isSubmitButtonNeutral} + submitButtonAndFooterContainerStyles={suggestionButtonRow.containerStyles} + submitButtonInnerStyles={suggestionButtonRow.submitButtonStyles} + footerContent={suggestionButtonRow.footerContent} shouldValidateOnChange shouldValidateOnBlur keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} @@ -142,6 +158,7 @@ function EditAgentRulePage({ type="markdown" excludedMarkdownStyles={['mentionReport']} onKeyPress={submitFormOnModEnter} + onValueChange={resetCheck} defaultValue={agentRule.prompt} multiline shouldLabelStayOnSingleLine @@ -150,7 +167,10 @@ function EditAgentRulePage({ inputStyle={[styles.flex1, styles.textAlignVerticalTop]} /> - {translate('workspace.rules.agentRules.disclaimer')} + {!!refusal && } + + {translate(isChecking ? 'workspace.rules.agentRules.checkingRule' : 'workspace.rules.agentRules.disclaimer')} + diff --git a/src/pages/workspace/rules/AgentRules/useAgentRulePromptCheck.ts b/src/pages/workspace/rules/AgentRules/useAgentRulePromptCheck.ts new file mode 100644 index 000000000000..755a4f5a1e30 --- /dev/null +++ b/src/pages/workspace/rules/AgentRules/useAgentRulePromptCheck.ts @@ -0,0 +1,154 @@ +/** + * Checks with Concierge whether RuleBot can carry out an agent rule that the admin wrote, before the rule saves. An + * `impossible` answer stops the save and returns the refusal. A save after the refusal, or any other answer, saves the rule. + */ +import type {FormRef} from '@components/Form/types'; + +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; + +import Log from '@libs/Log'; + +import {checkAgentRulePrompt, stopAgentRuleCheckLoading} from '@userActions/Policy/Rules'; +import type {AgentRuleFormID} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ADD_AGENT_RULE_INPUT_IDS from '@src/types/form/AddAgentRuleForm'; +import type EDIT_AGENT_RULE_INPUT_IDS from '@src/types/form/EditAgentRuleForm'; + +import type {RefObject} from 'react'; + +import {useEffect, useRef, useState} from 'react'; + +const PROMPT_INPUT_ID = ADD_AGENT_RULE_INPUT_IDS.PROMPT satisfies typeof EDIT_AGENT_RULE_INPUT_IDS.PROMPT; + +type UseAgentRulePromptCheckParams = { + policyID: string; + + /** The form that shows the loading state during the check */ + formID: AgentRuleFormID; + + /** The form that shows the suggested rule when the admin tries it */ + formRef: RefObject; + + /** A rule with this text saves without a check, because Concierge already wrote it or the admin saved it before */ + checkedPrompt?: string; + + /** Saves the rule with this text */ + savePrompt: (prompt: string) => void; +}; + +type PendingCheck = { + generationID: string; + prompt: string; +}; + +function useAgentRulePromptCheck({policyID, formID, formRef, checkedPrompt, savePrompt}: UseAgentRulePromptCheckParams) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const [isChecking = false] = useOnyx(formID, {selector: (form) => !!form?.isLoading}); + useAccessibilityAnnouncement(translate('workspace.rules.agentRules.checkingRule'), isChecking, {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true, politeness: 'polite'}); + const pendingCheckRef = useRef(undefined); + const timeoutRef = useRef>(undefined); + const [checkGenerationID, setCheckGenerationID] = useState(); + const generatedRuleID = generatedRule?.generationID; + const generatedRuleState = generatedRule?.state; + const generatedRuleReason = generatedRule?.reason; + const refusal = generatedRuleState === CONST.GENERATED_RULE.STATE.IMPOSSIBLE && !!generatedRuleID && generatedRuleID === checkGenerationID ? generatedRule : undefined; + + const finishCheck = () => { + pendingCheckRef.current = undefined; + clearTimeout(timeoutRef.current); + stopAgentRuleCheckLoading(formID); + }; + + useEffect(() => { + const pendingCheck = pendingCheckRef.current; + if (!pendingCheck || !generatedRuleState || generatedRuleID !== pendingCheck.generationID) { + return; + } + finishCheck(); + if (generatedRuleState === CONST.GENERATED_RULE.STATE.IMPOSSIBLE) { + Log.info('[GenerateRule] Warned about an agent rule before it saved', false, {policyID, generationID: generatedRuleID, reason: generatedRuleReason}); + return; + } + savePrompt(pendingCheck.prompt); + }, [finishCheck, generatedRuleID, generatedRuleReason, generatedRuleState, policyID, savePrompt]); + + // The loading state stays in Onyx, and a reload during a check does not run the cleanup. No check runs when the form + // mounts, therefore stop the loading state then too. Also stop it when the admin leaves during a check. + useEffect(() => { + stopAgentRuleCheckLoading(formID); + return () => { + clearTimeout(timeoutRef.current); + if (!pendingCheckRef.current) { + return; + } + stopAgentRuleCheckLoading(formID); + }; + }, [formID]); + + const startCheck = (prompt: string) => { + const generationID = checkAgentRulePrompt(formID, policyID, prompt); + pendingCheckRef.current = {generationID, prompt}; + setCheckGenerationID(generationID); + + // The rule must still save when the answer never arrives + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if (pendingCheckRef.current?.generationID !== generationID) { + return; + } + Log.info('[GenerateRule] Saved an agent rule without an answer to its check', false, {policyID, generationID}); + finishCheck(); + savePrompt(prompt); + }, CONST.GENERATED_RULE.AGENT_RULE_CHECK_TIMEOUT_MS); + }; + + /** Saves the rule, after a check when the admin wrote or changed the text */ + const checkThenSavePrompt = (prompt: string) => { + if (refusal) { + Log.info('[GenerateRule] Saved an agent rule after a warning', false, {policyID, generationID: refusal.generationID, reason: refusal.reason}); + setCheckGenerationID(undefined); + savePrompt(prompt); + return; + } + const isUnchanged = !!checkedPrompt && prompt.trim() === checkedPrompt.trim(); + // GenerateRule needs a connection. Offline, the rule saves without a check. + if (isUnchanged || isOffline) { + savePrompt(prompt); + return; + } + startCheck(prompt); + }; + + /** Shows the suggested rule of the refusal in place of the admin's text, and checks it */ + const trySuggestedPrompt = (suggestedPrompt: string) => { + Log.info('[GenerateRule] Tried the suggested agent rule', false, {policyID, generationID: refusal?.generationID, reason: refusal?.reason}); + formRef.current?.resetForm({[PROMPT_INPUT_ID]: suggestedPrompt}); + + // Start the check after the reset, so that a text change from the reset cannot stop it + startCheck(suggestedPrompt); + }; + + /** + * Stops a running check and hides the refusal when the admin changes the text. A running check must not save the text + * from before the change, so the admin saves again to check the new text. + */ + const resetCheck = () => { + setCheckGenerationID(undefined); + if (!pendingCheckRef.current) { + return; + } + Log.info('[GenerateRule] Stopped an agent rule check after the text changed', false, {policyID, generationID: pendingCheckRef.current.generationID}); + finishCheck(); + }; + + return {refusal, isChecking, checkThenSavePrompt, trySuggestedPrompt, resetCheck}; +} + +export default useAgentRulePromptCheck; diff --git a/src/pages/workspace/rules/DescribeRuleForm.tsx b/src/pages/workspace/rules/DescribeRuleForm.tsx index a14ee5657ad2..07dd73856b9c 100644 --- a/src/pages/workspace/rules/DescribeRuleForm.tsx +++ b/src/pages/workspace/rules/DescribeRuleForm.tsx @@ -1,16 +1,13 @@ /** * Form where an admin describes a rule in plain English and Concierge builds the rule. */ -import Button from '@components/Button'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import Text from '@components/Text'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -28,11 +25,10 @@ import { } from '@userActions/Policy/Rules'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/NewRulePromptForm'; import type {PolicyCategories, PolicyCategory} from '@src/types/onyx'; -import type {GeneratedRuleImpossibleReason, GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; +import type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type {ReactNode} from 'react'; import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; @@ -42,6 +38,9 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import ImpossibleRuleNotice from './ImpossibleRuleNotice'; +import useSuggestionButtonRow from './useSuggestionButtonRow'; + type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; type DescribeRuleFormProps = { @@ -101,15 +100,6 @@ type PendingGeneration = { const GENERATED_RULE_TYPES = new Set(Object.values(CONST.GENERATED_RULE.RULE_TYPE)); -// A reason that this app version does not know gets the general title -const IMPOSSIBLE_TITLE_BY_REASON: Partial> = { - [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_TRIGGER]: 'workspace.rules.newRule.impossible.noTrigger', - [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_CAPABILITY]: 'workspace.rules.newRule.impossible.noCapability', - [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.MISSING_DATA]: 'workspace.rules.newRule.impossible.missingData', - [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.RETROACTIVE]: 'workspace.rules.newRule.impossible.retroactive', - [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.HISTORY_LIMIT]: 'workspace.rules.newRule.impossible.historyLimit', -} satisfies Record; - /** Returns whether a description with this answer can become an agent rule */ function isAgentRuleFallbackState(state: GeneratedRuleState): boolean { return state === CONST.GENERATED_RULE.STATE.UNSUPPORTED || state === CONST.GENERATED_RULE.STATE.MULTIPLE_RULES || state === CONST.GENERATED_RULE.STATE.AGENT_RULE; @@ -148,7 +138,6 @@ function DescribeRuleForm({ onFinish, }: DescribeRuleFormProps) { const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const styles = useThemeStyles(); const formRef = useRef(null); const inputRef = useRef(null); @@ -166,10 +155,6 @@ function DescribeRuleForm({ const refusal = generatedRuleState === CONST.GENERATED_RULE.STATE.IMPOSSIBLE && !!generatedRuleID && generatedRuleID === submittedGenerationID ? generatedRule : undefined; const refusalSuggestedPrompt = refusal?.suggestedPrompt; const canCreateAnyway = !!refusalSuggestedPrompt && !!onAgentRuleFallback; - // An answer without an explanation still says why, with the general text for its reason - const refusalExplanation = - refusal && (refusal.explanation ? refusal.explanation : translate(IMPOSSIBLE_TITLE_BY_REASON[refusal.reason ?? ''] ?? 'workspace.rules.newRule.impossible.noCapability')); - const refusalDetail = refusalExplanation && refusalSuggestedPrompt ? translate('workspace.rules.newRule.impossible.explanationWithSuggestion', refusalExplanation) : refusalExplanation; const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); const finish = () => { @@ -306,6 +291,8 @@ function DescribeRuleForm({ describeRule(suggestedPrompt); }; + const suggestionButtonRow = useSuggestionButtonRow(refusalSuggestedPrompt, describeSuggestedRule); + return ( describeSuggestedRule(refusalSuggestedPrompt)} - // Like the form's submit button, this button sends GenerateRule, which needs a connection - isDisabled={isOffline} - style={[styles.flexGrow1, styles.flexBasisAuto]} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.DESCRIBE_RULE_TRY_SUGGESTION} - > - {translate('workspace.rules.newRule.impossible.useSuggestion')} - - ) - } + isSubmitButtonNeutral={suggestionButtonRow.isSubmitButtonNeutral} + submitButtonAndFooterContainerStyles={suggestionButtonRow.containerStyles} + submitButtonInnerStyles={suggestionButtonRow.submitButtonStyles} + footerContent={suggestionButtonRow.footerContent} // An empty description sends no request, so nothing would stop a loading state that starts on the press. shouldShowLoadingImmediatelyOnPress={!canSkipDescription} keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY} @@ -372,12 +343,7 @@ function DescribeRuleForm({ inputStyle={[styles.flex1, styles.textAlignVerticalTop]} /> - {!!refusal && ( - - {!!refusalDetail && {refusalDetail}} - {!!refusalSuggestedPrompt && {translate('workspace.rules.newRule.impossible.quotedSuggestion', refusalSuggestedPrompt)}} - - )} + {!!refusal && } ); } diff --git a/src/pages/workspace/rules/ImpossibleRuleNotice.tsx b/src/pages/workspace/rules/ImpossibleRuleNotice.tsx new file mode 100644 index 000000000000..af8bb237cd5a --- /dev/null +++ b/src/pages/workspace/rules/ImpossibleRuleNotice.tsx @@ -0,0 +1,53 @@ +/** + * Tells the admin why Concierge refused a rule, and quotes the closest rule that RuleBot can carry out. + */ +import Text from '@components/Text'; + +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {GeneratedRule} from '@src/types/onyx'; +import type {GeneratedRuleImpossibleReason} from '@src/types/onyx/GeneratedRule'; + +import React from 'react'; +import {View} from 'react-native'; + +type ImpossibleRuleNoticeProps = { + /** The `impossible` answer of Concierge */ + refusal: GeneratedRule; +}; + +// A reason that this app version does not know gets the general text +const IMPOSSIBLE_TITLE_BY_REASON: Partial> = { + [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_TRIGGER]: 'workspace.rules.newRule.impossible.noTrigger', + [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_CAPABILITY]: 'workspace.rules.newRule.impossible.noCapability', + [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.MISSING_DATA]: 'workspace.rules.newRule.impossible.missingData', + [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.RETROACTIVE]: 'workspace.rules.newRule.impossible.retroactive', + [CONST.GENERATED_RULE.IMPOSSIBLE_REASON.HISTORY_LIMIT]: 'workspace.rules.newRule.impossible.historyLimit', +} satisfies Record; + +function ImpossibleRuleNotice({refusal}: ImpossibleRuleNoticeProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const suggestedPrompt = refusal.suggestedPrompt; + + // An answer without an explanation still says why, with the general text for its reason + const explanation = refusal.explanation ? refusal.explanation : translate(IMPOSSIBLE_TITLE_BY_REASON[refusal.reason ?? ''] ?? 'workspace.rules.newRule.impossible.noCapability'); + const detail = suggestedPrompt ? translate('workspace.rules.newRule.impossible.explanationWithSuggestion', explanation) : explanation; + const quotedSuggestion = suggestedPrompt ? translate('workspace.rules.newRule.impossible.quotedSuggestion', suggestedPrompt) : ''; + useAccessibilityAnnouncement(quotedSuggestion ? `${detail} ${quotedSuggestion}` : detail, true, {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true}); + + return ( + + {detail} + {!!quotedSuggestion && {quotedSuggestion}} + + ); +} + +ImpossibleRuleNotice.displayName = 'ImpossibleRuleNotice'; + +export default ImpossibleRuleNotice; diff --git a/src/pages/workspace/rules/useSuggestionButtonRow.tsx b/src/pages/workspace/rules/useSuggestionButtonRow.tsx new file mode 100644 index 000000000000..0a3eaee6d1e6 --- /dev/null +++ b/src/pages/workspace/rules/useSuggestionButtonRow.tsx @@ -0,0 +1,65 @@ +/** + * Puts a green Use suggestion button beside the submit button of a rule form when Concierge refused the rule with a + * suggestion. The submit button turns neutral, so that the suggestion is the suggested action. + */ +import Button from '@components/Button'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +type SuggestionButtonRow = { + /** Whether the form's submit button has the neutral color */ + isSubmitButtonNeutral: boolean; + + /** The style of the view around the submit button and the Use suggestion button */ + containerStyles?: StyleProp; + + /** The style of the submit button */ + submitButtonStyles?: StyleProp; + + /** The Use suggestion button, for the form's footer */ + footerContent: ReactNode; +}; + +function useSuggestionButtonRow(suggestedPrompt: string | undefined, onUseSuggestion: (suggestedPrompt: string) => void): SuggestionButtonRow { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + + if (!suggestedPrompt) { + return {isSubmitButtonNeutral: false, footerContent: null}; + } + + return { + isSubmitButtonNeutral: true, + + // The buttons share a row and wrap to two rows when a translated label does not fit beside the other one + containerStyles: [styles.flexRow, styles.flexWrap, styles.gap2], + + // The form puts a bottom margin on the submit button when the footer follows it. Side by side, the buttons must line up. + submitButtonStyles: [styles.flexGrow1, styles.flexBasisAuto, styles.mb0], + footerContent: ( + + ), + }; +} + +export default useSuggestionButtonRow; diff --git a/tests/ui/AddAgentRulePageTest.tsx b/tests/ui/AddAgentRulePageTest.tsx index 0ae39b2b32c9..6bb2f67ee256 100644 --- a/tests/ui/AddAgentRulePageTest.tsx +++ b/tests/ui/AddAgentRulePageTest.tsx @@ -33,13 +33,14 @@ jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); jest.mock('@hooks/usePolicy', () => jest.fn()); jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn(() => Promise.resolve()), closeModal: jest.fn()}))); jest.mock('@hooks/useDiscardChangesConfirmation', () => jest.fn(() => ({suppressDiscardPrompt: jest.fn()}))); +// `dNone` hides the inactive tab, and a query does not find an element that the admin cannot see jest.mock('@hooks/useThemeStyles', () => jest.fn( () => new Proxy( {}, { - get: () => ({}), + get: (_target, styleName) => (styleName === 'dNone' ? {display: 'none'} : {}), }, ), ), @@ -104,6 +105,7 @@ const mockSuggestionPrompt = 'Hold any expense over $75 without a receipt'; // The prompt that the mocked Write tab saves. A test sets it to the suggestion's prompt to save the suggestion unchanged. let mockSavedPrompt = 'Hold gift card expenses'; +let mockWriteTabMountCount = 0; jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleSuggestionsTab', () => { const ReactModule = jest.requireActual('react'); @@ -120,8 +122,18 @@ jest.mock('@pages/workspace/rules/AgentRules/AddAgentRuleWriteTab', () => { Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; Text: React.ComponentType<{children?: React.ReactNode}>; }>('react-native'); - return ({onSave}: {onSave: (values: {prompt: string}) => void}) => - ReactModule.createElement(Pressable, {accessibilityLabel: 'WriteTab', onPress: () => onSave({prompt: mockSavedPrompt})}, ReactModule.createElement(Text, null, 'WriteTab')); + return ({onSave, checkedPrompt, isTabActive}: {onSave: (values: {prompt: string}) => void; checkedPrompt?: string; isTabActive: boolean}) => { + ReactModule.useEffect(() => { + mockWriteTabMountCount += 1; + }, []); + return ReactModule.createElement( + Pressable, + {accessibilityLabel: 'WriteTab', onPress: () => onSave({prompt: mockSavedPrompt})}, + ReactModule.createElement(Text, null, 'WriteTab'), + ReactModule.createElement(Text, null, `CheckedPrompt: ${checkedPrompt ?? 'none'}`), + ReactModule.createElement(Text, null, `IsTabActive: ${String(isTabActive)}`), + ); + }; }); type AddAgentRulePageProps = PlatformStackScreenProps; @@ -159,6 +171,7 @@ describe('AddAgentRulePage', () => { beforeEach(() => { jest.clearAllMocks(); mockSavedPrompt = PROMPT; + mockWriteTabMountCount = 0; mockedUsePolicy.mockReturnValue({ ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID, @@ -192,7 +205,7 @@ describe('AddAgentRulePage', () => { await waitForBatchedUpdatesWithAct(); // Then the admin sees the drafted prompt and does not have to look for it - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); }); it('opens on the Write tab when the draft prompt arrives after the first render', async () => { @@ -206,7 +219,73 @@ describe('AddAgentRulePage', () => { await waitForBatchedUpdatesWithAct(); // Then the page still switches to Write, because the first render must not decide the tab for good - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); + }); + + it('lets the draft of the describe flow save without another check', async () => { + // Given an agent rule that Concierge wrote in the describe flow + await setAgentRuleDraft(PROMPT); + await setAgentRuleAnswer(GENERATION_ID); + + // When the page renders with the generationID of that answer + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab treats the agent rule as checked, because Concierge already answered that RuleBot can do it + expect(screen.getByText(`CheckedPrompt: ${PROMPT}`)).toBeOnTheScreen(); + }); + + it('checks a changed draft of the describe flow after a reload', async () => { + // Given the admin changed the agent rule that Concierge wrote, and a reload kept the changed draft + await setAgentRuleDraft('Text the approver whenever a report is submitted'); + await setAgentRuleAnswer(GENERATION_ID); + + // When the page renders again with the generationID of that answer + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then only the text of Concierge counts as checked, so that the changed text gets a check before it saves + expect(screen.getByText(`CheckedPrompt: ${PROMPT}`)).toBeOnTheScreen(); + }); + + it('checks a draft that holds the description of the admin', async () => { + // Given Concierge refused the description, and the admin chose to create an agent rule from it anyway + await setAgentRuleDraft(PROMPT); + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, {generationID: GENERATION_ID, state: CONST.GENERATED_RULE.STATE.IMPOSSIBLE, suggestedPrompt: 'Hold gift card expenses after they post'}); + }); + + // When the page renders with the generationID of that answer + renderAddAgentRulePage(GENERATION_ID); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab checks the draft, because no answer keeps the description to show that the admin left it unchanged + expect(screen.getByText('CheckedPrompt: none')).toBeOnTheScreen(); + }); + + it('checks a draft that has no generationID', async () => { + // Given a draft that the admin typed on an earlier visit + await setAgentRuleDraft(PROMPT); + + // When the page renders without a generationID + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab checks the draft before it saves, because Concierge never saw it + expect(screen.getByText('CheckedPrompt: none')).toBeOnTheScreen(); + }); + + it('lets a picked suggestion save without a check', async () => { + // Given the page opens on the suggestions + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // When the admin picks a suggestion + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + + // Then the Write tab treats the suggestion as checked, because Concierge wrote it + expect(screen.getByText('CheckedPrompt: Hold any expense over $75 without a receipt')).toBeOnTheScreen(); }); it('shows the fallback banner when the page opens with a generationID', async () => { @@ -256,7 +335,7 @@ describe('AddAgentRulePage', () => { await waitForBatchedUpdatesWithAct(); // Then the Write tab opens without the banner, because no description was turned into this rule - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); }); @@ -271,7 +350,7 @@ describe('AddAgentRulePage', () => { // Then the page closes and the admin returns to the previous screen, because they did not come from the Suggestions tab expect(Navigation.goBack).toHaveBeenCalledTimes(1); - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); }); it('switches from the Write tab to Suggestions on Back without a generationID', async () => { @@ -288,6 +367,52 @@ describe('AddAgentRulePage', () => { expect(screen.queryByText('WriteTab')).toBeNull(); }); + it('keeps the Write tab mounted while the admin looks at Suggestions', async () => { + // Given the Write tab is open for a typed draft + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // When the admin goes to Suggestions and comes back without picking one + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.writeTab')); + await waitForBatchedUpdatesWithAct(); + + // Then the same Write tab shows, because a remount would stop a running check and drop its warning and a tried suggestion + expect(screen.getByText('WriteTab')).toBeVisible(); + expect(mockWriteTabMountCount).toBe(1); + }); + + it('stops Enter from submitting the Write tab while the admin looks at Suggestions', async () => { + // Given the Write tab is open for a typed draft + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('IsTabActive: true')).toBeTruthy(); + + // When the admin goes to Suggestions + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + await waitForBatchedUpdatesWithAct(); + + // Then the hidden Write tab is inactive, because Enter must not save a rule that the admin cannot see + expect(screen.getByText('IsTabActive: false', {includeHiddenElements: true})).toBeTruthy(); + }); + + it('mounts a new Write tab when the admin picks a suggestion', async () => { + // Given the Write tab is open for a typed draft + await setAgentRuleDraft(PROMPT); + renderAddAgentRulePage(); + await waitForBatchedUpdatesWithAct(); + + // When the admin picks a suggestion + fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); + fireEvent.press(screen.getByLabelText('PickSuggestion')); + await waitForBatchedUpdatesWithAct(); + + // Then a new Write tab shows the suggestion, because a check of the typed text must not save it in place of the suggestion + expect(mockWriteTabMountCount).toBe(2); + }); + it('drops the fallback banner and log after the admin picks a suggestion', async () => { // Given the describe flow opened the page with a generationID and the fallback banner const logSpy = jest.spyOn(Log, 'info'); @@ -300,7 +425,7 @@ describe('AddAgentRulePage', () => { fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); fireEvent.press(screen.getByLabelText('PickSuggestion')); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); expect(screen.queryByText(FALLBACK_BANNER)).toBeNull(); fireEvent.press(screen.getByLabelText('WriteTab')); @@ -318,7 +443,7 @@ describe('AddAgentRulePage', () => { fireEvent.press(screen.getByLabelText('workspace.rules.agentRules.suggestionsTab')); fireEvent.press(screen.getByLabelText('PickSuggestion')); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText('WriteTab')).toBeOnTheScreen(); + expect(screen.getByText('WriteTab')).toBeVisible(); // When the admin presses Back fireEvent.press(screen.getByLabelText('Back')); diff --git a/tests/ui/AgentRulePromptCheckTest.tsx b/tests/ui/AgentRulePromptCheckTest.tsx new file mode 100644 index 000000000000..439532f9decb --- /dev/null +++ b/tests/ui/AgentRulePromptCheckTest.tsx @@ -0,0 +1,514 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import usePolicy from '@hooks/usePolicy'; + +import * as API from '@libs/API'; +import type {GenerateRuleParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import AddAgentRuleWriteTab from '@pages/workspace/rules/AgentRules/AddAgentRuleWriteTab'; +import EditAgentRulePage from '@pages/workspace/rules/AgentRules/EditAgentRulePage'; +import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; + +import {updatePolicyAgentRule} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {GeneratedRule} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +let mockPrompt = ''; +let mockOnPromptChange: ((value: string) => void) | undefined; +let mockIsOffline = false; + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string, ...params: unknown[]) => [key, ...params].join(' '), + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: mockIsOffline}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@hooks/useAccessibilityAnnouncement', () => jest.fn()); +jest.mock('@pages/workspace/rules/AgentRules/useAgentPromptInputStyles', () => jest.fn(() => ({}))); +jest.mock('@pages/workspace/rules/useRuleDeleteHeaderProps', () => jest.fn(() => ({deleteIconButtonProps: undefined}))); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/CollapsibleHeaderOnKeyboard', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => () => null); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@userActions/Policy/Rules', () => ({ + ...jest.requireActual>('@userActions/Policy/Rules'), + updatePolicyAgentRule: jest.fn(), + deletePolicyAgentRule: jest.fn(), +})); +jest.mock('@components/Text', () => { + const ReactModule = jest.requireActual('react'); + const {Text} = jest.requireActual<{Text: React.ComponentType<{children?: React.ReactNode}>}>('react-native'); + return ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); +}); +jest.mock('@components/Button', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + const MockButton = ({onPress, children}: {onPress: () => void; children: React.ReactNode}) => ReactModule.createElement(Pressable, {onPress}, children); + MockButton.Text = ({children}: {children: React.ReactNode}) => ReactModule.createElement(Text, null, children); + return MockButton; +}); +jest.mock('@components/Form/InputWrapper', () => ({onValueChange}: {onValueChange?: (value: string) => void}) => { + mockOnPromptChange = onValueChange; + return null; +}); +jest.mock('@components/Form/FormProvider', () => { + const ReactModule = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{ + Pressable: React.ComponentType<{accessibilityLabel?: string; onPress?: () => void; children?: React.ReactNode}>; + Text: React.ComponentType<{children?: React.ReactNode}>; + }>('react-native'); + // `resetForm` also reports a text change, so that a test fails when a reset can stop the check that follows it + return ({ + children, + onSubmit, + submitButtonText, + footerContent, + ref, + }: { + children: React.ReactNode; + onSubmit: (values: {prompt: string}) => void; + submitButtonText: string; + footerContent?: React.ReactNode; + ref?: React.Ref<{resetForm: (values: {prompt: string}) => void}>; + }) => { + ReactModule.useImperativeHandle(ref, () => ({ + resetForm: (values: {prompt: string}) => { + mockPrompt = values.prompt; + mockOnPromptChange?.(values.prompt); + }, + })); + return ReactModule.createElement( + ReactModule.Fragment, + null, + children, + ReactModule.createElement( + Pressable, + {accessibilityLabel: submitButtonText, onPress: () => onSubmit({prompt: mockPrompt})}, + ReactModule.createElement(Text, null, submitButtonText), + ), + footerContent, + ); + }; +}); + +const POLICY_ID = 'policy1'; +const RULE_ID = 'rule1'; +const TYPED_PROMPT = "Text the approver's phone when a report is submitted"; +const SUGGESTED_PROMPT = 'When a report is submitted, send the approver a direct message'; +const CREATE_RULE = 'workspace.rules.agentRules.createRule'; +const CREATE_ANYWAY = 'workspace.rules.newRule.impossible.createAnyway'; +const USE_SUGGESTION = 'workspace.rules.newRule.impossible.useSuggestion'; + +type WriteSpy = jest.SpiedFunction; +type EditAgentRulePageProps = PlatformStackScreenProps; + +function isGenerateRuleParams(parameters: unknown): parameters is GenerateRuleParams { + return typeof parameters === 'object' && parameters !== null && 'generationID' in parameters && 'prompt' in parameters; +} + +/** Returns the parameters of every GenerateRule request, oldest first */ +function getGenerateRuleRequests(writeSpy: WriteSpy): GenerateRuleParams[] { + return writeSpy.mock.calls + .filter(([command]) => command === WRITE_COMMANDS.GENERATE_RULE) + .map(([, parameters]) => parameters) + .filter(isGenerateRuleParams); +} + +function getLastGenerationID(writeSpy: WriteSpy): string { + const generationID = getGenerateRuleRequests(writeSpy).at(-1)?.generationID; + if (!generationID) { + throw new Error('The form did not request GenerateRule'); + } + return generationID; +} + +async function renderWriteTab(onSave: jest.Mock, checkedPrompt?: string) { + render( + , + ); + await waitForBatchedUpdatesWithAct(); +} + +async function submit(prompt: string, buttonLabel = CREATE_RULE) { + mockPrompt = prompt; + fireEvent.press(screen.getByLabelText(buttonLabel)); + await waitForBatchedUpdatesWithAct(); +} + +async function answerCheck(answer: GeneratedRule) { + await act(async () => { + await Onyx.set(ONYXKEYS.GENERATED_RULE, answer); + }); + await waitForBatchedUpdatesWithAct(); +} + +function buildImpossibleAnswer(generationID: string): GeneratedRule { + return { + generationID, + state: CONST.GENERATED_RULE.STATE.IMPOSSIBLE, + reason: CONST.GENERATED_RULE.IMPOSSIBLE_REASON.NO_CAPABILITY, + explanation: "Agents can't send text messages.", + suggestedPrompt: SUGGESTED_PROMPT, + }; +} + +describe('Agent rule prompt check', () => { + let writeSpy: WriteSpy; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrompt = ''; + mockIsOffline = false; + writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + }); + + afterEach(async () => { + jest.useRealTimers(); + writeSpy.mockRestore(); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('saves a typed rule after Concierge answers that RuleBot can do it', async () => { + // Given an admin who typed an agent rule + const onSave = jest.fn(); + await renderWriteTab(onSave); + await submit(TYPED_PROMPT); + + // Then the rule waits for the check, which asks for any rule type because the admin did not pick one + expect(onSave).not.toHaveBeenCalled(); + const [request] = getGenerateRuleRequests(writeSpy); + expect(request).toMatchObject({policyID: POLICY_ID, prompt: TYPED_PROMPT}); + expect(request?.ruleType).toBeUndefined(); + + // When Concierge answers with an agent rule + await answerCheck({generationID: getLastGenerationID(writeSpy), state: CONST.GENERATED_RULE.STATE.AGENT_RULE}); + + // Then the rule saves with the text the admin typed, because only an impossible answer stops the save + expect(onSave).toHaveBeenCalledWith({prompt: TYPED_PROMPT}); + }); + + it('warns about an impossible rule and saves it when the admin taps Create anyway', async () => { + // Given an admin who typed a rule that RuleBot cannot carry out + const onSave = jest.fn(); + await renderWriteTab(onSave); + await submit(TYPED_PROMPT); + + // When Concierge answers that the rule is impossible + await answerCheck(buildImpossibleAnswer(getLastGenerationID(writeSpy))); + + // Then the rule does not save, and the form shows why with the suggested rule + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText("workspace.rules.newRule.impossible.explanationWithSuggestion Agents can't send text messages.")).toBeTruthy(); + expect(screen.getByText(`workspace.rules.newRule.impossible.quotedSuggestion ${SUGGESTED_PROMPT}`)).toBeTruthy(); + expect(screen.getByText(USE_SUGGESTION)).toBeTruthy(); + + // When the admin taps Create anyway + await submit(TYPED_PROMPT, CREATE_ANYWAY); + + // Then the rule saves without another check, because the check only warns + expect(onSave).toHaveBeenCalledWith({prompt: TYPED_PROMPT}); + expect(getGenerateRuleRequests(writeSpy)).toHaveLength(1); + }); + + it('announces the check and the warning to screen readers', async () => { + // Given an admin who typed a rule, and an API that applies the loading state of the check + writeSpy.mockImplementation((_command, _parameters, onyxData) => { + Onyx.update(onyxData?.optimisticData ?? []); + return Promise.resolve(); + }); + await renderWriteTab(jest.fn()); + + // When the admin taps Create rule + await submit(TYPED_PROMPT); + + // Then a screen reader hears that the check runs, because the save waits for it without a visible page change + expect(useAccessibilityAnnouncement).toHaveBeenCalledWith('workspace.rules.agentRules.checkingRule', true, { + shouldAnnounceOnWeb: true, + shouldAnnounceOnNative: true, + politeness: 'polite', + }); + + // When Concierge answers that the rule is impossible + await answerCheck(buildImpossibleAnswer(getLastGenerationID(writeSpy))); + + // Then a screen reader hears why and the suggested rule, because the warning stops the save + expect(useAccessibilityAnnouncement).toHaveBeenCalledWith( + `workspace.rules.newRule.impossible.explanationWithSuggestion Agents can't send text messages. workspace.rules.newRule.impossible.quotedSuggestion ${SUGGESTED_PROMPT}`, + true, + {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true}, + ); + }); + + it('checks the suggested rule when the admin taps Use suggestion', async () => { + // Given Concierge refused the typed rule + const onSave = jest.fn(); + await renderWriteTab(onSave); + await submit(TYPED_PROMPT); + await answerCheck(buildImpossibleAnswer(getLastGenerationID(writeSpy))); + + // When the admin taps Use suggestion, and Concierge answers that RuleBot can do the suggestion + fireEvent.press(screen.getByText(USE_SUGGESTION)); + await waitForBatchedUpdatesWithAct(); + await answerCheck({generationID: getLastGenerationID(writeSpy), state: CONST.GENERATED_RULE.STATE.AGENT_RULE}); + + // Then the suggested rule is checked and saved in place of the typed rule + expect(getGenerateRuleRequests(writeSpy).at(-1)?.prompt).toBe(SUGGESTED_PROMPT); + expect(onSave).toHaveBeenCalledWith({prompt: SUGGESTED_PROMPT}); + }); + + it('saves an unchanged suggestion without a check', async () => { + // Given an admin who picked a suggested rule and did not change it + const onSave = jest.fn(); + await renderWriteTab(onSave, SUGGESTED_PROMPT); + + // When the admin creates the rule + await submit(SUGGESTED_PROMPT); + + // Then the rule saves at once, because Concierge already wrote the suggestion + expect(onSave).toHaveBeenCalledWith({prompt: SUGGESTED_PROMPT}); + expect(getGenerateRuleRequests(writeSpy)).toHaveLength(0); + }); + + it('saves a typed rule without a check when the admin is offline', async () => { + // Given an admin who typed an agent rule while offline + mockIsOffline = true; + const onSave = jest.fn(); + await renderWriteTab(onSave); + + // When the admin creates the rule + await submit(TYPED_PROMPT); + + // Then the rule saves at once, because the check cannot run offline + expect(onSave).toHaveBeenCalledWith({prompt: TYPED_PROMPT}); + expect(getGenerateRuleRequests(writeSpy)).toHaveLength(0); + }); + + it('stops the check and keeps the page open when the admin edits the text during the check', async () => { + // Given an admin who typed an agent rule, and the check has not answered + const onSave = jest.fn(); + await renderWriteTab(onSave); + await submit(TYPED_PROMPT); + const generationID = getLastGenerationID(writeSpy); + + // When the admin changes the text, and the answer for the old text arrives after that + act(() => { + mockOnPromptChange?.(`${TYPED_PROMPT} and the submitter`); + }); + await waitForBatchedUpdatesWithAct(); + await answerCheck({generationID, state: CONST.GENERATED_RULE.STATE.AGENT_RULE}); + + // Then nothing saves and the form stops loading, because a save now would lose the admin's edit + expect(onSave).not.toHaveBeenCalled(); + expect((await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM))?.isLoading).toBe(false); + }); + + it('saves the rule when the check fails', async () => { + // Given an admin who typed an agent rule + const onSave = jest.fn(); + await renderWriteTab(onSave); + await submit(TYPED_PROMPT); + + // When the check fails + await answerCheck({generationID: getLastGenerationID(writeSpy), state: CONST.GENERATED_RULE.STATE.FAILED}); + + // Then the rule saves, because a failed check must not block the admin + expect(onSave).toHaveBeenCalledWith({prompt: TYPED_PROMPT}); + }); + + it('saves the rule when the answer does not arrive in time', async () => { + // Given an admin who typed an agent rule, and Concierge does not answer. The batched updates helper runs every + // pending timer when timers are fake, so this test flushes the microtasks itself. + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + const flushMicrotasks = () => + act(async () => { + await Promise.resolve(); + }); + const onSave = jest.fn(); + render( + , + ); + await flushMicrotasks(); + mockPrompt = TYPED_PROMPT; + fireEvent.press(screen.getByLabelText(CREATE_RULE)); + await flushMicrotasks(); + + // When the time limit has not passed yet + act(() => { + jest.advanceTimersByTime(CONST.GENERATED_RULE.AGENT_RULE_CHECK_TIMEOUT_MS - 1); + }); + + // Then the rule still waits for the answer + expect(onSave).not.toHaveBeenCalled(); + + // When the time limit passes + act(() => { + jest.advanceTimersByTime(1); + }); + + // Then the rule saves, because a slow check must not block the admin + expect(onSave).toHaveBeenCalledWith({prompt: TYPED_PROMPT}); + }); + + it('stops the loading state when the admin leaves during a check', async () => { + // Given an admin who typed an agent rule, and the check has not answered + const onSave = jest.fn(); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + mockPrompt = TYPED_PROMPT; + fireEvent.press(screen.getByLabelText(CREATE_RULE)); + await act(async () => { + await Promise.resolve(); + }); + + // When the admin leaves the page + screen.unmount(); + await waitForBatchedUpdatesWithAct(); + + // Then the form is not loading, because the next visit must not open with a loading button + expect((await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM))?.isLoading).toBe(false); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('stops a loading state that a reload left during a check', async () => { + // Given a reload during a check, which leaves the loading state in Onyx without the cleanup + await act(async () => { + await Onyx.merge(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM, {isLoading: true}); + }); + + // When the form mounts again + await renderWriteTab(jest.fn()); + + // Then the form is not loading, because no check runs after the reload + expect((await getOnyxValue(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM))?.isLoading).toBe(false); + expect(screen.getByText('workspace.rules.agentRules.disclaimer')).toBeTruthy(); + }); + + describe('EditAgentRulePage', () => { + const SAVED_PROMPT = 'Hold any expense over $75 without a receipt'; + + async function renderEditPage() { + jest.mocked(usePolicy).mockReturnValue({ + ...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), + id: POLICY_ID, + rules: {agentRules: {[RULE_ID]: {ruleID: RULE_ID, prompt: SAVED_PROMPT, title: 'Receipts', created: '2026-10-01 00:00:00'}}}, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only screen props. The page reads only route.params. + const props = { + route: {key: 'Rules_Agent_Edit', name: SCREENS.WORKSPACE.RULES_AGENT_EDIT, params: {policyID: POLICY_ID, ruleID: RULE_ID}}, + navigation: {}, + } as unknown as EditAgentRulePageProps; + render(); + await waitForBatchedUpdatesWithAct(); + } + + it('saves an unchanged rule without a check', async () => { + // Given an admin who opened a saved agent rule + await renderEditPage(); + + // When the admin saves it without changing the text + await submit(SAVED_PROMPT, 'common.save'); + + // Then the page closes without a check or an update, because nothing changed + expect(getGenerateRuleRequests(writeSpy)).toHaveLength(0); + expect(updatePolicyAgentRule).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalled(); + }); + + it('warns about an impossible edit and saves it when the admin taps Save anyway', async () => { + // Given an admin who changed a saved agent rule to one that RuleBot cannot carry out + await renderEditPage(); + await submit(TYPED_PROMPT, 'common.save'); + + // When Concierge answers that the rule is impossible + await answerCheck(buildImpossibleAnswer(getLastGenerationID(writeSpy))); + + // Then the rule does not update until the admin taps Save anyway + expect(updatePolicyAgentRule).not.toHaveBeenCalled(); + await submit(TYPED_PROMPT, 'workspace.rules.agentRules.saveAnyway'); + expect(updatePolicyAgentRule).toHaveBeenCalledWith(POLICY_ID, RULE_ID, TYPED_PROMPT, SAVED_PROMPT, 'Receipts'); + }); + + it('hides the delete button during the check', async () => { + // Given an admin who opened a saved agent rule, and an API that applies the loading state of the check + writeSpy.mockImplementation((_command, _parameters, onyxData) => { + Onyx.update(onyxData?.optimisticData ?? []); + return Promise.resolve(); + }); + await renderEditPage(); + expect(jest.mocked(useRuleDeleteHeaderProps).mock.lastCall?.[0].canDelete).toBe(true); + + // When the admin saves a changed rule and the check starts + await submit(TYPED_PROMPT, 'common.save'); + + // Then the admin cannot delete, because the save at the end of the check goes back one page under the confirmation + expect(jest.mocked(useRuleDeleteHeaderProps).mock.lastCall?.[0].canDelete).toBe(false); + + // And the admin can delete again after Concierge answers + await answerCheck(buildImpossibleAnswer(getLastGenerationID(writeSpy))); + expect(jest.mocked(useRuleDeleteHeaderProps).mock.lastCall?.[0].canDelete).toBe(true); + }); + }); +}); diff --git a/tests/ui/DescribeRuleModalTest.tsx b/tests/ui/DescribeRuleModalTest.tsx index 781e8a7817ad..031453d3563a 100644 --- a/tests/ui/DescribeRuleModalTest.tsx +++ b/tests/ui/DescribeRuleModalTest.tsx @@ -322,23 +322,14 @@ describe('DescribeRuleModal', () => { }); it('disables Use suggestion and sends no request while offline', async () => { - // Given Concierge answered that the description cannot be done and suggested another one + // Given the admin is offline when Concierge answers that the description cannot be done and suggests another one. + // The mocked network hook cannot re-render the refusal, so the refusal shows for the first time while offline. await renderFlagForReviewModal(jest.fn(), jest.fn()); const generationID = describeRule(writeSpy, 'Text the approver when a report is submitted'); + mockedUseNetwork.mockReturnValue({isOffline: true}); await answerGeneration({...IMPOSSIBLE_ANSWER, generationID}); - // When the admin goes offline and taps Use suggestion - mockedUseNetwork.mockReturnValue({isOffline: true}); - screen.rerender( - , - ); - await waitForBatchedUpdatesWithAct(); + // When the admin taps Use suggestion fireEvent.press(screen.getByText('workspace.rules.newRule.impossible.useSuggestion')); await waitForBatchedUpdatesWithAct();